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
257 lines
11 KiB
Go
257 lines
11 KiB
Go
// 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>`)
|
|
}
|