feat(submissions): fill firm.signature_block + fix generate-fallback junk (t-paliad-358 A-S1)
Two letterhead/Rubrum auto-fill fixes (Option A, no schema change):
1. firm.signature_block: was hardcoded "" ("reserved for Phase 2"), so every
template referencing {{firm.signature_block}} rendered blank. Now filled
from branding.Name — the firm identity line of a submission's signature
block (the signature section seeds with signature_block + user.display_name).
Firm-agnostic: a FIRM_NAME redeploy signs with the right firm.
2. Generate-fallback junk (kepler audit §1 Path 3): resolveSubmissionTemplate is
the merge-path resolver (every caller feeds merge.go), but its lower tiers
fetched _firm-skeleton.docx / _skeleton.docx — which were repurposed into
anchors-only Composer bases (t-paliad-313 Slice B). Their bodies hold only
{{#section:KEY}} markers, which placeholderRegex ignores, so merge.go emitted
them verbatim as literal "{{#section:letterhead}}…" junk for every code
without a per-code template (i.e. everything except de.inf.lg.erwidg).
Fix:
- docx.BuildFallbackSkeleton(lang): in-process, lang-aware, merge-safe basic
Schriftsatz with a data-driven basic Rubrum (real {{key}} placeholders the
var bag fills). Always available, no Gitea round-trip.
- docx.HasMergePlaceholders guards tiers 3/4/5: a fetched skeleton is used
only if it carries real placeholders, else we fall through to the embedded
fallback. Today's anchors-only/placeholder-free files are skipped; a future
merge-safe firm-skeleton (with letterhead) is preferred again automatically.
- merge.go strips stray {{#section:…}}/{{/section:…}} markers defensively so
no anchors-only carrier can ever leak Composer junk into a merged document.
Verified: confirmed live that deployed _firm-skeleton.docx + _skeleton.docx are
anchors-only (fetch+unzip); unit tests cover BuildFallbackSkeleton rendering a
real Rubrum (de+en), HasMergePlaceholders classification, marker stripping, and
the signature_block fill. go build / vet ./... / test ./... + bun build clean.
Out of scope (flagged for next slices): demo template's closing prints
{{firm.name}} then {{firm.signature_block}} (=firm.name) → A-S2 dedups the demo
wording. Restoring firm letterhead chrome to the merge fallback → A-S3.
This commit is contained in:
305
pkg/docforge/docx/fallback_skeleton.go
Normal file
305
pkg/docforge/docx/fallback_skeleton.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package docx
|
||||
|
||||
// Merge-safe fallback skeleton (t-paliad-358 A-S1).
|
||||
//
|
||||
// Why this exists: resolveSubmissionTemplate is the *merge-path* template
|
||||
// resolver — every caller feeds its result into SubmissionRenderer (merge.go),
|
||||
// which substitutes {{key}} tokens. Its lower fallback tiers used to fetch the
|
||||
// universal / firm skeletons from mWorkRepo, but those .docx files were
|
||||
// repurposed into Composer *bases* (t-paliad-313 Slice B): their bodies now
|
||||
// carry only {{#section:KEY}} anchor markers, which the Composer (compose.go)
|
||||
// splices section content into. placeholderRegex deliberately ignores markers
|
||||
// that start with '#' or '/', so when an anchors-only base reaches merge.go the
|
||||
// markers pass through verbatim and the lawyer sees literal
|
||||
// "{{#section:letterhead}}…" junk in Word (kepler audit §1 Path 3 / §2).
|
||||
//
|
||||
// Only de.inf.lg.erwidg ships a real per-code merge template today, so every
|
||||
// other submission_code's one-click /generate (and the v1 draft-export
|
||||
// fallback) was exposed to that junk. This builder gives the merge path a
|
||||
// self-contained, merge-safe fallback: a clean basic Schriftsatz with a
|
||||
// data-driven basic Rubrum built from real {{key}} placeholders the variable
|
||||
// bag fills. No Gitea round-trip, no Composer anchors, always available.
|
||||
//
|
||||
// Scope (A-S1): a *basic* caption — neutral, forum-hedged designation labels.
|
||||
// Parametrising heading / designations / court line per forum
|
||||
// (our_side / instance_level / proceeding.code) is A-S2. Restoring the firm
|
||||
// letterhead chrome to the merge path is A-S3 (firm-agnostic headers). This
|
||||
// file emits no firm-specific letterhead — {{firm.name}} / {{firm.signature_block}}
|
||||
// are filled from branding by the bag, keeping the skeleton firm-agnostic.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fallbackSkeletonTime pins every zip entry's mtime so the generated bytes are
|
||||
// byte-stable across calls (cheap to cache / diff, no spurious churn).
|
||||
var fallbackSkeletonTime = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// BuildFallbackSkeleton returns a minimal, Word-compatible .docx whose body is
|
||||
// a basic Schriftsatz with a data-driven Rubrum. Every dynamic value is a real
|
||||
// {{key}} placeholder resolved by SubmissionVarsService, so rendering it
|
||||
// through SubmissionRenderer.Render produces a merged document — never the
|
||||
// {{#section:…}} junk an anchors-only Composer base would.
|
||||
//
|
||||
// lang selects the static label language ("en" → English labels + EN date /
|
||||
// our-side aliases; anything else → German). The returned bytes are
|
||||
// self-contained: no external media, no firm letterhead, no macros.
|
||||
func BuildFallbackSkeleton(lang string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fallbackSkeletonTime,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
for _, part := range []struct{ name, body string }{
|
||||
{"[Content_Types].xml", fallbackContentTypesXML},
|
||||
{"_rels/.rels", fallbackRootRelsXML},
|
||||
{"word/_rels/document.xml.rels", fallbackDocumentRelsXML},
|
||||
{"word/styles.xml", fallbackStylesXML},
|
||||
{"word/document.xml", buildFallbackDocumentXML(lang)},
|
||||
} {
|
||||
if err := add(part.name, part.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const fallbackContentTypesXML = `<?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 fallbackRootRelsXML = `<?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>`
|
||||
|
||||
const fallbackDocumentRelsXML = `<?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>`
|
||||
|
||||
const fallbackStylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// fallbackLabels holds the language-dependent static text for the skeleton.
|
||||
// Dynamic values stay as {{key}} placeholders regardless of language.
|
||||
type fallbackLabels struct {
|
||||
editor string // "Bearbeiter:" / "Attorney:"
|
||||
dateKey string // {{today.long_de}} / {{today.long_en}}
|
||||
caseNo string // "Aktenzeichen:" / "Case no.:"
|
||||
inTheMatter string // "In der Sache" / "In the matter"
|
||||
representedBy string // "vertreten durch" / "represented by"
|
||||
claimantRole string // role designation line
|
||||
versus string // "gegen" / "against"
|
||||
defendantRole string
|
||||
others string // "Weitere Beteiligte:" / "Further parties:"
|
||||
subject string // "Betreff" / "Subject"
|
||||
patent string // "Streitpatent:" / "Patent in suit:"
|
||||
proceeding string // "Verfahrensart:" / "Proceeding:"
|
||||
ourSideKey string // {{project.our_side_de}} / {{project.our_side_en}}
|
||||
bodyHint string // editorial placeholder for the actual submission text
|
||||
closing string // "Schlussformel" / "Closing"
|
||||
}
|
||||
|
||||
func fallbackLabelsFor(lang string) fallbackLabels {
|
||||
if strings.EqualFold(lang, "en") {
|
||||
return fallbackLabels{
|
||||
editor: "Attorney:",
|
||||
dateKey: "{{today.long_en}}",
|
||||
caseNo: "Case no.:",
|
||||
inTheMatter: "In the matter",
|
||||
representedBy: "represented by",
|
||||
claimantRole: "— Claimant / Patent proprietor / Applicant —",
|
||||
versus: "against",
|
||||
defendantRole: "— Defendant / Opponent / Respondent —",
|
||||
others: "Further parties:",
|
||||
subject: "Subject",
|
||||
patent: "Patent in suit:",
|
||||
proceeding: "Proceeding:",
|
||||
ourSideKey: "{{project.our_side_en}}",
|
||||
bodyHint: "[Body of the submission goes here. This is a basic skeleton — fill in according to the submission type.]",
|
||||
closing: "Closing",
|
||||
}
|
||||
}
|
||||
return fallbackLabels{
|
||||
editor: "Bearbeiter:",
|
||||
dateKey: "{{today.long_de}}",
|
||||
caseNo: "Aktenzeichen:",
|
||||
inTheMatter: "In der Sache",
|
||||
representedBy: "vertreten durch",
|
||||
claimantRole: "— Klägerin / Patentinhaberin / Anmelderin —",
|
||||
versus: "gegen",
|
||||
defendantRole: "— Beklagte / Einsprechende / Beschwerdegegnerin —",
|
||||
others: "Weitere Beteiligte:",
|
||||
subject: "Betreff",
|
||||
patent: "Streitpatent:",
|
||||
proceeding: "Verfahrensart:",
|
||||
ourSideKey: "{{project.our_side_de}}",
|
||||
bodyHint: "[Hier folgt der Schriftsatztext. Diese Skelett-Vorlage trägt keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ergänzen.]",
|
||||
closing: "Schlussformel",
|
||||
}
|
||||
}
|
||||
|
||||
// buildFallbackDocumentXML emits the document body. Layout: firm header line →
|
||||
// court + case number → basic Rubrum (claimant / vs / defendant / others) →
|
||||
// subject (patent) → submission body placeholder → closing (date / author /
|
||||
// firm signature block). Every placeholder occupies its own run so the
|
||||
// renderer's pass-1 single-run substitution catches it.
|
||||
func buildFallbackDocumentXML(lang string) string {
|
||||
l := fallbackLabelsFor(lang)
|
||||
|
||||
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>`)
|
||||
|
||||
// Letterhead-ish header block (firm-agnostic — values from branding bag).
|
||||
fbHeading1(&b, "{{firm.name}}")
|
||||
fbPlain(&b, l.editor+" {{user.display_name}}")
|
||||
fbPlain(&b, "{{user.email}} · {{user.office}}")
|
||||
fbPlain(&b, l.dateKey)
|
||||
|
||||
// Court + case number.
|
||||
fbHeading2(&b, "{{project.court}}")
|
||||
fbPlain(&b, l.caseNo+" {{project.case_number}}")
|
||||
fbPlain(&b, l.proceeding+" {{project.proceeding.name}}")
|
||||
|
||||
// Basic Rubrum.
|
||||
fbHeading2(&b, l.inTheMatter)
|
||||
fbPlain(&b, "{{parties.claimant.name}}")
|
||||
fbPlain(&b, l.representedBy+" {{parties.claimant.representative}}")
|
||||
fbBold(&b, l.claimantRole)
|
||||
fbPlain(&b, "")
|
||||
fbPlain(&b, l.versus)
|
||||
fbPlain(&b, "")
|
||||
fbPlain(&b, "{{parties.defendant.name}}")
|
||||
fbPlain(&b, l.representedBy+" {{parties.defendant.representative}}")
|
||||
fbBold(&b, l.defendantRole)
|
||||
fbPlain(&b, l.others+" {{parties.other.name}}")
|
||||
|
||||
// Subject (patent in suit).
|
||||
fbHeading2(&b, l.subject)
|
||||
fbPlain(&b, l.patent+" {{project.patent_number}}")
|
||||
fbPlain(&b, "{{project.title}} ("+l.ourSideKey+")")
|
||||
|
||||
// Body placeholder for the actual submission text.
|
||||
fbPlain(&b, "")
|
||||
fbPlain(&b, l.bodyHint)
|
||||
fbPlain(&b, "")
|
||||
|
||||
// Closing / signature.
|
||||
fbHeading2(&b, l.closing)
|
||||
fbPlain(&b, l.dateKey)
|
||||
fbPlain(&b, "{{user.display_name}}")
|
||||
fbPlain(&b, "{{firm.signature_block}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fbHeading1(b *strings.Builder, text string) { fbParagraph(b, "Heading1", text, false) }
|
||||
func fbHeading2(b *strings.Builder, text string) { fbParagraph(b, "Heading2", text, false) }
|
||||
func fbPlain(b *strings.Builder, text string) { fbParagraph(b, "", text, false) }
|
||||
func fbBold(b *strings.Builder, text string) { fbParagraph(b, "", text, true) }
|
||||
|
||||
// fbParagraph writes one paragraph with the given pStyle and optional bold runs.
|
||||
// Placeholders are split into their own runs so the renderer's format-preserving
|
||||
// pass-1 substitution catches each one independently.
|
||||
func fbParagraph(b *strings.Builder, style, text string, bold bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range fbSplitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if bold {
|
||||
b.WriteString(`<w:rPr><w:b/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(fbXMLEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
// fbSplitOnPlaceholders splits text so each {{placeholder}} sits in its own
|
||||
// segment (and therefore its own run), keeping every key inside a single run.
|
||||
func fbSplitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
closeIdx := strings.Index(s[open:], "}}")
|
||||
if closeIdx < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + closeIdx + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fbXMLEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
107
pkg/docforge/docx/fallback_skeleton_test.go
Normal file
107
pkg/docforge/docx/fallback_skeleton_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package docx
|
||||
|
||||
// Tests for the merge-safe fallback skeleton + the merge-path guards that
|
||||
// keep anchors-only Composer bases from leaking {{#section:…}} junk into a
|
||||
// merged document (t-paliad-358 A-S1).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
func TestBuildFallbackSkeleton_IsMergeSafeAndRendersRubrum(t *testing.T) {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
t.Run(lang, func(t *testing.T) {
|
||||
tpl, err := BuildFallbackSkeleton(lang)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildFallbackSkeleton(%q): %v", lang, err)
|
||||
}
|
||||
if !HasMergePlaceholders(tpl) {
|
||||
t.Fatalf("fallback skeleton (%s) reported no merge placeholders", lang)
|
||||
}
|
||||
|
||||
// The fallback must never carry Composer section anchors — it is a
|
||||
// merge template, not a Composer base.
|
||||
body := readMergeDocumentXML(t, tpl)
|
||||
if strings.Contains(body, "{{#section:") || strings.Contains(body, "{{/section:") {
|
||||
t.Fatalf("fallback skeleton (%s) leaked a section anchor: %s", lang, body)
|
||||
}
|
||||
|
||||
// Render it the way the merge path does and confirm the basic Rubrum
|
||||
// fills from the bag (claimant + defendant + court + case number).
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tpl, docforge.PlaceholderMap{
|
||||
"firm.name": "HLC",
|
||||
"firm.signature_block": "HLC",
|
||||
"user.display_name": "Dr. Max Mustermann",
|
||||
"parties.claimant.name": "Acme Corp.",
|
||||
"parties.defendant.name": "Globex GmbH",
|
||||
"project.court": "Landgericht München I",
|
||||
"project.case_number": "7 O 1234/26",
|
||||
"project.patent_number": "EP 1 234 567 B1",
|
||||
}, docforge.DefaultMissingMarker(lang))
|
||||
if err != nil {
|
||||
t.Fatalf("render fallback (%s): %v", lang, err)
|
||||
}
|
||||
rendered := readMergeDocumentXML(t, out)
|
||||
for _, want := range []string{
|
||||
"Acme Corp.", "Globex GmbH", "Landgericht München I",
|
||||
"7 O 1234/26", "EP 1 234 567 B1", "HLC",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Errorf("rendered fallback (%s) missing %q\n%s", lang, want, rendered)
|
||||
}
|
||||
}
|
||||
// No unresolved placeholder braces for the keys we bound.
|
||||
if strings.Contains(rendered, "{{parties.claimant.name}}") {
|
||||
t.Errorf("rendered fallback (%s) left an unresolved bound placeholder", lang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasMergePlaceholders(t *testing.T) {
|
||||
mergeSafe := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
|
||||
`<w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||
if !HasMergePlaceholders(mergeSafe) {
|
||||
t.Error("expected merge-safe body to report placeholders")
|
||||
}
|
||||
|
||||
anchorsOnly := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
|
||||
`<w:p><w:r><w:t>{{#section:letterhead}}</w:t></w:r></w:p>`+
|
||||
`<w:p><w:r><w:t>{{/section:letterhead}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||
if HasMergePlaceholders(anchorsOnly) {
|
||||
t.Error("anchors-only Composer base must NOT report merge placeholders")
|
||||
}
|
||||
|
||||
noPlaceholders := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
|
||||
`<w:p><w:r><w:t>Letterhead only, no merge fields.</w:t></w:r></w:p></w:body></w:document>`)
|
||||
if HasMergePlaceholders(noPlaceholders) {
|
||||
t.Error("placeholder-free body must NOT report merge placeholders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_StripsStraySectionMarkers is the depth-in-defense check: if an
|
||||
// anchors-only Composer base ever reaches the merge path, the output must be
|
||||
// clean (markers stripped), never literal "{{#section:…}}" junk.
|
||||
func TestRender_StripsStraySectionMarkers(t *testing.T) {
|
||||
tmpl := minimalMergeDOCX(t, `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`+
|
||||
`<w:p><w:r><w:t>{{#section:letterhead}}</w:t></w:r></w:p>`+
|
||||
`<w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p>`+
|
||||
`<w:p><w:r><w:t>{{/section:letterhead}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if strings.Contains(body, "{{#section:") || strings.Contains(body, "{{/section:") {
|
||||
t.Errorf("section markers survived the merge: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "HLC") {
|
||||
t.Errorf("real placeholder around the markers was not substituted: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,19 @@ func htmlPreviewWrapper(key, value string) string {
|
||||
// always starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// sectionMarkerRegex matches a Composer section anchor —
|
||||
// {{#section:KEY}} (open) or {{/section:KEY}} (close). These markers are
|
||||
// the Composer's (compose.go) splice points; they are NOT merge
|
||||
// placeholders (placeholderRegex ignores them because they start with
|
||||
// '#' / '/'). When an anchors-only Composer base is mistakenly fed to
|
||||
// the merge path, the markers would otherwise survive verbatim into the
|
||||
// output and show up as literal "{{#section:letterhead}}…" junk in Word
|
||||
// (kepler audit §1 Path 3). substituteInDocumentXML strips them
|
||||
// defensively so no merged document ever leaks a Composer anchor — the
|
||||
// normal merge path uses a merge-safe template (BuildFallbackSkeleton),
|
||||
// this is depth-in-defense for any stray anchors-only carrier.
|
||||
var sectionMarkerRegex = regexp.MustCompile(`\{\{\s*[#/]\s*section\s*:\s*[A-Za-z0-9_.\-]+\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
@@ -181,6 +194,37 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.Plac
|
||||
return docXMLToHTML(merged), nil
|
||||
}
|
||||
|
||||
// HasMergePlaceholders reports whether the .docx at templateBytes carries
|
||||
// at least one real {{key}} merge placeholder in word/document.xml. The
|
||||
// merge path (resolveSubmissionTemplate → Render) needs this to tell a
|
||||
// merge-usable template apart from an anchors-only Composer base (whose
|
||||
// body holds only {{#section:KEY}} markers, which placeholderRegex
|
||||
// ignores) or a placeholder-free letterhead (.dotm) — both of which would
|
||||
// render an empty Rubrum. Returns false on any read/zip error so the
|
||||
// caller safely falls back to a known merge-safe skeleton
|
||||
// (t-paliad-358 A-S1).
|
||||
func HasMergePlaceholders(templateBytes []byte) bool {
|
||||
clean, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(clean), int64(len(clean)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range zr.File {
|
||||
if entry.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
body, err := readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return placeholderRegex.Match(body)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
@@ -227,6 +271,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run.
|
||||
func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
body = stripSectionMarkers(body)
|
||||
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
@@ -234,6 +279,24 @@ func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing
|
||||
return substituteAcrossRuns(replaced, vars, missing, wrap)
|
||||
}
|
||||
|
||||
// stripSectionMarkers removes any Composer section anchor ({{#section:KEY}}
|
||||
// / {{/section:KEY}}) from the <w:t> text nodes so a stray anchors-only
|
||||
// carrier rendered through the merge path produces a clean document
|
||||
// instead of literal "{{#section:…}}" junk. Markers are removed token-only
|
||||
// (the enclosing run/paragraph survives, just emptied of the marker), which
|
||||
// is safe because the generator emits each marker in its own paragraph.
|
||||
func stripSectionMarkers(body []byte) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
if !sectionMarkerRegex.MatchString(contents) {
|
||||
return match
|
||||
}
|
||||
stripped := sectionMarkerRegex.ReplaceAllString(contents, "")
|
||||
return []byte(`<w:t` + string(sub[1]) + `>` + xmlEncode(stripped) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
Reference in New Issue
Block a user