Merge: t-paliad-364 styled+filled submissions — project-less caption fill (P3b) + merge-safe styled firm-skeleton generator (P3a Option B)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-06-01 16:17:19 +02:00
3 changed files with 423 additions and 357 deletions

View File

@@ -170,9 +170,27 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
if in.ProjectID == nil {
// Project-less draft (t-paliad-243): no project / parties /
// deadline state to resolve. The lawyer's overrides will fill
// the placeholder map; missing keys render as
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
// deadline state to resolve — but the draft still carries a
// submission_code, and the rule loaded above carries its
// ProceedingTypeID. Fill-what-we-can (t-paliad-364 P3b): resolve
// the caption wording (heading / designations / versus / subject)
// and the project.proceeding.* line from that proceeding, so the
// Rubrum renders correct procedural wording instead of a wall of
// [KEIN WERT]. Only the genuinely project-specific values (party
// names, case number, court) stay blank for the lawyer to fill.
//
// loadProceedingType tolerates a nil id; resolveCaption (via
// captionResolver) and addProjectVars both tolerate a nil project,
// so this is safe even when the rule has no proceeding_type_id.
pt, err := s.loadProceedingType(ctx, rule.ProceedingTypeID)
if err != nil {
return nil, err
}
resolvers = append(resolvers,
projectResolver{project: nil, pt: pt, lang: lang},
captionResolver{project: nil, pt: pt, lang: lang},
)
out.ProceedingType = pt
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil
}
@@ -356,26 +374,35 @@ func addUserVars(bag PlaceholderMap, u *models.User) {
// addProjectVars populates project.* — title / case_number / court /
// patent_number / dates / our_side / proceeding metadata.
//
// A nil project is tolerated so this resolver can run as a proceeding-only
// resolver on the project-less draft path (t-paliad-364 P3b): the
// project.proceeding.* keys still fill from pt (which the submission_code's
// rule supplies), while the genuinely project-specific keys (title /
// case_number / court / …) stay unset and render as honest [KEIN WERT] gaps
// for the lawyer.
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
bag["project.title"] = p.Title
bag["project.reference"] = derefString(p.Reference)
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
// project.patent_number_upc is the UPC-brief convention — kind code
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
// kind code is present so the lawyer's draft never sees a worse
// number than the source value.
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
bag["project.our_side"] = derefString(p.OurSide)
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
bag["project.instance_level"] = derefString(p.InstanceLevel)
bag["project.client_number"] = derefString(p.ClientNumber)
bag["project.matter_number"] = derefString(p.MatterNumber)
if p != nil {
bag["project.title"] = p.Title
bag["project.reference"] = derefString(p.Reference)
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
// project.patent_number_upc is the UPC-brief convention — kind code
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
// kind code is present so the lawyer's draft never sees a worse
// number than the source value.
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
bag["project.our_side"] = derefString(p.OurSide)
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
bag["project.instance_level"] = derefString(p.InstanceLevel)
bag["project.client_number"] = derefString(p.ClientNumber)
bag["project.matter_number"] = derefString(p.MatterNumber)
}
if pt != nil {
bag["project.proceeding.code"] = pt.Code
bag["project.proceeding.jurisdiction"] = derefString(pt.Jurisdiction)

View File

@@ -0,0 +1,80 @@
package services
// Pins the project-less fill-what-we-can wiring (t-paliad-364 P3b). On a
// draft started "Ohne Projekt" (project_id NULL) the draft still carries a
// submission_code whose rule supplies a ProceedingTypeID. SubmissionVarsService
// .Build's project-less branch now runs projectResolver{project:nil} +
// captionResolver{project:nil} off that proceeding type, so the caption.* and
// project.proceeding.* keys fill while the genuinely project-specific keys
// (title / case_number / court) stay blank.
//
// This pins the resolver-set the branch assembles directly (no DB): the same
// two nil-project resolvers, run into one bag, must produce filled caption
// wording + proceeding name and leave the project-specific keys absent.
import (
"testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
func TestProjectlessFill_CaptionAndProceedingFromRuleProceeding(t *testing.T) {
// DE LG infringement proceeding — the kind a submission_code's rule
// carries via proceeding_type_id even when no project is bound.
pt := ptType("de.inf.lg", "DE")
pt.Name = "Patentverletzungsklage LG"
pt.NameEN = "Patent infringement action (LG)"
// Mirror exactly what the project-less branch of Build appends.
bag := docforge.NewResolverSet(
projectResolver{project: nil, pt: pt, lang: "de"},
captionResolver{project: nil, pt: pt, lang: "de"},
).BuildBag()
// caption.* fills from the proceeding alone (resolveCaption is nil-project
// safe) — heading / designations / versus / subject all resolved, NOT
// [KEIN WERT].
wantCaption := map[string]string{
"caption.heading": "In dem Rechtsstreit",
"caption.claimant_designation": "Klägerin",
"caption.defendant_designation": "Beklagte",
"caption.versus": "gegen",
"caption.subject": "Patentverletzung",
}
for k, want := range wantCaption {
if got := bag[k]; got != want {
t.Errorf("bag[%q] = %q, want %q (caption must fill on project-less draft)", k, got, want)
}
}
// project.proceeding.* fills from pt.
if got := bag["project.proceeding.name"]; got == "" {
t.Errorf("bag[\"project.proceeding.name\"] is empty; want the proceeding name filled from the rule's proceeding type")
}
if got := bag["project.proceeding.code"]; got != "de.inf.lg" {
t.Errorf("bag[\"project.proceeding.code\"] = %q, want %q", got, "de.inf.lg")
}
// Genuinely project-specific keys stay absent — addProjectVars skips them
// when project is nil, so they fall through to the lawyer's overrides /
// [KEIN WERT] rather than rendering a bogus value.
for _, k := range []string{"project.title", "project.case_number", "project.court"} {
if _, present := bag[k]; present {
t.Errorf("bag[%q] present on a project-less draft; expected it to stay unset for the lawyer to fill", k)
}
}
}
// Pins nil-project safety of addProjectVars directly: a nil project must not
// panic and must still populate the proceeding namespace from pt.
func TestAddProjectVars_NilProjectFillsProceedingOnly(t *testing.T) {
bag := PlaceholderMap{}
addProjectVars(bag, nil, ptType("upc.inf.cfi", "UPC"), "en")
if got := bag["project.proceeding.code"]; got != "upc.inf.cfi" {
t.Errorf("project.proceeding.code = %q, want %q", got, "upc.inf.cfi")
}
if _, present := bag["project.title"]; present {
t.Error("project.title present for a nil project; want it skipped")
}
}

View File

@@ -1,47 +1,63 @@
// HL-firm skeleton submission template generator (t-paliad-275).
// Merge-safe styled firm-skeleton generator (t-paliad-275 → t-paliad-364 P3a).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
// Produces the firm-formatted, MERGE-SAFE Schriftsatz skeleton paliad's
// submission generator picks up via the merge-path fallback chain
// (resolveSubmissionTemplate, internal/handlers/submission_drafts.go):
//
// 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.
// - tier 4 `_firm-skeleton.docx` (DE drafts + project-less drafts)
// - tier 3 `_skeleton.en.docx` (EN drafts)
//
// Drop the output into HL/mWorkRepo at
// Both tiers are GUARDED by docx.HasMergePlaceholders: a template only
// wins the merge path if word/document.xml carries real {{key}}
// placeholders. The firm-skeleton's body had been repurposed into an
// anchors-only Composer base ({{#section:KEY}} markers; t-paliad-313
// Slice B), so the guard rejected it and every generated submission fell
// back to the in-process docx.BuildFallbackSkeleton — a plain, generic
// (Heading1/Normal) Rubrum (kepler diagnosis t-paliad-363 §P3a). This
// generator restores a merge-safe firm-styled body so the guard accepts
// it again and the resolver auto-prefers it (no handler change).
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
// HOW: it does NOT rebuild the package from the macro-bearing .dotm.
// Instead it takes an already-clean .docx CARRIER (the deployed
// firm-skeleton) and replaces ONLY word/document.xml with a clean,
// caption-driven Rubrum, preserving every other part byte-for-byte —
// the firm styles.xml, theme, numbering, fontTable, the letterhead
// header[12]/footer[12] + logo media, customXml, settings. The carrier's
// own <w:document> namespaces and <w:sectPr> (which wires the letterhead
// header/footer references) are reused verbatim, so the output keeps the
// firm letterhead on every page.
//
// 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.
// The Rubrum body MIRRORS docx.BuildFallbackSkeleton (the in-process
// merge fallback) — same layout, same {{key}} / {{caption.*}} placeholder
// surface — but applies the firm's named paragraph styles instead of the
// generic Heading2/Normal: party lines → <prefix>Table-Recitals-Party /
// PartyDetails / PartyRoles, the versus connector → Sequencers, section
// heads → <prefix>Heading-H2, the signature block → <prefix>Signature,
// everything else → <prefix>Body-B0.
//
// 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.
// The caption wording (heading / designations / versus / subject) comes
// from the SHARED parametric {{caption.*}} keys (t-paliad-358 A-S2), in
// their bare (draft-language-resolved) form, so the same file renders DE
// or EN caption wording per draft. Only the static scaffold labels
// ("Aktenzeichen:", "wegen", …) and the today/our-side aliases are
// language-baked — hence one file per language.
//
// Run:
// Style-prefix drift: the firm style IDs are auto-detected from the
// carrier's word/styles.xml. The originally-deployed firm-skeleton uses
// the `HLpat-` prefix; the upstream `HLC Patents Style.dotm` was rebuilt
// during the HL→HLC rebrand and now emits `HLCpat-`. Detecting the prefix
// from the carrier keeps this generator correct against either source and
// across that migration. (Reconciling the prefix across all consumers is
// a separate follow-up — flagged in t-paliad-364.)
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
// Run (one file per language):
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
// go run ./scripts/gen-hl-skeleton-template -in carrier.docx -lang de -out _firm-skeleton.docx
// go run ./scripts/gen-hl-skeleton-template -in carrier.docx -lang en -out _skeleton.en.docx
//
// where carrier.docx is the deployed firm-skeleton fetched from
// HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx.
// Output is byte-stable across runs for a given (input, lang).
package main
import (
@@ -51,27 +67,34 @@ import (
"fmt"
"io"
"os"
"regexp"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
in := flag.String("in", "", "path to the clean .docx carrier (deployed firm-skeleton) — required")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
lang := flag.String("lang", "de", "draft language for the static scaffold labels: de | en")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to the clean .docx firm-skeleton carrier)")
os.Exit(2)
}
l := strings.ToLower(strings.TrimSpace(*lang))
if l != "de" && l != "en" {
fmt.Fprintf(os.Stderr, "gen-hl-skeleton-template: -lang must be de or en, got %q\n", *lang)
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read carrier:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
docx, err := buildDocx(srcBytes, l)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
@@ -80,89 +103,87 @@ func main() {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
fmt.Printf("wrote %s (%d bytes, lang=%s)\n", *out, len(docx), l)
}
// 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)
// fixedTime pins every zip entry's mtime so successive runs over the same
// (carrier, lang) produce byte-stable output. Useful for diffing the
// generated file in review.
var fixedTime = time.Date(2026, 6, 1, 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) {
// buildDocx copies every part of the carrier byte-for-byte except
// word/document.xml, which is replaced with the merge-safe firm-styled
// Rubrum for the requested language. The carrier's own <w:document> open
// tag and <w:sectPr> are reused so the letterhead header/footer wiring is
// preserved exactly.
func buildDocx(src []byte, lang string) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
return nil, fmt.Errorf("open carrier zip: %w", err)
}
// Read the two parts we need to inspect: styles.xml (prefix detection)
// and document.xml (open tag + sectPr reuse).
var stylesXML, docXML string
for _, f := range zr.File {
switch f.Name {
case "word/styles.xml":
b, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read word/styles.xml: %w", err)
}
stylesXML = string(b)
case "word/document.xml":
b, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read word/document.xml: %w", err)
}
docXML = string(b)
}
}
if stylesXML == "" {
return nil, fmt.Errorf("carrier has no word/styles.xml")
}
if docXML == "" {
return nil, fmt.Errorf("carrier has no word/document.xml")
}
prefix, err := detectStylePrefix(stylesXML)
if err != nil {
return nil, err
}
openTag, err := documentOpenTag(docXML)
if err != nil {
return nil, err
}
sectPr, err := extractSectPr(docXML)
if err != nil {
return nil, err
}
newDoc := buildDocumentXML(lang, prefix, openTag, sectPr)
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)
return nil, fmt.Errorf("read %s: %w", f.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())
if f.Name == "word/document.xml" {
body = []byte(newDoc)
}
hdr := &zip.FileHeader{
Name: name,
w, err := zw.CreateHeader(&zip.FileHeader{
Name: f.Name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
})
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
return nil, fmt.Errorf("create %s: %w", f.Name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
return nil, fmt.Errorf("write %s: %w", f.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
@@ -178,265 +199,203 @@ func readZipEntry(f *zip.File) ([]byte, error) {
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,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
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, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
// detectStylePrefix returns the firm style-id prefix the carrier defines —
// "HLCpat-" (current HLC Patents Style.dotm) or "HLpat-" (the originally
// deployed firm-skeleton) — keyed off the Recitals-Party style every firm
// Rubrum needs. Erroring out here is deliberate: a carrier missing the
// Recitals styles would silently produce an unstyled document.
func detectStylePrefix(stylesXML string) (string, error) {
for _, p := range []string{"HLCpat-", "HLpat-"} {
if strings.Contains(stylesXML, `w:styleId="`+p+`Table-Recitals-Party"`) {
return p, nil
}
// 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
}
return "", fmt.Errorf("carrier styles.xml carries neither HLCpat-Table-Recitals-Party nor HLpat-Table-Recitals-Party — not a firm-styled skeleton")
}
var (
docOpenRegex = regexp.MustCompile(`(?s)<w:document\b[^>]*>`)
sectPrRegex = regexp.MustCompile(`(?s)<w:sectPr\b.*</w:sectPr>`)
)
// documentOpenTag returns the carrier's <w:document …> open tag verbatim so
// the rebuilt body keeps the exact namespace declarations the sectPr (r:id
// refs) and styles rely on.
func documentOpenTag(docXML string) (string, error) {
m := docOpenRegex.FindString(docXML)
if m == "" {
return "", fmt.Errorf("carrier document.xml has no <w:document> open tag")
}
return m, nil
}
// extractSectPr returns the carrier's <w:sectPr>…</w:sectPr> block verbatim.
// It wires the letterhead header/footer references (rId16=header1,
// rId17=footer1, rId18=header2 first-page, rId19=footer2 first-page) and the
// A4 page geometry; reusing it keeps the firm letterhead on every page.
func extractSectPr(docXML string) (string, error) {
m := sectPrRegex.FindString(docXML)
if m == "" {
return "", fmt.Errorf("carrier document.xml has no <w:sectPr> — cannot preserve letterhead wiring")
}
return m, nil
}
// firmLabels holds the language-dependent static scaffold text. Dynamic
// values stay as {{key}} placeholders regardless of language; the caption
// pieces use the BARE {{caption.*}} keys (draft-language-resolved) so the
// procedural wording flips DE/EN per draft even though the scaffold labels
// are baked. Mirrors docx.fallbackLabelsFor so the firm-styled and
// in-process fallbacks read identically.
type firmLabels struct {
editor string
dateKey string
caseNo string
representedBy string
others string
wegen string
subjectLabel string
patent string
proceeding string
ourSideKey string
bodyHint string
closing string
}
func labelsFor(lang string) firmLabels {
if lang == "en" {
return firmLabels{
editor: "Attorney:",
dateKey: "{{today.long_en}}",
caseNo: "Case no.:",
representedBy: "represented by",
others: "Further parties:",
wegen: "re",
subjectLabel: "Subject",
patent: "Patent in suit:",
proceeding: "Proceeding:",
ourSideKey: "{{project.our_side_en}}",
bodyHint: "[Body of the submission goes here. This is a basic firm-styled skeleton — fill in according to the submission type.]",
closing: "Closing",
}
s = s[:start] + s[start+end+2:]
}
return firmLabels{
editor: "Bearbeiter:",
dateKey: "{{today.long_de}}",
caseNo: "Aktenzeichen:",
representedBy: "vertreten durch",
others: "Weitere Beteiligte:",
wegen: "wegen",
subjectLabel: "Betreff",
patent: "Streitpatent:",
proceeding: "Verfahrensart:",
ourSideKey: "{{project.our_side_de}}",
bodyHint: "[Hier folgt der Schriftsatztext. Diese Skelett-Vorlage trägt keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ergänzen.]",
closing: "Schlussformel",
}
}
// 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 <w:r> 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 {
// buildDocumentXML emits the merge-safe firm-styled Rubrum body. Layout
// mirrors docx.buildFallbackDocumentXML (author/date → court/case/proceeding
// → Rubrum heading → claimant block → versus → defendant block → others →
// wegen-subject → patent → body placeholder → closing/signature) so the two
// merge fallbacks stay structurally identical; only the paragraph styles
// differ (firm HLpat/HLCpat styles here vs generic Heading2/Normal there).
func buildDocumentXML(lang, prefix, openTag, sectPr string) string {
l := labelsFor(lang)
body0 := prefix + "Body-B0"
heading := prefix + "Heading-H2"
party := prefix + "Table-Recitals-Party"
partyDetails := prefix + "Table-Recitals-PartyDetails"
partyRoles := prefix + "Table-Recitals-PartyRoles"
sequencer := prefix + "Table-Recitals-Sequencers"
signature := prefix + "Signature"
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(openTag)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
// Author / date block. The firm identity + logo live in the letterhead
// header/footer (preserved via the carrier's sectPr), so they are not
// repeated in the body.
para(&b, body0, l.editor+" {{user.display_name}}")
para(&b, body0, "{{user.email}} · {{user.office}}")
para(&b, body0, l.dateKey)
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}}")
// Court + case number + proceeding.
para(&b, body0, "{{project.court}}")
para(&b, body0, l.caseNo+" {{project.case_number}}")
para(&b, body0, l.proceeding+" {{project.proceeding.name}}")
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}}")
// Rubrum heading — parametric caption wording, no outline number.
headingNoNum(&b, heading, "{{caption.heading}}")
headerSubsection(&b, "In der Sache")
// Claimant block (Recitals-Party auto-numbers it "1.").
para(&b, party, "{{parties.claimant.name}}")
para(&b, partyDetails, l.representedBy+" {{parties.claimant.representative}}")
para(&b, partyRoles, "— {{caption.claimant_designation}} —")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
// Versus connector.
para(&b, sequencer, "{{caption.versus}}")
recitalsSequencer(&b, "gegen")
// Defendant block (Recitals-Party auto-numbers it "2.").
para(&b, party, "{{parties.defendant.name}}")
para(&b, partyDetails, l.representedBy+" {{parties.defendant.representative}}")
para(&b, partyRoles, "— {{caption.defendant_designation}} —")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
// Further parties + subject.
para(&b, partyDetails, l.others+" {{parties.other.name}}")
para(&b, body0, l.wegen+" {{caption.subject}}")
recitalsSequencer(&b, "sowie")
// Patent in suit.
headingNoNum(&b, heading, l.subjectLabel)
para(&b, body0, l.patent+" {{project.patent_number}}")
para(&b, body0, "{{project.title}} ("+l.ourSideKey+")")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
// Body placeholder for the actual submission text.
para(&b, body0, "")
para(&b, body0, l.bodyHint)
para(&b, body0, "")
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}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// for custom templates that want them, but the default HL skeleton
// no longer renders them in the submission body: the deadline is
// internal/admin context and has no place in a court-bound document.
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, "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)
// Closing / signature.
headingNoNum(&b, heading, l.closing)
para(&b, body0, l.dateKey)
para(&b, signature, "{{user.display_name}}")
para(&b, signature, "{{firm.signature_block}}")
// sectPr — reused verbatim from the carrier (letterhead wiring + A4
// geometry).
b.WriteString(sectPr)
b.WriteString(`</w:body></w:document>`)
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 = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
// para writes one paragraph with the given paragraph style. The full line
// (static label + any {{key}} placeholders) goes in a single run/text node;
// the merge renderer's pass-1 substitutes each placeholder inside the node
// in place (format-preserving), so no per-placeholder run splitting is
// needed here.
func para(b *strings.Builder, style, text string) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/></w:pPr><w:r><w:t xml:space="preserve">`)
b.WriteString(xmlEscape(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
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(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
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
}
}
// headingNoNum writes a heading paragraph that suppresses the heading
// style's auto-numbering (the firm Heading-H1/H2 styles carry a numbered
// outline list; a Rubrum caption/section title must not render "1.1."). A
// paragraph-level numId=0 override removes the paragraph from any list while
// keeping the heading's font/spacing.
func headingNoNum(b *strings.Builder, style, text string) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/><w:numPr><w:ilvl w:val="0"/><w:numId w:val="0"/></w:numPr></w:pPr><w:r><w:t xml:space="preserve">`)
b.WriteString(xmlEscape(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
func xmlEscape(s string) string {