From f2fbf93adf5fc4e22e0b3f5684b0c3e4a7e1f7f9 Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 16:35:38 +0200 Subject: [PATCH] feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a firm-formatted Schriftsatz skeleton between the per-submission_code template and the generic universal skeleton in the fallback chain. Carries every HL paragraph + character style from the HL Patents Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section, HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature, HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm letterhead (header logo + firm-address footer), plus the full 48-key SubmissionVarsService placeholder bag exercised in a real Schriftsatz layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen → Beweis → Schlussformel) with a locale-aware verification footer covering every DE/EN alias and the rule.* legacy keys. Resolved fallback chain after this CL: 1. per-firm per-submission_code template (submissionTemplateRegistry) 2. _firm-skeleton.docx — HL styles + placeholders (NEW) 3. universal _skeleton.docx — placeholders only 4. HL Patents Style.dotm — letterhead only scripts/gen-hl-skeleton-template/main.go reads the source .dotm, strips VBA macros + ribbon customizations + glossary parts, patches [Content_Types].xml and the document rels, and replaces document.xml with HL-styled paragraphs containing the placeholders. Keeps styles.xml, theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml, fontTable.xml, and media untouched so the firm typography survives. Template uploaded to HL/mWorkRepo at 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx (commit 0a41b45, blob SHA 07f7547d). Verified end-to-end against the in-house renderer with a 48-key sample project: every placeholder substitutes cleanly, no orphan {{ markers, no VBA / glossary / customUI leftovers, header/footer rIds resolve. --- internal/handlers/files.go | 51 ++- internal/handlers/submission_drafts.go | 29 +- scripts/gen-hl-skeleton-template/main.go | 450 +++++++++++++++++++++++ 3 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 scripts/gen-hl-skeleton-template/main.go diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 4682091..827a2f3 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -79,6 +79,24 @@ var fileRegistry = map[string]fileEntry{ RepoName: "mWorkRepo", FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx", }, + // Firm-formatted skeleton (t-paliad-275). Carries the same 48-key + // placeholder bag as the universal _skeleton.docx, but additionally + // preserves every HL paragraph + character style from the HL Patents + // Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section, + // HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead + // (header logo + firm-address footer). Slotted ahead of the universal + // skeleton in the fallback chain so any submission_code without a + // dedicated per-code template still renders as a real firm-branded + // Schriftsatz with variables substituted, rather than a plain skeleton. + // Generated via scripts/gen-hl-skeleton-template against the .dotm. + firmSkeletonSubmissionSlug: { + RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx", + DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx", + }, } // skeletonSubmissionSlug names the universal skeleton template inside @@ -87,6 +105,14 @@ var fileRegistry = map[string]fileEntry{ // the same string the registry uses. const skeletonSubmissionSlug = "submission/_skeleton.docx" +// firmSkeletonSubmissionSlug names the firm-formatted skeleton template +// inside the shared fileRegistry cache (t-paliad-275). Same placeholder +// surface as skeletonSubmissionSlug; carries HL paragraph + character +// styles from the source .dotm on top. Sits between the per-code +// template and the generic universal skeleton in the fallback chain so +// codes without a dedicated template still render with firm branding. +const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx" + // submissionTemplateRegistry maps a deadline-rule submission_code to a // fileRegistry slug. Lookup order matches the cronus design fallback // chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then @@ -219,11 +245,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) { // call warms the cache synchronously from mWorkRepo via Gitea; later // calls return immediately while a background refresh runs. func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) { - entry, ok := fileRegistry[skeletonSubmissionSlug] + return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug) +} + +// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton +// template bytes (HL paragraph/character styles + 48-key placeholder +// bag) plus its provenance SHA. Sits between the per-code template and +// the generic universal skeleton in resolveSubmissionTemplate's +// fallback chain (t-paliad-275). Same stale-while-revalidate caching +// as the other Gitea-backed template parts. +func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) { + return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug) +} + +// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by +// the firm-skeleton and universal-skeleton accessors. Factored out so +// the two paths can't drift apart on caching semantics. +func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) { + entry, ok := fileRegistry[slug] if !ok { - return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug) + return nil, "", fmt.Errorf("file proxy: %s not registered", slug) } - ce := getCacheEntry(skeletonSubmissionSlug) + ce := getCacheEntry(slug) ce.mu.RLock() hasData := len(ce.data) > 0 @@ -241,7 +284,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) { ce.mu.RLock() defer ce.mu.RUnlock() if len(ce.data) == 0 { - return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug) + return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug) } out := make([]byte, len(ce.data)) copy(out, ce.data) diff --git a/internal/handlers/submission_drafts.go b/internal/handlers/submission_drafts.go index 66c8f20..51355d8 100644 --- a/internal/handlers/submission_drafts.go +++ b/internal/handlers/submission_drafts.go @@ -904,19 +904,25 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, // resolveSubmissionTemplate returns the .docx bytes for the given // submission code. Lookup order matches the cronus design fallback chain -// §8 plus the t-paliad-259 universal-skeleton slot: +// §8 plus the t-paliad-259 universal-skeleton slot and the t-paliad-275 +// firm-skeleton slot: // // 1. per-firm per-submission_code template registered in // submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code- // specific structure plus the full variable bag. -// 2. universal _skeleton.docx — same variable bag, no submission_code- -// specific prose. Catches every code without a dedicated template -// so the editor preview / generate flow still has variables to -// substitute instead of falling through to the bare letterhead. -// 3. universal HL Patents Style .dotm — macro-only letterhead, no -// placeholders. Final fallback when even the skeleton is unreachable -// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour -// for resilience. +// 2. firm-formatted _firm-skeleton.docx — full HL paragraph + character +// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section, +// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the +// source .dotm, the firm letterhead header/footer, plus the full +// 48-key placeholder bag. Catches every code without a dedicated +// template so the editor still renders firm-branded output. +// 3. universal _skeleton.docx — same variable bag, no firm formatting. +// Backstop for when the firm skeleton is unreachable (e.g. a future +// firm hasn't authored one yet). +// 4. universal HL Patents Style .dotm — macro-only letterhead, no +// placeholders. Final fallback when even both skeletons are +// unreachable (mWorkRepo outage etc.). Preserves the +// pre-t-paliad-259 behaviour for resilience. // // The returned SHA is the cache entry's commit SHA so the export audit // row can record provenance. @@ -926,6 +932,11 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]by } else if found { return data, sha, nil } + if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil { + return data, sha, nil + } else { + log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %v", submissionCode, err) + } if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil { return data, sha, nil } else { diff --git a/scripts/gen-hl-skeleton-template/main.go b/scripts/gen-hl-skeleton-template/main.go new file mode 100644 index 0000000..e75fe1b --- /dev/null +++ b/scripts/gen-hl-skeleton-template/main.go @@ -0,0 +1,450 @@ +// HL-firm skeleton submission template generator (t-paliad-275). +// +// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA +// macros and template-only artifacts, then emits a clean .docx that: +// +// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1, +// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by +// keeping word/styles.xml, word/theme/*, word/numbering.xml, +// word/fontTable.xml, settings.xml, footnotes/endnotes from the +// source .dotm untouched. +// 2. Preserves the firm letterhead (logo header + firm-address footer) +// by keeping word/header[12].xml + word/footer[12].xml and the +// sectPr that references them. +// 3. Replaces word/document.xml with a Schriftsatz-shaped body that +// exercises every SubmissionVarsService placeholder (firm.*, +// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*, +// deadline.*) — applying HL paragraph/character styles to each +// section so the rendered output reads as a real HL submission with +// variables substituted. +// +// Drop the output into HL/mWorkRepo at +// +// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx +// +// so paliad's submission generator picks it up via the fallback chain. +// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx +// (THIS file — HL formatting + placeholders) → universal _skeleton.docx +// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm +// (no placeholders). See internal/handlers/submission_drafts.go +// resolveSubmissionTemplate. +// +// Why this is firm-specific: the .dotm carries HL-licensed fonts, +// HL-branded logo media, and HLpat-prefixed style IDs. The output lives +// under the firm-namespaced directory in mWorkRepo so a future firm gets +// its own equivalent file generated against its own .dotm. +// +// Run: +// +// go run ./scripts/gen-hl-skeleton-template \ +// -in /tmp/hl-patents-style.dotm \ +// -out /tmp/_firm-skeleton.docx +// +// Output is byte-stable across runs for a given input (zip mtimes +// pinned). +package main + +import ( + "archive/zip" + "bytes" + "flag" + "fmt" + "io" + "os" + "strings" + "time" +) + +func main() { + in := flag.String("in", "", "path to source HL Patents Style .dotm (required)") + out := flag.String("out", "_firm-skeleton.docx", "output .docx path") + flag.Parse() + + if *in == "" { + fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)") + os.Exit(2) + } + + srcBytes, err := os.ReadFile(*in) + if err != nil { + fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err) + os.Exit(1) + } + + docx, err := buildDocx(srcBytes) + if err != nil { + fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err) + os.Exit(1) + } + if err := os.WriteFile(*out, docx, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err) + os.Exit(1) + } + fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx)) +} + +// fixedTime pins every zip entry's mtime so successive runs over the +// same .dotm produce byte-stable output. Useful for diffing the +// generated file in PR review. +var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC) + +// dropPaths lists zip entries removed during the .dotm → .docx +// conversion. VBA macros + their keymap binding + the template-only +// glossary parts and ribbon customizations are all dead weight (and +// some actively trigger Word's macro-security warning) — none of them +// add anything to a placeholder-rich Schriftsatz starter. +var dropPaths = map[string]bool{ + "word/vbaProject.bin": true, + "word/vbaData.xml": true, + "word/customizations.xml": true, + "userCustomization/customUI.xml": true, + "customUI/customUI14.xml": true, + "word/glossary/document.xml": true, + "word/glossary/_rels/document.xml.rels": true, + "word/glossary/fontTable.xml": true, + "word/glossary/numbering.xml": true, + "word/glossary/settings.xml": true, + "word/glossary/styles.xml": true, + "word/glossary/webSettings.xml": true, +} + +// rIdsToDrop names the document-rel ids whose targets are stripped +// from the package (vbaProject, customizations.xml, glossary). They +// must vanish from word/_rels/document.xml.rels so Word doesn't choke +// on a dangling reference. +var rIdsToDrop = map[string]bool{ + "rId1": true, // vbaProject.bin + "rId2": true, // customizations.xml (keymap to VBA) + "rId21": true, // glossary/document.xml +} + +func buildDocx(src []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src))) + if err != nil { + return nil, fmt.Errorf("open source zip: %w", err) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for _, f := range zr.File { + name := f.Name + if dropPaths[name] { + continue + } + + body, err := readZipEntry(f) + if err != nil { + return nil, fmt.Errorf("read %s: %w", name, err) + } + + switch name { + case "[Content_Types].xml": + body = []byte(patchContentTypes(string(body))) + case "_rels/.rels": + body = []byte(patchRootRels(string(body))) + case "word/_rels/document.xml.rels": + body = []byte(patchDocumentRels(string(body))) + case "word/document.xml": + body = []byte(buildDocumentXML()) + } + + hdr := &zip.FileHeader{ + Name: name, + Method: zip.Deflate, + Modified: fixedTime, + } + w, err := zw.CreateHeader(hdr) + if err != nil { + return nil, fmt.Errorf("create %s: %w", name, err) + } + if _, err := w.Write(body); err != nil { + return nil, fmt.Errorf("write %s: %w", name, err) + } + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("finalise zip: %w", err) + } + return buf.Bytes(), nil +} + +func readZipEntry(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} + +// patchContentTypes rewrites the macroEnabledTemplate part type to the +// regular wordprocessingml.document type (a .dotm carries the macro +// part type even on the body part), and removes Default/Override +// entries that target now-deleted parts (vba binary, customizations, +// glossary). +func patchContentTypes(in string) string { + out := in + out = strings.ReplaceAll(out, + ``, + ``) + + removals := []string{ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + } + for _, r := range removals { + out = strings.ReplaceAll(out, r, "") + } + return out +} + +// patchRootRels drops the userCustomization (ribbon mini-tab) and the +// customUI14 extensibility relationships — both reference VBA-backed +// UI we don't ship. +func patchRootRels(in string) string { + out := in + out = stripRelByPrefix(out, ` element whose +// open tag starts with the given prefix. Tolerates either a regular +// closing tag () or the more common self-closing form. +func stripRelByPrefix(s, prefix string) string { + for { + start := strings.Index(s, prefix) + if start < 0 { + return s + } + // Find end of this element (next "/>"). The .dotm always uses the + // self-closing form for Relationship elements. + end := strings.Index(s[start:], "/>") + if end < 0 { + return s + } + s = s[:start] + s[start+end+2:] + } +} + +// buildDocumentXML emits a Schriftsatz skeleton that exercises every +// SubmissionVarsService placeholder (the canonical 48-key v1 contract +// + the procedural_event.* canonical names + their rule.* legacy +// aliases). The structure mirrors a real DE/UPC submission — title +// block → court → rubrum → patent reference → submission title → +// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis → +// signature → locale-variant verification footer. +// +// Each placeholder lives in its own run so the renderer's pass-1 +// (format-preserving single-run replace) catches every key. HL +// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are +// applied via pStyle, character styles via rStyle. +// +// The sectPr at the bottom is copied verbatim from the source .dotm +// so the firm header/footer references (rId16=header1, rId17=footer1, +// rId18=header2 first-page, rId19=footer2 first-page) keep resolving +// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm +// exactly — a lawyer printing this gets the same A4 layout the .dotm +// produces. +func buildDocumentXML() string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + + skeletonBanner(&b) + + heading(&b, "HLpat-Heading-H1", "{{firm.name}}") + body0(&b, "Bearbeiter: {{user.display_name}}") + body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}") + body0(&b, "Datum: {{today.long_de}} ({{today.iso}})") + body0(&b, "{{firm.signature_block}}") + + headerSection(&b, "{{project.court}}") + body0(&b, "Aktenzeichen: {{project.case_number}}") + body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})") + body0(&b, "Instanz: {{project.instance_level}}") + + headerSubsection(&b, "In der Sache") + + recitalsParty(&b, "{{parties.claimant.name}}") + recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}") + recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —") + + recitalsSequencer(&b, "gegen") + + recitalsParty(&b, "{{parties.defendant.name}}") + recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}") + recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —") + + recitalsSequencer(&b, "sowie") + + recitalsParty(&b, "{{parties.other.name}}") + recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}") + recitalsRoles(&b, "— Weitere Beteiligte —") + + headerSubsection(&b, "Betreff") + body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})") + body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}") + body0(&b, "Projekttitel: {{project.title}}") + body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})") + body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}") + body0(&b, "Internes Aktenzeichen: {{project.reference}}") + + heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}") + body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})") + body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})") + body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}") + + headerSubsection(&b, "Frist") + body0(&b, "Frist-Bezeichnung: {{deadline.title}}") + body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})") + body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}") + body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}") + + heading(&b, "HLpat-Heading-H2", "I. Sachverhalt") + body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]") + + heading(&b, "HLpat-Heading-H2", "II. Anträge") + requestsIntro(&b, "Es wird beantragt:") + requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]") + requestsLevel1(&b, "[Antrag 2]") + + heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen") + body0(&b, "[Hier folgen die Rechtsausführungen.]") + + heading(&b, "HLpat-Heading-H2", "IV. Beweis") + evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]") + + heading(&b, "HLpat-Heading-H2", "Schlussformel") + signature(&b, "{{today.long_de}}") + signature(&b, "") + signature(&b, "{{user.display_name}}") + signature(&b, "{{firm.name}}") + + // Locale-aware verification block — exercises every EN/DE alias the + // variable bag carries plus the rule.* legacy aliases so a lawyer + // editing the template sees that both surfaces resolve. A real + // submission deletes this section after sanity-checking the render. + heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)") + body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}") + body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}") + body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}") + body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}") + body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}") + body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}") + body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}") + body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}") + + // sectPr — copied verbatim from the source .dotm. Keeps the firm + // letterhead header (rId16=header1.xml, rId18=header2.xml first-page) + // and the firm-address footer (rId17, rId19) on every printed page. + b.WriteString(sectPrXML) + + b.WriteString(``) + return b.String() +} + +// sectPrXML matches the source .dotm's section properties exactly so +// the firm header/footer refs and A4 page geometry round-trip. +const sectPrXML = `` + +func skeletonBanner(b *strings.Builder) { + b.WriteString(`SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)`) +} + +func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) } +func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) } +func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) } +func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) } +func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) } +func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) } +func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) } +func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) } +func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) } +func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) } +func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) } +func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) } +func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) } + +// styledPara writes one paragraph with the given pStyle (paragraph +// style id) and optional rStyle (character style applied to every run). +// Empty style ids drop the corresponding wrapper. Placeholders inside +// `text` are split into their own runs so the renderer's pass-1 +// single-run replace catches each one independently. +func styledPara(b *strings.Builder, pStyle, rStyle, text string) { + b.WriteString(``) + if pStyle != "" { + b.WriteString(``) + } + for _, seg := range splitOnPlaceholders(text) { + b.WriteString(``) + if rStyle != "" { + b.WriteString(``) + } + b.WriteString(``) + b.WriteString(xmlEscape(seg)) + b.WriteString(``) + } + b.WriteString(``) +} + +func splitOnPlaceholders(s string) []string { + if s == "" { + return []string{""} + } + var out []string + for { + open := strings.Index(s, "{{") + if open < 0 { + out = append(out, s) + return out + } + close := strings.Index(s[open:], "}}") + if close < 0 { + out = append(out, s) + return out + } + end := open + close + 2 + if open > 0 { + out = append(out, s[:open]) + } + out = append(out, s[open:end]) + s = s[end:] + if s == "" { + return out + } + } +} + +func xmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "'", "'") + return s +}