Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@office-kit/docx",
"version": "0.1.0",
"version": "0.1.1",
"description": "OOXML-compliant .docx generation for browser and Node.js.",
"keywords": [
"docx",
Expand Down
56 changes: 26 additions & 30 deletions src/api/docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2641,30 +2641,7 @@ export function toBlob(doc: Docx): Blob {
* missing media parts). Returns the issues; never throws.
*/
export function validate(doc: Docx): ValidationIssue[] {
if (doc.dirty) {
flushDocument(doc);
doc.dirty = false;
}
if (doc.stylesDirty && doc.stylesCache) {
flushStyles(doc, doc.stylesCache);
doc.stylesDirty = false;
}
if (doc.numberingDirty && doc.numberingCache) {
flushNumbering(doc, doc.numberingCache);
doc.numberingDirty = false;
}
if (doc.commentsDirty && doc.commentsCache) {
flushComments(doc, doc.commentsCache);
doc.commentsDirty = false;
}
if (doc.footnotesDirty && doc.footnotesCache) {
flushNotes(doc, doc.footnotesCache, FOOTNOTES_PART_NAME, "footnotes");
doc.footnotesDirty = false;
}
if (doc.endnotesDirty && doc.endnotesCache) {
flushNotes(doc, doc.endnotesCache, ENDNOTES_PART_NAME, "endnotes");
doc.endnotesDirty = false;
}
flushPendingParts(doc);
return validatePackage(doc.opc);
}

Expand Down Expand Up @@ -2720,12 +2697,26 @@ export function clone(doc: Docx): Docx {
return openDocx(toUint8Array(doc));
}

/** Serialize the package back to `.docx` bytes. */
export function toUint8Array(doc: Docx): Uint8Array {
if (doc.dirty) {
flushDocument(doc);
doc.dirty = false;
}
/**
* Flush every in-memory model back to its package part.
*
* The main document part is re-serialized from the WML AST unconditionally.
* After {@link openDocx} the AST — not the raw part bytes — is the source of
* truth, and node-level mutators such as `setTableCellText` /
* `setParagraphText` operate on a `WmlTable` / `WmlParagraph` handed out by
* {@link tables} / {@link paragraphs}. Those helpers receive only the tree
* node, so they cannot mark the owning {@link Docx} dirty; gating the flush on
* `doc.dirty` would silently drop their edits on save. The AST is round-trip
* stable (unrecognized elements are preserved verbatim as pass-through nodes),
* so re-serializing an untouched document reproduces equivalent bytes.
*
* Side parts (styles, numbering, comments, notes) are only reachable through
* their `*Part` accessors, which set the matching `*Dirty` flag, so those keep
* their dirty fast-path.
*/
function flushPendingParts(doc: Docx): void {
flushDocument(doc);
doc.dirty = false;
if (doc.stylesDirty && doc.stylesCache) {
flushStyles(doc, doc.stylesCache);
doc.stylesDirty = false;
Expand All @@ -2746,6 +2737,11 @@ export function toUint8Array(doc: Docx): Uint8Array {
flushNotes(doc, doc.endnotesCache, ENDNOTES_PART_NAME, "endnotes");
doc.endnotesDirty = false;
}
}

/** Serialize the package back to `.docx` bytes. */
export function toUint8Array(doc: Docx): Uint8Array {
flushPendingParts(doc);
return writeOpcPackage(doc.opc);
}

Expand Down
32 changes: 32 additions & 0 deletions src/api/edit-after-reopen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,17 @@ import {
externalHyperlinks,
footers,
footnotesPart,
getTableCellText,
headers,
images,
listStyles,
openDocx,
paragraphs,
paragraphText,
removeStyle,
setHyperlinkUrl,
setParagraphText,
setTableCellText,
tables,
toUint8Array,
validate,
Expand Down Expand Up @@ -231,4 +235,32 @@ describe("editing a reopened document", () => {
const final = expectClean(toUint8Array(reopened));
expect(externalHyperlinks(final)[0]?.target).toBe("https://example.com/x");
});

// Regression: node-level setters (setTableCellText / setParagraphText)
// operate on a WmlTable / WmlParagraph handed out by tables()/paragraphs(),
// not on the Docx, so they cannot flip doc.dirty. Before the fix, saving a
// *reopened* doc gated the document flush on doc.dirty and silently dropped
// these edits (createDocx masked the bug because add*/append* had already
// set dirty). toUint8Array must now re-serialize the AST unconditionally.
it("setTableCellText / setParagraphText after reopen persist through toUint8Array", () => {
const seed = createDocx();
addTable(seed, [
["Q", "A"],
["Q1", ""],
["Q2", ""],
]);
appendParagraph(seed, "para1");

// Round-trip once so the doc starts clean (dirty === false), mirroring the
// real "open a template → edit → save" workflow.
const reopened = openDocx(toUint8Array(seed));
setTableCellText(tables(reopened)[0]!, 1, 1, "ANS");
setParagraphText(paragraphs(reopened).at(-1)!, "para-edited");

const final = expectClean(toUint8Array(reopened));
expect(getTableCellText(tables(final)[0]!, 1, 1)).toBe("ANS");
expect(paragraphText(paragraphs(final).at(-1)!)).toBe("para-edited");
// The untouched non-empty cell must survive too.
expect(getTableCellText(tables(final)[0]!, 1, 0)).toBe("Q1");
});
});
Loading