Move the variable-bag contract (PlaceholderMap, MissingPlaceholderFn,
DefaultMissingMarker) up to the pkg/docforge root (placeholder.go) — it is
format-neutral, consumed by the resolver layer and any future exporter.
The {{key}} substitution grammar (placeholderRegex, PUA preview sentinels,
replacePlaceholders) stays in pkg/docforge/docx: it is the .docx renderer's
own machinery, not a root concern.
New at the root (vars.go):
- VariableResolver{Namespace() string; Populate(bag PlaceholderMap)} —
a PUSH interface, deliberately not pull Resolve(key): some namespaces
emit a data-dependent key set (parties.claimant.0.name, .1.name, … one
per party) that a fixed key-by-key pull can't enumerate.
- ResolverSet + BuildBag() — composes resolvers into one bag, replacing
the hard-coded addFooVars-then-addBarVars sequencing in Build.
paliad side (submission_vars_resolvers.go): seven resolver types wrap the
UNCHANGED addXxxVars push-builders (firm/today/user/procedural_event/
project/parties/deadline), each capturing the entity it needs. The builder
bodies are byte-for-byte untouched, so the bag is identical by
construction; SubmissionVarsService.Build now wires the applicable
resolvers and calls ResolverSet.BuildBag(). Resolvers stay in paliad
because they read paliad's domain model; a second docforge consumer plugs
its own resolvers into a ResolverSet the same way.
Keys()/Catalogue() (the static key list that will data-drive the authoring
palette + kill the hardcoded VARIABLE_GROUPS in submission-draft.ts) is
deferred to the UI slice that consumes it, sourced from the frontend's
existing labels — building it now, ahead of its consumer, would be
speculative (PRD §4 B3 principle).
Verification: go build ./... clean, go vet clean, full module test green.
Alias-parity (procedural_event ≡ rule) and party-form tests pass unchanged
= bag byte-identical.
m/paliad#157
637 lines
22 KiB
Go
637 lines
22 KiB
Go
package docx
|
|
|
|
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
|
// §9.2). Assembles a base .docx and a draft's section rows into a
|
|
// merged .docx ready for export.
|
|
//
|
|
// Pipeline (high-level):
|
|
//
|
|
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
|
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
|
// 3. For each section in the draft (order_index ASC, included=true):
|
|
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
|
// base.section_spec.stylemap.paragraph.
|
|
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
|
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
|
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
|
// (including the anchor paragraphs themselves) with the rendered
|
|
// section.
|
|
// - Append mode: when no anchor pair is found for a section, the
|
|
// rendered OOXML appends at the end of the body, just before any
|
|
// `<w:sectPr>` element. Sections with `included=false` are
|
|
// dropped silently.
|
|
// 5. Strip any leftover unmatched anchor paragraphs.
|
|
// 6. Re-pack the document.xml into the zip, leaving every other part
|
|
// untouched.
|
|
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
|
// so `{{path}}` placeholders inside section content (and inside
|
|
// the base's untouched chrome) get substituted by the merged bag.
|
|
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
|
// placeholders the same as v1.
|
|
//
|
|
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
|
// archive/zip + the existing SubmissionRenderer.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
|
)
|
|
|
|
// Composer assembles base + sections into a final .docx.
|
|
// Stateless; safe for concurrent use.
|
|
type Composer struct {
|
|
renderer *SubmissionRenderer
|
|
}
|
|
|
|
// NewComposer wires the composer. The renderer is required —
|
|
// a nil renderer is a programmer error and the composer panics at
|
|
// construction.
|
|
func NewComposer(renderer *SubmissionRenderer) *Composer {
|
|
if renderer == nil {
|
|
panic("submission composer: renderer required")
|
|
}
|
|
return &Composer{renderer: renderer}
|
|
}
|
|
|
|
// Carrier is the opaque base document the composer splices rendered
|
|
// content into. Its bytes are preserved verbatim outside the regions the
|
|
// splice touches — the {{#section:KEY}} anchor paragraphs and the
|
|
// {{placeholder}} tokens — so the firm's letterhead, styles, headers, and
|
|
// footers survive a compose byte-for-byte. This is the docforge "carrier"
|
|
// for the .docx format: the lossless host for editable content.
|
|
type Carrier struct {
|
|
// Bytes is the raw base .docx. May be a .dotm/.docm/.dotx; Compose
|
|
// runs ConvertDotmToDocx on it first (idempotent on a plain .docx).
|
|
Bytes []byte
|
|
|
|
// Stylemap maps a logical block kind (paragraph, heading_1/2/3,
|
|
// list_bullet, list_numbered, blockquote) to the Word paragraph
|
|
// style name the base defines for it. Drives the Markdown walker's
|
|
// <w:pStyle>. Missing entries fall back to the "paragraph" style.
|
|
Stylemap map[string]string
|
|
}
|
|
|
|
// Section is one editable content block the composer renders and splices.
|
|
// It is the format-neutral input the docforge engine consumes; the
|
|
// consuming application maps its own row type onto it (paliad maps
|
|
// SubmissionSection → Section).
|
|
type Section struct {
|
|
// Key matches a {{#section:KEY}} anchor in the carrier, or — when no
|
|
// anchor matches — marks an append-mode section.
|
|
Key string
|
|
// OrderIndex sets append-mode ordering (ascending).
|
|
OrderIndex int
|
|
// Included=false drops the section entirely.
|
|
Included bool
|
|
// ContentMDDE / ContentMDEN are the bilingual Markdown sources; Lang
|
|
// selects which one renders.
|
|
ContentMDDE string
|
|
ContentMDEN string
|
|
}
|
|
|
|
// ComposeOptions carries the per-call composition inputs.
|
|
type ComposeOptions struct {
|
|
// Sections are the draft's section rows in display order. The
|
|
// composer renders included sections; excluded rows are dropped.
|
|
// Caller is responsible for visibility — by the time the composer
|
|
// runs, the section rows have already been gated by the caller.
|
|
Sections []Section
|
|
|
|
// Carrier is the base .docx chrome plus its stylemap. Required.
|
|
Carrier Carrier
|
|
|
|
// Lang ('de' or 'en') selects which content_md_* column the
|
|
// composer reads per section. Defaults to 'de' if empty.
|
|
Lang string
|
|
|
|
// Vars is the merged placeholder bag the v1 renderer pass
|
|
// substitutes after the composer assembly. Passed straight through
|
|
// to SubmissionRenderer.Render.
|
|
Vars docforge.PlaceholderMap
|
|
|
|
// Missing translates an unbound placeholder key into the marker
|
|
// the lawyer sees in Word. Passed straight to the renderer.
|
|
Missing docforge.MissingPlaceholderFn
|
|
}
|
|
|
|
// Compose runs the full pipeline and returns the merged .docx bytes.
|
|
func (c *Composer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
|
|
_ = ctx // reserved for cancellation propagation in later slices
|
|
sections := opts.Sections
|
|
|
|
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
|
cleanBytes, err := ConvertDotmToDocx(opts.Carrier.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
|
}
|
|
|
|
// Locate + extract word/document.xml so we can splice in-place.
|
|
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
|
// rId outside the base's existing namespace. The post-pass
|
|
// (patchDocumentXMLRels) writes the matching Relationship rows
|
|
// before the zip is repacked. Slice D adds inline `[label](url)`
|
|
// hyperlink support.
|
|
linkAlloc := newComposerLinkAllocator()
|
|
|
|
// Build the rendered-section map: section_key → OOXML span.
|
|
stylemap := opts.Carrier.Stylemap
|
|
rendered := make(map[string]string, len(sections))
|
|
keptSections := make([]Section, 0, len(sections))
|
|
for _, sec := range sections {
|
|
if !sec.Included {
|
|
continue
|
|
}
|
|
md := sec.ContentMDDE
|
|
if strings.EqualFold(opts.Lang, "en") {
|
|
md = sec.ContentMDEN
|
|
}
|
|
rendered[sec.Key] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
|
keptSections = append(keptSections, sec)
|
|
}
|
|
// Stable order — already sorted ascending by ListForDraft, but
|
|
// belt-and-braces in case the caller swaps the ordering policy
|
|
// later.
|
|
sort.SliceStable(keptSections, func(i, j int) bool {
|
|
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
|
})
|
|
|
|
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
|
|
|
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
|
// for inline `[label](url)` links, the base's
|
|
// word/_rels/document.xml.rels needs matching <Relationship>
|
|
// entries so Word can resolve the rIds. Mutates one zip part in
|
|
// otherParts (or appends if missing).
|
|
if linkAlloc.HasLinks() {
|
|
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
otherParts = updatedParts
|
|
}
|
|
|
|
// Re-pack into a zip with the assembled document.xml. All other
|
|
// parts (styles, fonts, headers, footers, theme, settings) pass
|
|
// through bit-for-bit at their original mtime + compression.
|
|
repacked, err := repackBaseZip(otherParts, assembledBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Final pass: substitute placeholders against the merged bag. The
|
|
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
|
// alias contract, and the missing-marker emission. Reusing it
|
|
// guarantees v1's placeholder grammar stays intact inside section
|
|
// content + base chrome.
|
|
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Section splicing
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// Anchor markers as they appear inside a <w:t> text node. We don't
|
|
// need a full XML parse — finding the marker text inside the body is
|
|
// sufficient because:
|
|
// - {{ and }} are never legitimate document content (placeholders
|
|
// follow the same convention everywhere else in paliad).
|
|
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
|
// special characters.
|
|
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
|
// exactly one <w:r>...</w:r>, which lives in exactly one
|
|
// <w:p>...</w:p>. We expand from the marker outward to find the
|
|
// enclosing <w:p> span and drop the entire paragraph as part of
|
|
// the splice.
|
|
//
|
|
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
|
// implemented as manual byte-index search around the marker hit
|
|
// (anchorParagraphSpan below) rather than a single regex pattern.
|
|
|
|
const (
|
|
anchorOpenPrefix = "{{#section:"
|
|
anchorClosePrefix = "{{/section:"
|
|
anchorSuffix = "}}"
|
|
)
|
|
|
|
// anchorKeyRegex validates that the captured anchor key is a clean
|
|
// identifier. Keys that include other characters (which can't actually
|
|
// appear in our authored .docx) are treated as no match.
|
|
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
|
|
|
// anchorPair records the byte span of one matched anchor pair inside
|
|
// the body — from the start of the opening anchor's <w:p> element
|
|
// through the end of the closing anchor's </w:p>.
|
|
type anchorPair struct {
|
|
key string
|
|
openStart int // start of <w:p> for the opening anchor
|
|
closeEnd int // index just past </w:p> for the closing anchor
|
|
}
|
|
|
|
// findAllAnchorPairs scans the body for matched open/close anchor
|
|
// pairs. Unbalanced markers (open without close, or vice versa) are
|
|
// dropped from the result. Returns pairs in body-order; each pair's
|
|
// span is non-overlapping.
|
|
func findAllAnchorPairs(body string) []anchorPair {
|
|
type marker struct {
|
|
key string
|
|
paraStart int
|
|
paraEnd int
|
|
isOpen bool
|
|
}
|
|
var markers []marker
|
|
|
|
collect := func(prefix string, isOpen bool) {
|
|
offset := 0
|
|
for {
|
|
idx := strings.Index(body[offset:], prefix)
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
start := offset + idx
|
|
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
|
if suffixIdx < 0 {
|
|
return
|
|
}
|
|
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
|
if !anchorKeyRegex.MatchString(key) {
|
|
offset = start + len(prefix)
|
|
continue
|
|
}
|
|
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
|
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
|
if !ok {
|
|
offset = markerEnd
|
|
continue
|
|
}
|
|
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
|
offset = pEnd
|
|
}
|
|
}
|
|
collect(anchorOpenPrefix, true)
|
|
collect(anchorClosePrefix, false)
|
|
|
|
// Walk markers in body-order, matching each open with the next
|
|
// close that carries the same key.
|
|
sort.SliceStable(markers, func(i, j int) bool {
|
|
return markers[i].paraStart < markers[j].paraStart
|
|
})
|
|
var pairs []anchorPair
|
|
openStack := map[string]marker{}
|
|
for _, m := range markers {
|
|
if m.isOpen {
|
|
openStack[m.key] = m
|
|
continue
|
|
}
|
|
o, ok := openStack[m.key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
pairs = append(pairs, anchorPair{
|
|
key: m.key,
|
|
openStart: o.paraStart,
|
|
closeEnd: m.paraEnd,
|
|
})
|
|
delete(openStack, m.key)
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
|
// element that fully contains the byte range [markerStart, markerEnd).
|
|
// Returns false when the byte range doesn't sit inside a single
|
|
// paragraph (which would mean the marker survived a cross-paragraph
|
|
// edit — defensive guard, shouldn't happen in well-formed input).
|
|
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
|
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
|
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
|
// the enclosing paragraph's opening tag.
|
|
pStart := -1
|
|
cursor := markerStart
|
|
for cursor > 0 {
|
|
idx := strings.LastIndex(body[:cursor], "<w:p")
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
// Confirm this is a paragraph open, not a different
|
|
// w:p-prefixed tag (e.g. <w:pPr>).
|
|
if idx+4 <= len(body) {
|
|
after := body[idx+4]
|
|
if after == ' ' || after == '>' || after == '/' {
|
|
// <w:p ...> or <w:p>; not <w:pPr>.
|
|
close := strings.Index(body[idx:], ">")
|
|
if close < 0 {
|
|
return 0, 0, false
|
|
}
|
|
pStart = idx
|
|
break
|
|
}
|
|
}
|
|
cursor = idx
|
|
}
|
|
if pStart < 0 {
|
|
return 0, 0, false
|
|
}
|
|
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
|
// the next </w:p> after the marker is the close.
|
|
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
|
if pEndIdx < 0 {
|
|
return 0, 0, false
|
|
}
|
|
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
|
return pStart, pEnd, true
|
|
}
|
|
|
|
// spliceSections replaces anchor slots with rendered sections and
|
|
// appends any unanchored sections before sectPr. Returns the assembled
|
|
// document.xml body.
|
|
func spliceSections(documentXML []byte, rendered map[string]string, kept []Section, all []Section) []byte {
|
|
body := string(documentXML)
|
|
pairs := findAllAnchorPairs(body)
|
|
|
|
// Build a lookup of kept section keys for quick membership tests.
|
|
keptByKey := map[string]int{}
|
|
for i, sec := range kept {
|
|
keptByKey[sec.Key] = i
|
|
}
|
|
allByKey := map[string]int{}
|
|
for i, sec := range all {
|
|
allByKey[sec.Key] = i
|
|
}
|
|
|
|
matchedKeys := map[string]bool{}
|
|
|
|
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
|
// later offsets.
|
|
sort.SliceStable(pairs, func(i, j int) bool {
|
|
return pairs[i].openStart > pairs[j].openStart
|
|
})
|
|
for _, p := range pairs {
|
|
replacement := ""
|
|
if idx, ok := keptByKey[p.key]; ok {
|
|
replacement = rendered[p.key]
|
|
matchedKeys[p.key] = true
|
|
_ = idx
|
|
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
|
// Anchor matches an excluded section on the draft — drop
|
|
// the entire slot.
|
|
replacement = ""
|
|
} else {
|
|
// Anchor doesn't match any section on this draft — drop
|
|
// to leave the base's chrome unbroken.
|
|
replacement = ""
|
|
}
|
|
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
|
}
|
|
|
|
// Append unanchored sections before sectPr in order_index ASC.
|
|
var unanchored strings.Builder
|
|
for _, sec := range kept {
|
|
if matchedKeys[sec.Key] {
|
|
continue
|
|
}
|
|
unanchored.WriteString(rendered[sec.Key])
|
|
}
|
|
if unanchored.Len() > 0 {
|
|
body = appendBeforeSectPr(body, unanchored.String())
|
|
}
|
|
|
|
return []byte(body)
|
|
}
|
|
|
|
// appendBeforeSectPr inserts content immediately before the first
|
|
// `<w:sectPr` element in the body, or at the end of the body if there
|
|
// is none. Word documents conventionally close the body with a sectPr
|
|
// describing page setup; we want to land sections before that element
|
|
// so they show up on the actual pages.
|
|
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
|
|
|
func appendBeforeSectPr(body, content string) string {
|
|
loc := sectPrRegex.FindStringIndex(body)
|
|
if loc == nil {
|
|
// No sectPr → append before `</w:body>` if present, else at
|
|
// the very end.
|
|
idx := strings.LastIndex(body, "</w:body>")
|
|
if idx < 0 {
|
|
return body + content
|
|
}
|
|
return body[:idx] + content + body[idx:]
|
|
}
|
|
return body[:loc[0]] + content + body[loc[0]:]
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Zip plumbing
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// baseZipPart captures one zip entry we kept aside while extracting
|
|
// document.xml.
|
|
type baseZipPart struct {
|
|
name string
|
|
method uint16
|
|
modTime int64 // wall seconds; converted back to time.Time on repack
|
|
body []byte
|
|
}
|
|
|
|
// splitBaseZip extracts document.xml and returns it alongside every
|
|
// other zip entry, ready for repacking.
|
|
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
|
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
|
}
|
|
var documentXML []byte
|
|
parts := make([]baseZipPart, 0, len(zr.File))
|
|
for _, f := range zr.File {
|
|
body, err := readZipEntry(f)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
|
}
|
|
if f.Name == "word/document.xml" {
|
|
documentXML = body
|
|
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
|
continue
|
|
}
|
|
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
|
}
|
|
if documentXML == nil {
|
|
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
|
}
|
|
return documentXML, parts, nil
|
|
}
|
|
|
|
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
|
// assembled body and leaving every other part untouched.
|
|
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
zw := zip.NewWriter(&out)
|
|
for _, p := range parts {
|
|
hdr := &zip.FileHeader{
|
|
Name: p.name,
|
|
Method: p.method,
|
|
}
|
|
if p.modTime > 0 {
|
|
hdr.Modified = time.Unix(p.modTime, 0)
|
|
}
|
|
w, err := zw.CreateHeader(hdr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
|
}
|
|
body := p.body
|
|
if p.name == "word/document.xml" {
|
|
body = assembledBody
|
|
}
|
|
if _, err := w.Write(body); err != nil {
|
|
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func readZipEntry(f *zip.File) ([]byte, error) {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(rc)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Slice D — hyperlink wiring
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
|
// targets discovered by the MD walker. Each unique URL gets one rId
|
|
// (deduped — repeated links to the same URL share one Relationship).
|
|
// Allocations land outside the base's rId namespace by prefixing with
|
|
// "rIdComposer" so they can't collide with existing relationships.
|
|
type composerLinkAllocator struct {
|
|
next int
|
|
byURL map[string]string
|
|
order []string // URLs in allocation order
|
|
}
|
|
|
|
func newComposerLinkAllocator() *composerLinkAllocator {
|
|
return &composerLinkAllocator{byURL: map[string]string{}}
|
|
}
|
|
|
|
// Alloc returns the rId for url, allocating one on first sight.
|
|
func (a *composerLinkAllocator) Alloc(url string) string {
|
|
if rid, ok := a.byURL[url]; ok {
|
|
return rid
|
|
}
|
|
a.next++
|
|
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
|
a.byURL[url] = rid
|
|
a.order = append(a.order, url)
|
|
return rid
|
|
}
|
|
|
|
// HasLinks reports whether any links were allocated during this compose.
|
|
func (a *composerLinkAllocator) HasLinks() bool {
|
|
return len(a.order) > 0
|
|
}
|
|
|
|
// Pairs returns the (rId, URL) pairs in allocation order. The
|
|
// document.xml.rels patcher consumes this to emit <Relationship>
|
|
// elements.
|
|
func (a *composerLinkAllocator) Pairs() [][2]string {
|
|
pairs := make([][2]string, 0, len(a.order))
|
|
for _, url := range a.order {
|
|
pairs = append(pairs, [2]string{a.byURL[url], url})
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
|
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
|
// relationships. If the rels part doesn't exist (some bases omit it
|
|
// when the body has no relationships), this function appends a fresh
|
|
// part with the minimal Relationships wrapper.
|
|
//
|
|
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
|
// already references the URL for some other reason).
|
|
//
|
|
// Returns the (possibly extended) parts slice — callers must overwrite
|
|
// their reference because the append in the no-rels-yet case grows the
|
|
// backing array.
|
|
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
|
const path = "word/_rels/document.xml.rels"
|
|
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
|
|
|
existingIdx := -1
|
|
for i := range parts {
|
|
if parts[i].name == path {
|
|
existingIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
var body string
|
|
if existingIdx >= 0 {
|
|
body = string(parts[existingIdx].body)
|
|
} else {
|
|
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
|
}
|
|
|
|
var inserts strings.Builder
|
|
for _, p := range pairs {
|
|
rid := p[0]
|
|
url := p[1]
|
|
if strings.Contains(body, `Id="`+rid+`"`) {
|
|
continue
|
|
}
|
|
inserts.WriteString(`<Relationship Id="`)
|
|
inserts.WriteString(xmlAttrEscape(rid))
|
|
inserts.WriteString(`" Type="`)
|
|
inserts.WriteString(hyperlinkType)
|
|
inserts.WriteString(`" Target="`)
|
|
inserts.WriteString(xmlAttrEscape(url))
|
|
inserts.WriteString(`" TargetMode="External"/>`)
|
|
}
|
|
|
|
if inserts.Len() == 0 {
|
|
return parts, nil
|
|
}
|
|
|
|
closeIdx := strings.LastIndex(body, "</Relationships>")
|
|
if closeIdx < 0 {
|
|
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
|
}
|
|
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
|
|
|
if existingIdx >= 0 {
|
|
parts[existingIdx].body = []byte(patched)
|
|
return parts, nil
|
|
}
|
|
parts = append(parts, baseZipPart{
|
|
name: path,
|
|
method: zip.Deflate,
|
|
modTime: time.Now().Unix(),
|
|
body: []byte(patched),
|
|
})
|
|
return parts, nil
|
|
}
|