Merge: Composer Slice E — specialist bases lg-duesseldorf + upc-formal (mig 150) + base-swap content survival (m/paliad#141)
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",
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
base := composerBase()
|
||||
// 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