From d11d7c7d519374caf8d53c6103e492675f8be957 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 5 Jul 2026 11:11:29 +0900 Subject: [PATCH] fix: persist node-level edits on reopened docx in toUint8Array Node-level mutators such as setTableCellText / setParagraphText operate on a WmlTable / WmlParagraph handed out by tables()/paragraphs(), not on the Docx, so they cannot flip doc.dirty. toUint8Array()/validate() gated the document-part flush on doc.dirty, so saving a reopened document silently dropped those edits (createDocx masked it because add*/append* had already set dirty). The WML AST is the source of truth after openDocx and is round-trip stable (unknown elements preserved as pass-through), so re-serialize the document part unconditionally via a shared flushPendingParts() helper. Side parts keep their dirty fast-path. Adds a regression test: openDocx -> setTableCellText/setParagraphText -> toUint8Array -> openDocx now reflects the edits. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/api/docx.ts | 56 ++++++++++++++----------------- src/api/edit-after-reopen.test.ts | 32 ++++++++++++++++++ 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 3e8f2fb..2949466 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/docx.ts b/src/api/docx.ts index ac6061c..ce7bbe8 100644 --- a/src/api/docx.ts +++ b/src/api/docx.ts @@ -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); } @@ -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; @@ -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); } diff --git a/src/api/edit-after-reopen.test.ts b/src/api/edit-after-reopen.test.ts index 5c9469e..a920516 100644 --- a/src/api/edit-after-reopen.test.ts +++ b/src/api/edit-after-reopen.test.ts @@ -41,13 +41,17 @@ import { externalHyperlinks, footers, footnotesPart, + getTableCellText, headers, images, listStyles, openDocx, paragraphs, + paragraphText, removeStyle, setHyperlinkUrl, + setParagraphText, + setTableCellText, tables, toUint8Array, validate, @@ -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"); + }); });