feat(submissions): Composer Slice E — specialist bases + base-swap content survival (m/paliad#141)
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
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- t-paliad-317: revert specialist base seed rows.
|
||||||
|
|
||||||
|
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');
|
||||||
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
@@ -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;
|
||||||
@@ -113,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
|
|||||||
RepoName: "mWorkRepo",
|
RepoName: "mWorkRepo",
|
||||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
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
|
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||||
// the shared fileRegistry cache. Exported via a const so handler code
|
// the shared fileRegistry cache. Exported via a const so handler code
|
||||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||||
@@ -413,6 +439,8 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
|||||||
var composerBaseSlugMap = map[string]string{
|
var composerBaseSlugMap = map[string]string{
|
||||||
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
||||||
"neutral": skeletonSubmissionSlug,
|
"neutral": skeletonSubmissionSlug,
|
||||||
|
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
|
||||||
|
"upc-formal": composerBaseUPCFormalSlug,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
||||||
|
|||||||
@@ -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 := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||||
|
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, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||||
|
t.Errorf("HLC heading style missing: %s", hlcXML)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||||
|
t.Errorf("LG heading style missing: %s", lgXML)
|
||||||
|
}
|
||||||
|
if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||||
|
t.Errorf("HLC output leaked LG style: %s", hlcXML)
|
||||||
|
}
|
||||||
|
if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||||
|
t.Errorf("LG output leaked HLC style: %s", lgXML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestComposer_OrderIndexAscending(t *testing.T) {
|
func TestComposer_OrderIndexAscending(t *testing.T) {
|
||||||
base := composerBase()
|
base := composerBase()
|
||||||
// No anchors → both sections append in order_index ASC order
|
// No anchors → both sections append in order_index ASC order
|
||||||
|
|||||||
256
scripts/gen-submission-base/main.go
Normal file
256
scripts/gen-submission-base/main.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// Composer Slice E base-template generator (t-paliad-317).
|
||||||
|
//
|
||||||
|
// Produces a minimal Composer-mode .docx whose <w:body> 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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||||
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||||
|
</Types>`
|
||||||
|
|
||||||
|
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
// documentRelsXML — empty relationships envelope. The composer's
|
||||||
|
// hyperlink patch slots fresh <Relationship Type="…/hyperlink"/>
|
||||||
|
// rows in here at compose time.
|
||||||
|
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
// 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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||||
|
|
||||||
|
// Document defaults — sets the body font + size for every paragraph
|
||||||
|
// that doesn't override.
|
||||||
|
fmt.Fprintf(&b, `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="%s" w:hAnsi="%s" w:cs="%s"/><w:sz w:val="%d"/></w:rPr></w:rPrDefault></w:docDefaults>`,
|
||||||
|
cfg.DefaultFont, cfg.DefaultFont, cfg.DefaultFont, cfg.BodyHalfPoints)
|
||||||
|
|
||||||
|
// Normal — Word's default paragraph style; nothing fancy.
|
||||||
|
b.WriteString(`<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>`)
|
||||||
|
|
||||||
|
// Body style — body0 alias for the composer's stylemap.paragraph.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Body"><w:name w:val="%s body"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
|
||||||
|
// Headings — three levels with descending sizes + colours.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading1"><w:name w:val="%s heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="320" w:after="160"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading1Size, cfg.Heading1Color)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading2"><w:name w:val="%s heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading2Size, cfg.Heading2Color)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading3"><w:name w:val="%s heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||||
|
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, `<w:style w:type="paragraph" w:styleId="%s-ListBullet"><w:name w:val="%s list bullet"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListNumber"><w:name w:val="%s list number"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||||
|
cfg.StylePrefix, cfg.StylePrefix)
|
||||||
|
|
||||||
|
// Blockquote — italic, indented, optional alternative font.
|
||||||
|
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Quote"><w:name w:val="%s quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/><w:spacing w:before="120" w:after="120"/></w:pPr><w:rPr><w:i/><w:rFonts w:ascii="%s" w:hAnsi="%s"/></w:rPr></w:style>`,
|
||||||
|
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(`<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>`)
|
||||||
|
|
||||||
|
b.WriteString(`</w:styles>`)
|
||||||
|
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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||||
|
b.WriteString(`<w:body>`)
|
||||||
|
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(`</w:body></w:document>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func anchor(b *strings.Builder, text string) {
|
||||||
|
b.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
|
||||||
|
b.WriteString(text)
|
||||||
|
b.WriteString(`</w:t></w:r></w:p>`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user