Relocate the in-house OOXML machinery out of internal/services into the
first docforge adapter, with zero behaviour change:
submission_merge.go -> pkg/docforge/docx/merge.go (placeholder
substitution renderer + preview-HTML emitter)
submission_md.go -> pkg/docforge/docx/markdown.go (Markdown->OOXML
walker incl. the b78a984 underscore-fix)
submission_render.go -> pkg/docforge/docx/dotm.go (.dotm->.docx)
+ their _test.go files (git-tracked renames, 84-99% identical)
internal/services keeps thin type-alias + forwarder shims
(docforge_shims.go) so every caller in services/handlers/main compiles
and behaves identically: PlaceholderMap, MissingPlaceholderFn,
SubmissionRenderer, HyperlinkAllocator (aliases); NewSubmissionRenderer,
DefaultMissingMarker, RenderMarkdownToOOXML[WithStyles], ConvertDotmToDocx,
SanitiseSubmissionFileName (forwarders). docx.XMLAttrEscape is exported so
submission_compose.go's hyperlink-rels inserts reuse the walker's escaping.
Three mis-filed pretty-printer tests (legalSourcePretty, ourSideDE/EN,
patentNumberUPC) that exercise the vars layer move back to
internal/services/submission_vars_pretty_test.go.
Placeholder grammar + PlaceholderMap stay co-located with the renderer in
docx for now; slice 3 hoists the format-neutral grammar to the docforge
root with the VariableResolver interface.
Verification: go build ./... clean, go vet clean, full module test green
(the byte-exact OOXML golden tests in merge/compose/render pass unchanged
= behaviour preserved). gofmt drift on the moved files is pre-existing
(72/169 services files already drift; no gofmt gate).
m/paliad#157
256 lines
8.9 KiB
Go
256 lines
8.9 KiB
Go
package docx
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// minimalDOTM builds a small .dotm zip whose shape mirrors the real
|
|
// HL Patents Style template: macro-enabled main content type, Default
|
|
// extension declaring .bin as vbaProject, Overrides for vbaData.xml +
|
|
// customizations.xml, document.xml.rels with vbaProject +
|
|
// keyMapCustomizations relationships, and the four macro parts on
|
|
// disk (vbaProject.bin + auxiliary rels + vbaData.xml +
|
|
// customizations.xml).
|
|
//
|
|
// In-memory so the test is self-contained (no checked-in binary).
|
|
// Word and LibreOffice would reject this minimal file as incomplete
|
|
// (no _rels/.rels root manifest); the tests work at the byte level
|
|
// and assert structural properties of the converted output.
|
|
func minimalDOTM(t *testing.T) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
t.Helper()
|
|
w, err := zw.CreateHeader(&zip.FileHeader{
|
|
Name: name,
|
|
Method: zip.Deflate,
|
|
Modified: time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("zip header %s: %v", name, err)
|
|
}
|
|
if _, err := io.WriteString(w, body); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
add(contentTypesPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`+
|
|
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`+
|
|
`<Default Extension="xml" ContentType="application/xml"/>`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+dotmMainContentType+`"/>`+
|
|
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`+
|
|
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`+
|
|
`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`+
|
|
`</Types>`)
|
|
|
|
add("word/document.xml",
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
|
`<w:body><w:p><w:r><w:t>Hello Paliad</w:t></w:r></w:p></w:body></w:document>`)
|
|
|
|
add(documentRelsPath,
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
|
|
`<Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>`+
|
|
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`+
|
|
`<Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations" Target="customizations.xml"/>`+
|
|
`</Relationships>`)
|
|
|
|
add("word/styles.xml", `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`)
|
|
add("word/vbaProject.bin", "PRETEND-VBA-BINARY-PAYLOAD")
|
|
add("word/_rels/vbaProject.bin.rels", `<?xml version="1.0"?><Relationships/>`)
|
|
add("word/vbaData.xml", `<?xml version="1.0"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
|
add("word/customizations.xml", `<?xml version="1.0"?><wne:tcg xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
|
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func unzipEntries(t *testing.T, data []byte) map[string]string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("open output zip: %v", err)
|
|
}
|
|
out := make(map[string]string, len(zr.File))
|
|
for _, f := range zr.File {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", f.Name, err)
|
|
}
|
|
body, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", f.Name, err)
|
|
}
|
|
out[f.Name] = string(body)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestConvertDotmToDocx_StripsMacroParts(t *testing.T) {
|
|
dotm := minimalDOTM(t)
|
|
out, err := ConvertDotmToDocx(dotm)
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
|
|
entries := unzipEntries(t, out)
|
|
|
|
for _, name := range []string{
|
|
"word/vbaProject.bin",
|
|
"word/_rels/vbaProject.bin.rels",
|
|
"word/vbaData.xml",
|
|
"word/customizations.xml",
|
|
} {
|
|
if _, ok := entries[name]; ok {
|
|
t.Errorf("output still contains %s", name)
|
|
}
|
|
}
|
|
if doc, ok := entries["word/document.xml"]; !ok {
|
|
t.Error("output is missing word/document.xml")
|
|
} else if !strings.Contains(doc, "Hello Paliad") {
|
|
t.Errorf("document body lost during conversion: %q", doc)
|
|
}
|
|
if _, ok := entries["word/styles.xml"]; !ok {
|
|
t.Error("output lost unrelated word/styles.xml")
|
|
}
|
|
|
|
ctypes, ok := entries[contentTypesPath]
|
|
if !ok {
|
|
t.Fatal("output is missing [Content_Types].xml")
|
|
}
|
|
if strings.Contains(ctypes, "macroEnabled") {
|
|
t.Errorf("output [Content_Types].xml still references a macro-enabled type: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("output is missing plain docx main content type: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "vbaProject") {
|
|
t.Errorf("output [Content_Types].xml still references vbaProject: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "vbaData") {
|
|
t.Errorf("output [Content_Types].xml still overrides vbaData: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "keyMapCustomizations") {
|
|
t.Errorf("output [Content_Types].xml still overrides customizations: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, "wordprocessingml.styles") {
|
|
t.Errorf("output lost unrelated styles Override: %q", ctypes)
|
|
}
|
|
|
|
rels, ok := entries[documentRelsPath]
|
|
if !ok {
|
|
t.Fatal("output is missing word/_rels/document.xml.rels")
|
|
}
|
|
if strings.Contains(rels, "vbaProject") {
|
|
t.Errorf("output rels still references vbaProject: %q", rels)
|
|
}
|
|
if strings.Contains(rels, "keyMapCustomizations") {
|
|
t.Errorf("output rels still references keyMapCustomizations: %q", rels)
|
|
}
|
|
if !strings.Contains(rels, "styles.xml") {
|
|
t.Errorf("output rels lost unrelated styles relationship: %q", rels)
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_IdempotentOnPlainDocx(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
if _, err := io.WriteString(w, body); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
add(contentTypesPath, `<?xml version="1.0"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+docxMainContentType+`"/>`+
|
|
`</Types>`)
|
|
add("word/document.xml", `<w:document/>`)
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
out, err := ConvertDotmToDocx(buf.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
|
|
entries := unzipEntries(t, out)
|
|
if _, ok := entries["word/vbaProject.bin"]; ok {
|
|
t.Error("plain docx grew a vbaProject during conversion")
|
|
}
|
|
if ctypes := entries[contentTypesPath]; !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("plain docx lost its content type: %q", ctypes)
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_AcceptsDocmAndDotx(t *testing.T) {
|
|
for _, mainType := range []string{docmMainContentType, dotxMainContentType} {
|
|
t.Run(mainType, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
w, _ := zw.Create(name)
|
|
_, _ = io.WriteString(w, body)
|
|
}
|
|
add(contentTypesPath, `<?xml version="1.0"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+mainType+`"/>`+
|
|
`</Types>`)
|
|
add("word/document.xml", `<w:document/>`)
|
|
zw.Close()
|
|
out, err := ConvertDotmToDocx(buf.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
ctypes := unzipEntries(t, out)[contentTypesPath]
|
|
if strings.Contains(ctypes, mainType) {
|
|
t.Errorf("non-docx main type survived conversion: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("docx main type not present: %q", ctypes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_RejectsNonZip(t *testing.T) {
|
|
_, err := ConvertDotmToDocx([]byte("not a zip file"))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zip input, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSanitiseSubmissionFileName(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Klageerwiderung": "Klageerwiderung",
|
|
"Berufungsbegründung": "Berufungsbegruendung",
|
|
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
|
|
`Statement of "Defence"`: "Statement of Defence",
|
|
` Klage `: "Klage",
|
|
"Größe": "Groesse",
|
|
}
|
|
for in, want := range cases {
|
|
t.Run(in, func(t *testing.T) {
|
|
if got := SanitiseSubmissionFileName(in); got != want {
|
|
t.Errorf("SanitiseSubmissionFileName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|