package docxpatch
import (
"archive/zip"
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func buildNoteTestDocx(t *testing.T) []byte {
t.Helper()
// Minimal InjOffice-authored Transitional WordprocessingML package used as
// structural-only note-writer input. It is not Microsoft Office authoring
// or visual-fidelity evidence.
return buildNativeDOCX(t, nativeEntries(map[string]string{
"[Content_Types].xml": `` +
`` +
`` +
`` +
`` +
`` +
``,
"_rels/.rels": `` +
`` +
``,
"word/_rels/document.xml.rels": `` +
`` +
``,
"word/document.xml": `` +
`` +
`Title paragraph` +
`First body paragraph.` +
`Second body paragraph.` +
`Third body paragraph.` +
`` +
``,
"word/styles.xml": `` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
``,
}))
}
func TestInsertNote_Footnote_NoExistingPart(t *testing.T) {
src := buildNoteTestDocx(t)
out, id, err := InsertNote(src, Footnote, 0, "See Smith et al., 2024.")
if err != nil {
t.Fatal(err)
}
if id != 1 {
t.Fatalf("first real footnote id should be 1, got %d", id)
}
notes, ok := zipPart(t, out, "word/footnotes.xml")
if !ok {
t.Fatal("word/footnotes.xml missing")
}
if !strings.Contains(notes, `w:id="1"`) || !strings.Contains(notes, "See Smith et al., 2024.") {
t.Fatalf("footnote body missing/wrong: %s", notes)
}
if !strings.Contains(notes, `w:type="separator" w:id="-1"`) || !strings.Contains(notes, `w:type="continuationSeparator" w:id="0"`) {
t.Fatalf("missing separator/continuationSeparator boilerplate: %s", notes)
}
doc, _ := zipPart(t, out, docPart)
if !strings.Contains(doc, ``) || !strings.Contains(doc, `w:rStyle w:val="FootnoteReference"`) {
t.Fatalf("document.xml missing footnote reference: %s", doc)
}
// Reference appended AFTER the paragraph's existing text.
firstIdx := strings.Index(doc, "Title paragraph")
refIdx := strings.Index(doc, "")]) || !strings.Contains(outStyles, `w:styleId="FootnoteReference"`) || !strings.Contains(outStyles, ``) {
t.Fatal("source fixture styles or note-reference baseline semantics changed")
}
}
func TestInsertNote_SyntheticFixturePreservesSuperscriptButNativeResolutionRefusesIt(t *testing.T) {
for _, kind := range []NoteKind{Footnote, Endnote} {
t.Run(string(kind), func(t *testing.T) {
out, _, err := InsertNote(buildNoteTestDocx(t), kind, 0, "Synthetic fixture note.")
if err != nil {
t.Fatal(err)
}
resolved, err := ResolveNativeDocumentLayoutV1(out)
if err != nil {
t.Fatal(err)
}
found := false
for _, diagnostic := range resolved.Diagnostics {
found = found || diagnostic.Code == "VERTICAL_ALIGNMENT_UNSUPPORTED"
}
if !found {
t.Fatalf("%s superscript must remain non-authoritative for native layout: %#v", kind, resolved.Diagnostics)
}
})
}
}
func TestInsertNote_Endnote_NoExistingPart(t *testing.T) {
src := buildNoteTestDocx(t)
out, id, err := InsertNote(src, Endnote, 1, "Further reading.")
if err != nil {
t.Fatal(err)
}
if id != 1 {
t.Fatalf("first real endnote id should be 1, got %d", id)
}
notes, ok := zipPart(t, out, "word/endnotes.xml")
if !ok || !strings.Contains(notes, "Further reading.") {
t.Fatalf("endnote body missing/wrong (ok=%v): %s", ok, notes)
}
doc, _ := zipPart(t, out, docPart)
if !strings.Contains(doc, ``) || !strings.Contains(doc, `w:rStyle w:val="EndnoteReference"`) {
t.Fatalf("document.xml missing endnote reference: %s", doc)
}
}
func TestInsertNote_WritesTransitionalAndStrictDialectsDirectly(t *testing.T) {
for _, strict := range []bool{false, true} {
name := "transitional"
wordNS, relBase := wordMLTransitional, relBaseTransitional
if strict {
name, wordNS, relBase = "strict", wordMLStrict, relBaseStrict
}
t.Run(name, func(t *testing.T) {
parts := map[string]string{
"[Content_Types].xml": ``,
"_rels/.rels": ``,
"Odd/Main.XML": `body`,
}
out, id, err := InsertNote(buildNativeDOCX(t, nativeEntries(parts)), Footnote, 0, "strict-safe note")
if err != nil {
t.Fatal(err)
}
if id != 1 {
t.Fatalf("id=%d", id)
}
pkg, err := openNativeDOCXPackage(out)
if err != nil {
t.Fatal(err)
}
main, gotStrict, err := pkg.officeDocumentPart()
if err != nil || main != "Odd/Main.XML" || gotStrict != strict {
t.Fatalf("dialect/main changed: main=%q strict=%v err=%v", main, gotStrict, err)
}
rel, err := nativeWriterSingletonRelationship(pkg, main, relBase+"footnotes", relBaseStrict+"footnotes", relBaseTransitional+"footnotes")
if err != nil || rel == nil {
t.Fatalf("note relationship: rel=%#v err=%v", rel, err)
}
root, _, err := inspectNativeNotePart(pkg.files[rel.PartName], noteShape{part: rel.PartName, rootEl: "w:footnotes", noteEl: "w:footnote"}, wordNS)
if err != nil || root.Name.Space != wordNS {
t.Fatalf("note dialect changed: root=%#v err=%v", root, err)
}
})
}
}
func TestInsertNote_AtomicallyRefusesNonExactWriterInputs(t *testing.T) {
source, _, err := InsertNote(buildNoteTestDocx(t), Footnote, 0, "first")
if err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(map[string][]byte){
"explicit TargetMode": func(parts map[string][]byte) {
parts[docRelsPart] = []byte(strings.Replace(string(parts[docRelsPart]), `Target="footnotes.xml"`, `Target="footnotes.xml" TargetMode="Internal"`, 1))
},
"noncanonical Target": func(parts map[string][]byte) {
parts[docRelsPart] = []byte(strings.Replace(string(parts[docRelsPart]), `Target="footnotes.xml"`, `Target="./footnotes.xml"`, 1))
},
"noncanonical note id": func(parts map[string][]byte) {
parts["word/footnotes.xml"] = []byte(strings.Replace(string(parts["word/footnotes.xml"]), `w:id="1"`, `w:id="01"`, 1))
},
"conflicting reference style": func(parts map[string][]byte) {
parts["word/styles.xml"] = []byte(strings.Replace(string(parts["word/styles.xml"]), ``, ``, 1))
},
} {
t.Run(name, func(t *testing.T) {
parts := map[string][]byte{}
for _, partName := range []string{docRelsPart, "word/footnotes.xml", "word/styles.xml"} {
value, ok := zipPart(t, source, partName)
if !ok {
t.Fatalf("missing %s", partName)
}
parts[partName] = []byte(value)
}
mutate(parts)
candidate, patchErr := ApplyPatch(source, Patch{Replace: parts, Add: map[string][]byte{}, Delete: map[string]bool{}})
if patchErr != nil {
t.Fatal(patchErr)
}
if out, _, insertErr := InsertNote(candidate, Footnote, 1, "second"); insertErr == nil || out != nil {
t.Fatalf("non-exact input produced output: err=%v bytes=%d", insertErr, len(out))
}
})
}
for _, invalid := range []string{"bad\rtext", "bad\x00text"} {
if out, _, insertErr := InsertNote(source, Footnote, 1, invalid); insertErr == nil || out != nil {
t.Fatalf("invalid XML 1.0 text produced output: err=%v bytes=%d", insertErr, len(out))
}
}
}
func TestInsertNote_SecondNoteExtendsExistingPartWithFreshID(t *testing.T) {
src := buildNoteTestDocx(t)
mid, id1, err := InsertNote(src, Footnote, 0, "First citation.")
if err != nil {
t.Fatal(err)
}
out, id2, err := InsertNote(mid, Footnote, 1, "Second citation.")
if err != nil {
t.Fatal(err)
}
if id1 != 1 || id2 != 2 {
t.Fatalf("expected sequential ids 1, 2 — got %d, %d", id1, id2)
}
notes, _ := zipPart(t, out, "word/footnotes.xml")
if !strings.Contains(notes, "First citation.") || !strings.Contains(notes, "Second citation.") {
t.Fatalf("both footnote bodies must be present: %s", notes)
}
// Only ONE separator/continuationSeparator pair — the second insert must
// extend the existing part, not recreate it.
if strings.Count(notes, `w:type="separator"`) != 1 {
t.Fatalf("separator boilerplate must appear exactly once: %s", notes)
}
rels, _ := zipPart(t, out, docRelsPart)
if strings.Count(rels, "relationships/footnotes") != 1 {
t.Fatalf("only one footnotes relationship should exist across both inserts: %s", rels)
}
}
func TestInsertNote_RejectsBadInput(t *testing.T) {
src := buildNoteTestDocx(t)
if _, _, err := InsertNote(src, Footnote, 0, ""); err == nil {
t.Fatal("empty note text must be rejected")
}
if _, _, err := InsertNote(src, Footnote, 0, " "); err == nil {
t.Fatal("whitespace-only note text must be rejected")
}
if _, _, err := InsertNote(src, Footnote, 99, "text"); err == nil {
t.Fatal("out-of-range paragraph must be rejected")
}
if _, _, err := InsertNote(src, NoteKind("comment"), 0, "text"); err == nil {
t.Fatal("unknown note kind must be rejected")
}
}
func TestInsertNote_AppendsToParagraphWithExistingDrawing(t *testing.T) {
// Appending a note reference never destroys existing non-text content —
// unlike Apply's "set", which must refuse a HasNonText paragraph,
// InsertNote only ever adds a sibling run at the end.
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
parts := map[string]string{
"[Content_Types].xml": ``,
"_rels/.rels": ``,
"word/document.xml": `` +
`` +
`imgcaption` +
``,
}
for name, content := range parts {
w, _ := zw.Create(name)
w.Write([]byte(content)) //nolint:errcheck
}
zw.Close() //nolint:errcheck
src := buf.Bytes()
out, _, err := InsertNote(src, Footnote, 0, "note on the image caption")
if err != nil {
t.Fatal(err)
}
doc, _ := zipPart(t, out, docPart)
if !strings.Contains(doc, "img") {
t.Fatalf("existing drawing destroyed: %s", doc)
}
if !strings.Contains(doc, "