From c1781c9a89fca2d83aa2a1560f06e1f7beabb20e Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 1 Jun 2026 16:14:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(generation):=20t-paliad-364=20styled+fille?= =?UTF-8?q?d=20submissions=20=E2=80=94=20project-less=20caption=20fill=20(?= =?UTF-8?q?P3b)=20+=20merge-safe=20styled=20firm-skeleton=20generator=20(P?= =?UTF-8?q?3a=20Option=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3(b) fill-what-we-can (submission_vars.go): The project-less branch of SubmissionVarsService.Build ran only firm/today/ user/proceduralEvent resolvers, so caption.*/project.proceeding.* never populated and every Rubrum value rendered [KEIN WERT]. The rule is already loaded on this path and carries ProceedingTypeID. Now the branch loads the proceeding type via loadProceedingType(rule.ProceedingTypeID) (tolerates a nil id) and appends a proceeding-only projectResolver{project:nil} + a captionResolver{project:nil} — both nil-project safe. Result: caption heading / designations / versus / subject + the proceeding line fill from the submission_code's proceeding; only party names / case number / court stay blank for the lawyer. Preserves the "Ohne Projekt" affordance (t-paliad-243). addProjectVars is now nil-project safe (guards the project.* direct fields, keeps the pt-driven project.proceeding.* block) so projectResolver can serve as the proceeding-only resolver. Pinned by TestProjectlessFill_* + TestAddProjectVars_NilProjectFillsProceedingOnly (no DB). P3(a) styling — merge-safe styled firm-skeleton (scripts/gen-hl-skeleton-template): Generation landed on docx.BuildFallbackSkeleton (generic Heading1/2/Normal) because the firm-skeleton's body had been repurposed into an anchors-only Composer base, which HasMergePlaceholders rejects. Rewrote the generator to take the deployed clean .docx carrier and replace ONLY word/document.xml with a clean caption-driven Rubrum that uses the firm Rubrum styles (Table-Recitals-Party/ PartyDetails/PartyRoles/Sequencers, Heading-H2, Signature, Body-B0) and the same {{key}}/{{caption.*}} placeholders the in-process fallback uses — preserving the carrier's styles/theme/numbering/letterhead/logo and its sectPr verbatim. Adds a -lang flag (DE _firm-skeleton.docx, EN _skeleton.en.docx) and auto-detects the firm style prefix (HLpat-/HLCpat-) so it stays correct across the .dotm rebrand drift. The resolver's tier-4/tier-3 merge-safe guard auto-prefers the restored templates — no handler change. Dry-run (TEST_DATABASE_URL, de.inf.lg.erwidg): project-less render fills caption heading="In dem Rechtsstreit"/Klägerin/gegen/Beklagte/Patentverletzung + the proceeding line, only party/case/court blank; full-project render additionally fills parties + case number + court. Both carry the HLpat-Table-Recitals-* / HLpat-Heading-H2 / HLpat-Signature styles, HasMergePlaceholders=true, no {{#section}} junk. --- internal/services/submission_vars.go | 71 +- .../submission_vars_projectless_test.go | 80 +++ scripts/gen-hl-skeleton-template/main.go | 629 ++++++++---------- 3 files changed, 423 insertions(+), 357 deletions(-) create mode 100644 internal/services/submission_vars_projectless_test.go diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index 571289d..3a98905 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -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) diff --git a/internal/services/submission_vars_projectless_test.go b/internal/services/submission_vars_projectless_test.go new file mode 100644 index 0000000..178cc78 --- /dev/null +++ b/internal/services/submission_vars_projectless_test.go @@ -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") + } +} diff --git a/scripts/gen-hl-skeleton-template/main.go b/scripts/gen-hl-skeleton-template/main.go index e23c17a..ade987c 100644 --- a/scripts/gen-hl-skeleton-template/main.go +++ b/scripts/gen-hl-skeleton-template/main.go @@ -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 namespaces and (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 → Table-Recitals-Party / +// PartyDetails / PartyRoles, the versus connector → Sequencers, section +// heads → Heading-H2, the signature block → Signature, +// everything else → 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 open +// tag and 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, - ``, - ``) - - 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 +// 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)]*>`) + sectPrRegex = regexp.MustCompile(`(?s)`) +) + +// documentOpenTag returns the carrier's 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 open tag") + } + return m, nil +} + +// extractSectPr returns the carrier's 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 — 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 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(``) - b.WriteString(``) + b.WriteString(openTag) b.WriteString(``) - 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(``) 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)`) +// 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(``) + b.WriteString(xmlEscape(text)) + b.WriteString(``) } -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 - } - } +// 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(``) + b.WriteString(xmlEscape(text)) + b.WriteString(``) } func xmlEscape(s string) string {