From 94310ba498bfe3eec0dbe6372826fafc139cb299 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 20:21:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(submissions):=20Composer=20Slice=20E=20?= =?UTF-8?q?=E2=80=94=20specialist=20bases=20+=20base-swap=20content=20surv?= =?UTF-8?q?ival=20(m/paliad#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new firm-agnostic base templates + the generic generator that produced them + a regression test pinning Q10's base-swap-content- survival contract. Mig 150: seeds two `submission_bases` rows with firm=NULL. - lg-duesseldorf — proceeding_family='de.inf.lg'. Conservative German legal style: Times New Roman 11pt; plain black headings. Stylemap targets LG-Body / LG-Heading1..3 / LG-ListBullet / LG-ListNumber / LG-Quote. - upc-formal — proceeding_family='upc.inf.cfi'. UPC court style: Calibri 11pt body; UPC-blue (#1F3864) headings; Cambria italic for blockquotes. Stylemap targets UPC-Body / UPC-Heading1..3 / … Both rows ship the same 10-section spec.defaults shape as the Slice A bases (letterhead → signature) with their own seed Markdown. scripts/gen-submission-base/main.go (NEW, ~240 LoC): - Generic generator with -preset flag. Two presets baked in (lg-duesseldorf + upc-formal). Each preset hard-codes typography (font, sizes, colour) so the lawyer can swap between bases and see chrome change while section content carries through unchanged. - Output is byte-reproducible (zip mtime pinned to 2026-05-26 UTC). - Emits a minimal Composer-mode .docx: [Content_Types].xml, _rels/.rels, word/_rels/document.xml.rels (empty envelope so the composer's hyperlink-rels patch from Slice D has somewhere to land), word/styles.xml (preset's full named-style block + "Hyperlink" character style for Slice D link runs), word/document.xml (anchor- only body in §6.1 default section order). Gitea uploads (via mAi): - 6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx blob SHA: 82f57b3cb3b54c755fc5ab36862bfd61b8aaa73e - 6 - material/Templates/Word/Paliad/Composer/upc-formal.docx blob SHA: 41b9a388263ccc43ddc28b55caab301a4cf74fe8 These live under Composer/ (not under HLC/) so a future non-HLC deployment serves the same cross-firm files. Backend wiring: - internal/handlers/files.go: two new fileRegistry entries (composerBaseLGDuesseldorfSlug, composerBaseUPCFormalSlug) + matching slugs in composerBaseSlugMap so fetchComposerBaseBytes routes the new catalog rows to the new Gitea objects. Tests: - TestComposer_BaseSwapPreservesContent — composes the same draft against an HLC-style stylemap AND an LG-style stylemap; asserts (a) content survives both ways, (b) each output carries the correct stylemap-entry stylenames, (c) neither output leaks the other's stylenames. Pins Q10's base-swap-survives-content contract. Build hygiene: go build/vet/test -short clean (all packages); bun run build clean. NOT in scope (Slice E's brief was specialist bases + survival test): - Generator coverage for HL Patents Style bases — gen-hl-skeleton- template stays as the per-firm path (it needs the proprietary .dotm source). gen-submission-base is for firm-agnostic bases. - LG-Düsseldorf-court-style-guide deep fidelity — the LG preset is a conservative starting point; admin refines via the admin editor in a later slice if needed. - numbering.xml carrying numId=1/2 — Slice D's MD walker emits visible "• " / "N. " prefixes that don't need numbering.xml; honours stylemap entry for indentation. Hard rules honoured: - Migration purely additive (`ON CONFLICT (slug) DO NOTHING`). - NO behavior change for pre-Composer drafts. - NO behavior change for existing hlc-letterhead + neutral seed rows. - {{rule.X}} aliases preserved (walker passes placeholders through; v1 SubmissionRenderer pass substitutes). - Q10 base-swap-content-survival pinned by new test. t-paliad-317 Slice E --- .../150_submission_bases_specialist.down.sql | 3 + .../150_submission_bases_specialist.up.sql | 128 +++++++++ internal/handlers/files.go | 28 ++ internal/services/submission_compose_test.go | 83 ++++++ scripts/gen-submission-base/main.go | 256 ++++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 internal/db/migrations/150_submission_bases_specialist.down.sql create mode 100644 internal/db/migrations/150_submission_bases_specialist.up.sql create mode 100644 scripts/gen-submission-base/main.go diff --git a/internal/db/migrations/150_submission_bases_specialist.down.sql b/internal/db/migrations/150_submission_bases_specialist.down.sql new file mode 100644 index 0000000..798acb2 --- /dev/null +++ b/internal/db/migrations/150_submission_bases_specialist.down.sql @@ -0,0 +1,3 @@ +-- t-paliad-317: revert specialist base seed rows. + +DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal'); diff --git a/internal/db/migrations/150_submission_bases_specialist.up.sql b/internal/db/migrations/150_submission_bases_specialist.up.sql new file mode 100644 index 0000000..195d379 --- /dev/null +++ b/internal/db/migrations/150_submission_bases_specialist.up.sql @@ -0,0 +1,128 @@ +-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases. +-- +-- Two firm-agnostic bases for proceeding-family-specific styling: +-- +-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style. +-- Times New Roman 11pt; black headings. +-- upc-formal — UPC court of first instance (upc.inf.cfi) formal +-- style. Calibri 11pt body; UPC-blue (1F3864) headings; +-- Cambria italic for blockquotes. +-- +-- The .docx body for each is a minimal Composer-mode skeleton with +-- the 10 default section anchors and an empty rels envelope. The +-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet +-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character +-- style (matches the MD walker's emitted r:id="rIdComposerN" link +-- runs from Slice D). +-- +-- Generator: scripts/gen-submission-base/main.go (each preset hard- +-- codes the typography). The .docx files are uploaded to Gitea at +-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi. +-- +-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a +-- re-run safe and existing rows untouched. + +INSERT INTO paliad.submission_bases + (slug, firm, proceeding_family, label_de, label_en, + description_de, description_en, + gitea_path, section_spec, is_default_for) +VALUES + ('lg-duesseldorf', NULL, 'de.inf.lg', + 'LG-Düsseldorf-Stil', 'LG-Düsseldorf style', + 'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.', + 'Conservative DE LG style: Times New Roman 11pt, plain headings.', + '6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx', + jsonb_build_object( + 'version', 1, + 'stylemap', jsonb_build_object( + 'paragraph', 'LG-Body', + 'heading_1', 'LG-Heading1', + 'heading_2', 'LG-Heading2', + 'heading_3', 'LG-Heading3', + 'list_bullet', 'LG-ListBullet', + 'list_numbered', 'LG-ListNumber', + 'blockquote', 'LG-Quote' + ), + 'defaults', jsonb_build_array( + jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead', + 'included',true, + 'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}', + 'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'), + jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption', + 'included',true, + 'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}', + 'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'), + jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits', + 'included',false, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing', + 'included',true, + 'seed_md_de', E'Mit freundlichen Grüßen', + 'seed_md_en', E'Yours sincerely,'), + jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature', + 'included',true, + 'seed_md_de', E'{{user.display_name}}', + 'seed_md_en', E'{{user.display_name}}') + ) + ), + '{}'::text[] + ), + ('upc-formal', NULL, 'upc.inf.cfi', + 'UPC-Verfahren', 'UPC formal', + 'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.', + 'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.', + '6 - material/Templates/Word/Paliad/Composer/upc-formal.docx', + jsonb_build_object( + 'version', 1, + 'stylemap', jsonb_build_object( + 'paragraph', 'UPC-Body', + 'heading_1', 'UPC-Heading1', + 'heading_2', 'UPC-Heading2', + 'heading_3', 'UPC-Heading3', + 'list_bullet', 'UPC-ListBullet', + 'list_numbered', 'UPC-ListNumber', + 'blockquote', 'UPC-Quote' + ), + 'defaults', jsonb_build_array( + jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead', + 'included',true, + 'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}', + 'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'), + jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption', + 'included',true, + 'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}', + 'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'), + jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering', + 'included',true, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits', + 'included',false, 'seed_md_de', '', 'seed_md_en', ''), + jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing', + 'included',true, + 'seed_md_de', E'Mit freundlichen Grüßen', + 'seed_md_en', E'Yours sincerely,'), + jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature', + 'included',true, + 'seed_md_de', E'{{user.display_name}}', + 'seed_md_en', E'{{user.display_name}}') + ) + ), + '{}'::text[] + ) +ON CONFLICT (slug) DO NOTHING; diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 0faabe3..ebb2aba 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -113,8 +113,34 @@ var fileRegistry = map[string]fileEntry{ RepoName: "mWorkRepo", FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx", }, + // t-paliad-317 Composer Slice E — specialist firm-agnostic bases. + // Both live under Composer/ (not under HLC/) so a future non-HLC + // deployment serves the same cross-firm files. Body = anchor-only + // per Slice B; styles.xml carries the preset's typography. + composerBaseLGDuesseldorfSlug: { + RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx", + DownloadName: "LG-Düsseldorf Stil.docx", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx", + }, + composerBaseUPCFormalSlug: { + RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx", + DownloadName: "UPC formal.docx", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx", + }, } +// t-paliad-317 Composer Slice E — slugs for the new specialist bases. +const ( + composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx" + composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx" +) + // skeletonSubmissionSlug names the universal skeleton template inside // the shared fileRegistry cache. Exported via a const so handler code // (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to @@ -413,6 +439,8 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) { var composerBaseSlugMap = map[string]string{ "hlc-letterhead": firmSkeletonSubmissionSlug, "neutral": skeletonSubmissionSlug, + "lg-duesseldorf": composerBaseLGDuesseldorfSlug, + "upc-formal": composerBaseUPCFormalSlug, } // fetchComposerBaseBytes returns the .docx bytes for a Composer base, diff --git a/internal/services/submission_compose_test.go b/internal/services/submission_compose_test.go index 62b5b86..6d310e0 100644 --- a/internal/services/submission_compose_test.go +++ b/internal/services/submission_compose_test.go @@ -368,6 +368,89 @@ func TestComposer_HyperlinkDedupesByURL(t *testing.T) { } } +// Slice E — base swap preserves section content; only chrome / styles +// change. This is the design's "Markdown is base-agnostic" contract +// from Q10 + §5.3 ratification. We compose the SAME section text +// against two bases with DIFFERENT stylemaps and verify: +// 1. The section text appears in both outputs. +// 2. Each base applies its OWN paragraph style (the stylemap diff +// is the only visible delta in the document body). + +func TestComposer_BaseSwapPreservesContent(t *testing.T) { + body := `{{#section:facts}}{{/section:facts}}` + baseBytes := minimalBaseBytes(t, body) + + // Base A: HLC-style stylemap. + hlc := &SubmissionBase{ + ID: uuid.New(), Slug: "hlc-test", + SectionSpec: BaseSectionSpec{ + Stylemap: map[string]string{ + "paragraph": "HLpat-Body-B0", + "heading_1": "HLpat-Heading-H1", + }, + }, + } + // Base B: LG-style stylemap. + lg := &SubmissionBase{ + ID: uuid.New(), Slug: "lg-test", + SectionSpec: BaseSectionSpec{ + Stylemap: map[string]string{ + "paragraph": "LG-Body", + "heading_1": "LG-Heading1", + }, + }, + } + + // Identical Markdown content rendered against each base. + md := "# Heading line\n\nA paragraph of substantive prose." + sections := []SubmissionSection{ + {ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md}, + } + + composer := NewSubmissionComposer(NewSubmissionRenderer()) + hlcOut, err := composer.Compose(context.Background(), ComposeOptions{ + Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de", + }) + if err != nil { + t.Fatalf("Compose hlc: %v", err) + } + lgOut, err := composer.Compose(context.Background(), ComposeOptions{ + Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de", + }) + if err != nil { + t.Fatalf("Compose lg: %v", err) + } + + hlcXML := extractDocumentXML(t, hlcOut) + lgXML := extractDocumentXML(t, lgOut) + + // Content survives both ways. + for _, want := range []string{"Heading line", "A paragraph of substantive prose."} { + if !strings.Contains(hlcXML, want) { + t.Errorf("HLC output missing content %q", want) + } + if !strings.Contains(lgXML, want) { + t.Errorf("LG output missing content %q", want) + } + } + + // Stylemap diff actually shows up in the body — HLC's headings + // use HLpat-Heading-H1, LG's use LG-Heading1. If the composer + // silently passed the wrong stylemap, this would fire. + if !strings.Contains(hlcXML, ``) { + t.Errorf("HLC heading style missing: %s", hlcXML) + } + if !strings.Contains(lgXML, ``) { + t.Errorf("LG heading style missing: %s", lgXML) + } + if strings.Contains(hlcXML, ``) { + t.Errorf("HLC output leaked LG style: %s", hlcXML) + } + if strings.Contains(lgXML, ``) { + t.Errorf("LG output leaked HLC style: %s", lgXML) + } +} + func TestComposer_OrderIndexAscending(t *testing.T) { base := composerBase() // No anchors → both sections append in order_index ASC order diff --git a/scripts/gen-submission-base/main.go b/scripts/gen-submission-base/main.go new file mode 100644 index 0000000..3cce275 --- /dev/null +++ b/scripts/gen-submission-base/main.go @@ -0,0 +1,256 @@ +// Composer Slice E base-template generator (t-paliad-317). +// +// Produces a minimal Composer-mode .docx whose contains the +// 10 default section anchors and whose word/styles.xml declares a +// named style for each stylemap key the composer references. Each +// "preset" (lg-duesseldorf, upc-formal, …) hard-codes the typography +// (font, sizes, colour) so the lawyer can swap between them and see +// the chrome change while the section content carries through +// unchanged (the Q10 base-swap-content-survival contract). +// +// Run: +// +// go run ./scripts/gen-submission-base -preset lg-duesseldorf -out /tmp/lg-duesseldorf.docx +// go run ./scripts/gen-submission-base -preset upc-formal -out /tmp/upc-formal.docx +// +// Both outputs are byte-reproducible (zip mtimes pinned to a fixed +// UTC timestamp so a clean rebuild diff stays at zero bytes). +// +// Cross-firm: the bases this generator emits are firm-agnostic +// (firm = NULL on the catalog row). They contain no HLC branding +// content. Per-firm bases continue to use gen-hl-skeleton-template +// against the proprietary .dotm source. +package main + +import ( + "archive/zip" + "bytes" + "flag" + "fmt" + "os" + "strings" + "time" +) + +func main() { + preset := flag.String("preset", "", "preset: lg-duesseldorf | upc-formal") + out := flag.String("out", "", "output .docx path (required)") + flag.Parse() + + if *preset == "" || *out == "" { + fmt.Fprintln(os.Stderr, "usage: gen-submission-base -preset NAME -out PATH") + os.Exit(2) + } + + cfg, ok := presets[*preset] + if !ok { + fmt.Fprintf(os.Stderr, "unknown preset %q (available: ", *preset) + first := true + for k := range presets { + if !first { + fmt.Fprint(os.Stderr, ", ") + } + fmt.Fprint(os.Stderr, k) + first = false + } + fmt.Fprintln(os.Stderr, ")") + os.Exit(2) + } + + docx, err := buildDocx(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "gen-submission-base:", err) + os.Exit(1) + } + if err := os.WriteFile(*out, docx, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "gen-submission-base: write:", err) + os.Exit(1) + } + fmt.Printf("wrote %s (%d bytes) for preset %s\n", *out, len(docx), *preset) +} + +// presetConfig captures everything the generator needs to vary between +// bases: typography defaults (font + size + colour) and the style-name +// prefix that surfaces in the styles.xml. +type presetConfig struct { + StylePrefix string // e.g. "LG" / "UPC" + DefaultFont string // e.g. "Times New Roman" / "Calibri" + BodyHalfPoints int // w:sz value (half-points; 22 = 11pt) + Heading1Size int + Heading2Size int + Heading3Size int + Heading1Color string // hex without # + Heading2Color string + Heading3Color string + BlockquoteFont string // separate font for the quote style +} + +// presets are the seeded base styles for Slice E. Both are intended +// as starting points the firm's admin can refine via the admin editor +// in a later slice — this is the floor, not the ceiling. +var presets = map[string]presetConfig{ + "lg-duesseldorf": { + StylePrefix: "LG", + DefaultFont: "Times New Roman", + BodyHalfPoints: 22, // 11pt + Heading1Size: 28, // 14pt + Heading2Size: 26, // 13pt + Heading3Size: 24, // 12pt + Heading1Color: "000000", + Heading2Color: "000000", + Heading3Color: "000000", + BlockquoteFont: "Times New Roman", + }, + "upc-formal": { + StylePrefix: "UPC", + DefaultFont: "Calibri", + BodyHalfPoints: 22, // 11pt + Heading1Size: 32, // 16pt + Heading2Size: 28, // 14pt + Heading3Size: 24, // 12pt + Heading1Color: "1F3864", // UPC dark blue + Heading2Color: "1F3864", + Heading3Color: "1F3864", + BlockquoteFont: "Cambria", + }, +} + +var fixedTime = time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) + +func buildDocx(cfg presetConfig) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + add := func(name, body string) error { + hdr := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: fixedTime} + w, err := zw.CreateHeader(hdr) + if err != nil { + return fmt.Errorf("create %s: %w", name, err) + } + if _, err := w.Write([]byte(body)); err != nil { + return fmt.Errorf("write %s: %w", name, err) + } + return nil + } + + if err := add("[Content_Types].xml", contentTypesXML); err != nil { + return nil, err + } + if err := add("_rels/.rels", rootRelsXML); err != nil { + return nil, err + } + if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil { + return nil, err + } + if err := add("word/styles.xml", buildStylesXML(cfg)); err != nil { + return nil, err + } + if err := add("word/document.xml", buildDocumentXML()); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("finalise zip: %w", err) + } + return buf.Bytes(), nil +} + +const contentTypesXML = ` + + + + + +` + +const rootRelsXML = ` + + +` + +// documentRelsXML — empty relationships envelope. The composer's +// hyperlink patch slots fresh +// rows in here at compose time. +const documentRelsXML = ` + + +` + +// buildStylesXML emits the stylemap-aligned named styles. Each style +// id matches what the catalog row's section_spec.stylemap declares +// for the corresponding key (paragraph / heading_1/2/3 / list_* +// / blockquote / Hyperlink). +// +// "Hyperlink" is the built-in Word style id the composer's MD walker +// emits on link-child runs (Slice D). Including it here makes the +// blue-underline-link rendering land out of the box. +func buildStylesXML(cfg presetConfig) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + + // Document defaults — sets the body font + size for every paragraph + // that doesn't override. + fmt.Fprintf(&b, ``, + cfg.DefaultFont, cfg.DefaultFont, cfg.DefaultFont, cfg.BodyHalfPoints) + + // Normal — Word's default paragraph style; nothing fancy. + b.WriteString(``) + + // Body style — body0 alias for the composer's stylemap.paragraph. + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix) + + // Headings — three levels with descending sizes + colours. + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix, cfg.Heading1Size, cfg.Heading1Color) + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix, cfg.Heading2Size, cfg.Heading2Color) + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix, cfg.Heading3Size, cfg.Heading3Color) + + // List paragraph styles — same indent as body but with hanging + // indent so the visible "• " / "N. " prefix from the MD walker + // aligns cleanly. + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix) + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix) + + // Blockquote — italic, indented, optional alternative font. + fmt.Fprintf(&b, ``, + cfg.StylePrefix, cfg.StylePrefix, cfg.BlockquoteFont, cfg.BlockquoteFont) + + // Hyperlink — Word's built-in character-style id matches what the + // MD walker emits, so the link runs pick up the colour + underline + // automatically. + b.WriteString(``) + + b.WriteString(``) + return b.String() +} + +// buildDocumentXML emits the composer-mode body — 10 default section +// anchors in the design's §6.1 order, nothing else. +func buildDocumentXML() string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + for _, key := range []string{ + "letterhead", "caption", "introduction", "requests", + "facts", "legal_argument", "evidence", "exhibits", + "closing", "signature", + } { + anchor(&b, "{{#section:"+key+"}}") + anchor(&b, "{{/section:"+key+"}}") + } + b.WriteString(``) + return b.String() +} + +func anchor(b *strings.Builder, text string) { + b.WriteString(``) + b.WriteString(text) + b.WriteString(``) +}