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
+}