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]) } }