package officekit
import (
"encoding/xml"
"fmt"
"strings"
"testing"
)
// minimalWordDoc builds a tiny, realistic .docx — using Word's own
// "w:"-prefixed convention, not this package's unprefixed
// default-namespace one — containing one body paragraph and one table
// with a single cell's worth of text. This exercises the append/replace
// operations against content this package doesn't itself model (tables)
// without needing a real binary fixture file on disk.
func minimalWordDoc(t *testing.T) []byte {
t.Helper()
documentXML := xml.Header + `` +
`` +
`Intro paragraph.` +
`cell one text` +
`` +
``
data, err := buildZip([]zipPart{
{Name: "[Content_Types].xml", Body: docxContentTypes},
{Name: "_rels/.rels", Body: docxRootRels},
{Name: docxDocumentPart, Body: documentXML},
})
if err != nil {
t.Fatalf("buildZip: %v", err)
}
return data
}
func TestBuildDocxReadRoundTrip(t *testing.T) {
data, err := BuildDocx([]Paragraph{
{Text: "Title", Style: StyleHeading1},
{Text: "First item", Style: StyleBullet},
{Text: "Second item", Style: StyleBullet},
{Text: "Styled body text", Bold: true, Italic: true, Underline: true},
})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
text, err := ReadDocxText(data)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
want := []string{"Title", "- First item", "- Second item", "Styled body text"}
got := strings.Split(text, "\n")
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d: got %q, want %q", i, got[i], want[i])
}
}
}
func TestDocxRunFormattingXML(t *testing.T) {
data, err := BuildDocx([]Paragraph{{Text: "x", Bold: true, Italic: true, Underline: true}})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
for _, want := range []string{"", "", ``} {
if !strings.Contains(body, want) {
t.Errorf("expected document.xml to contain %s, got:\n%s", want, body)
}
}
}
func TestAppendDocxParagraphPreservesTable(t *testing.T) {
data := minimalWordDoc(t)
out, err := AppendDocxParagraph(data, Paragraph{Text: "Appended line."})
if err != nil {
t.Fatalf("AppendDocxParagraph: %v", err)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
for _, want := range []string{"Intro paragraph.", "cell one text", "Appended line."} {
if !strings.Contains(text, want) {
t.Errorf("expected read-back text to contain %q, got:\n%s", want, text)
}
}
// The new paragraph must land before the trailing sectPr, not after
// it — content after a body's final sectPr is invalid OOXML.
raw, ok, err := readZipPart(out, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
idxAppended := strings.Index(body, "Appended line.")
idxSectPr := strings.Index(body, " idxSectPr {
t.Errorf("expected appended paragraph before , got appended@%d sectPr@%d:\n%s", idxAppended, idxSectPr, body)
}
}
func TestReplaceDocxTextInTable(t *testing.T) {
data := minimalWordDoc(t)
out, n, err := ReplaceDocxText(data, "cell one text", "updated cell text")
if err != nil {
t.Fatalf("ReplaceDocxText: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 text run changed, got %d", n)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if strings.Contains(text, "cell one text") {
t.Errorf("old text still present after replace: %s", text)
}
if !strings.Contains(text, "updated cell text") {
t.Errorf("expected replaced text, got: %s", text)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("unrelated paragraph lost: %s", text)
}
}
func TestReplaceDocxTextNotFound(t *testing.T) {
data := minimalWordDoc(t)
if _, _, err := ReplaceDocxText(data, "nope, not in this document", "x"); err == nil {
t.Fatal("expected an error for text that isn't present")
}
}
func TestSetXlsxCellsFormulaAndOrdering(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"Label", "Value"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
// Written deliberately out of row order, including a formula, to
// exercise the sort-before-marshal fix.
out, err := SetXlsxCells(data, "", map[string]string{
"A5": "fifth",
"A2": "second",
"B2": "=A2",
"A1": "first",
})
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
// Three sparse rows exist: 1 (from BuildXlsx + overwritten A1), 2, 5.
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
}
if rows[0][0] != "first" {
t.Errorf("row 1 col A: got %q, want %q", rows[0][0], "first")
}
if rows[1][0] != "second" {
t.Errorf("row 2 col A: got %q, want %q", rows[1][0], "second")
}
if len(rows[1]) < 2 || rows[1][1] != "=A2" {
t.Errorf("row 2 col B: got %v, want formula \"=A2\"", rows[1])
}
if rows[2][0] != "fifth" {
t.Errorf("row 5 col A: got %q, want %q", rows[2][0], "fifth")
}
// A real reader (Excel included) cares about the underlying
// element order, not just what this package's own reader derives —
// confirm the raw XML itself is in ascending row/column order.
raw, ok, err := readZipPart(out, xlsxSheetPart(1))
if err != nil || !ok {
t.Fatalf("readZipPart sheet1: ok=%v err=%v", ok, err)
}
body := string(raw)
idxA1 := strings.Index(body, `r="A1"`)
idxA2 := strings.Index(body, `r="A2"`)
idxB2 := strings.Index(body, `r="B2"`)
idxA5 := strings.Index(body, `r="A5"`)
if idxA1 < 0 || idxA2 < 0 || idxB2 < 0 || idxA5 < 0 {
t.Fatalf("expected every written cell ref present in raw XML:\n%s", body)
}
if !(idxA1 < idxA2 && idxA2 < idxB2 && idxB2 < idxA5) {
t.Errorf("expected ascending order A1 < A2 < B2 < A5 in raw XML, got offsets %d,%d,%d,%d:\n%s",
idxA1, idxA2, idxB2, idxA5, body)
}
}
// TestSetXlsxCellsManyNewRowsNoDataLoss is a regression test for a bug
// where SetXlsxCells indexed newly-created rows by a *pointer* taken
// at append time. Go's append reallocates a slice's backing array
// once its capacity is exceeded, which silently strands any
// previously-taken pointer into the old array — a write applied
// through it returns no error but never reaches the file. That bug
// only showed up when (a) more than one cell landed in the same
// brand-new row and (b) a slice reallocation happened to fall between
// those two writes, so it passed or failed depending on Go's
// (deliberately randomized) map iteration order. This test forces
// many reallocations (far more new rows than any small initial slice
// capacity) and writes two cells into every one of them, so a
// reintroduction of the pointer bug fails deterministically rather
// than intermittently.
func TestSetXlsxCellsManyNewRowsNoDataLoss(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"seed"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
const numRows = 200
writes := make(map[string]string, numRows*2)
for r := 2; r <= numRows+1; r++ {
writes[fmt.Sprintf("A%d", r)] = fmt.Sprintf("label-%d", r)
writes[fmt.Sprintf("B%d", r)] = fmt.Sprintf("=A%d", r)
}
out, err := SetXlsxCells(data, "", writes)
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
if len(rows) != numRows+1 { // +1 for the seeded row 1
t.Fatalf("expected %d rows, got %d", numRows+1, len(rows))
}
for r := 2; r <= numRows+1; r++ {
row := rows[r-1]
if len(row) != 2 {
t.Fatalf("row %d: expected 2 cells, got %d: %v", r, len(row), row)
}
wantA := fmt.Sprintf("label-%d", r)
wantB := fmt.Sprintf("=A%d", r)
if row[0] != wantA {
t.Errorf("row %d col A: got %q, want %q", r, row[0], wantA)
}
if row[1] != wantB {
t.Errorf("row %d col B: got %q, want %q", r, row[1], wantB)
}
}
}
func TestBuildPptxReadRoundTrip(t *testing.T) {
data, err := BuildPptx([]Slide{
{Title: "Welcome"},
{Title: "Agenda", Bullets: []string{"Point one", "Point two & three"}},
})
if err != nil {
t.Fatalf("BuildPptx: %v", err)
}
slides, err := ReadPptxSlides(data)
if err != nil {
t.Fatalf("ReadPptxSlides: %v", err)
}
if len(slides) != 2 {
t.Fatalf("expected 2 slides, got %d", len(slides))
}
if slides[0].Title != "Welcome" {
t.Errorf("slide 1 title: got %q", slides[0].Title)
}
if slides[1].Title != "Agenda" || len(slides[1].Bullets) != 2 || slides[1].Bullets[1] != "Point two & three" {
t.Errorf("slide 2: got %+v", slides[1])
}
}