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.
306 lines
12 KiB
Go
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, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
s = strings.ReplaceAll(s, "'", "'")
|
|
return s
|
|
}
|