Files
paliad/pkg/docforge/docx/fallback_skeleton.go
mAi 0763b7daa2 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.
2026-06-01 12:39:53 +02:00

306 lines
12 KiB
Go

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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}