What happened?
Any touch of a header or footer re-serializes that part, and the re-serialized part does not round-trip. Rejecting the edit does not undo it: the part still differs from what was imported, permanently, for the life of the document.
Two concrete losses, both from a tracked edit that was rejected, so the intended net change is zero.
1. The PAGE field's cached result run is deleted.
Before (as imported, and as exported when nothing is touched):
<w:r><w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
<w:r><w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:rPr><w:noProof/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:t>1</w:t></w:r>
<w:r><w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:fldChar w:fldCharType="end"/></w:r>
After edit-then-reject — separate is immediately followed by end, and the result run is gone:
<w:r><w:rPr><w:sz w:val="18"/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:rPr><w:sz w:val="18"/></w:rPr><w:instrText xml:space="preserve"> PAGE</w:instrText></w:r>
<w:r><w:rPr><w:sz w:val="18"/></w:rPr><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:rPr><w:sz w:val="18"/></w:rPr><w:fldChar w:fldCharType="end"/></w:r>
The cached result is the only part of a field that a consumer which does not compute fields can render — plenty of DOCX→PDF and DOCX→text pipelines fall in that bucket, as does any viewer showing the document before a field update. The field is also not marked w:dirty, so nothing signals that it needs recomputing.
What makes this look like a bug rather than a policy is that the adjacent NUMPAGES field in the same paragraph is handled differently: it keeps its cached result run, but loses <w:noProof/> from that run and gains w:dirty="true" on its begin. Two structurally identical constructs, three inconsistent outcomes.
Smaller losses in the same paragraph: the field instruction " PAGE " loses its trailing space (" PAGE"), and every field-code run loses <w:szCs w:val="18"/> from its rPr.
Expected: a field the edit never touched keeps its structure and its cached result — begin / instrText / separate / result / end — across a save, and if a result is invalidated, w:dirty="true" says so. Not byte-for-byte identity; just the field and its cached result preserved.
2. w:rFonts is stamped onto a run that had none.
The edited header run had no <w:rFonts> at all — it inherited its font from the document defaults. After edit-then-reject it carries an explicit <w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>, so the paragraph is now pinned to Calibri and no longer follows the theme or the docDefaults. #2767 fixed exactly this on the body side (zero-edit open→save injecting w:rFonts into heading runs); the header path still does it.
Body control arm: the same tracked edit + reject performed in the body exports word/document.xml byte-identical to the untouched export, and stamps no w:rFonts. This is header/footer-specific, not a general export defect.
The trigger is "the header/footer part got re-serialized at all", not "the edit was kept". Rejecting is just the cleanest way to show it, because it makes the intended net change zero while the exported bytes still differ.
Steps to reproduce
Test fixture: test_contract.docx — synthetic, no third-party content. It does not contain a Word-realistic PAGE field, so the script splices one into word/footer1.xml first — each fldChar/instrText in its own run, with a cached result run between separate and end, the way Word writes it. That splice is the first 12 lines of the script, so the fixture is regenerable. The w:rFonts claim and the body control arm need no splice.
npm i @harbour-enterprises/superdoc@1.46.1 jsdom fflate
node repro.mjs test_contract.docx
// repro.mjs
import fs from "node:fs";
import { JSDOM } from "jsdom";
import { unzipSync, zipSync, strFromU8, strToU8 } from "fflate";
// 1. Splice a Word-realistic PAGE/NUMPAGES footer (each fldChar in its own run,
// with a CACHED RESULT run between `separate` and `end`, as Word writes it).
const files = unzipSync(new Uint8Array(fs.readFileSync(process.argv[2])));
const P = `<w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>`;
const N = `<w:rPr><w:noProof/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>`;
const r = (inner, rpr = P) => `<w:r>${rpr}${inner}</w:r>`;
const fld = (instr, cached) => r(`<w:fldChar w:fldCharType="begin"/>`) +
r(`<w:instrText xml:space="preserve"> ${instr} </w:instrText>`) + r(`<w:fldChar w:fldCharType="separate"/>`) +
r(`<w:t>${cached}</w:t>`, N) + r(`<w:fldChar w:fldCharType="end"/>`);
files["word/footer1.xml"] = strToU8(strFromU8(files["word/footer1.xml"]).replace(/<w:p>[\s\S]*<\/w:p>/,
`<w:p>${r(`<w:t xml:space="preserve">Page </w:t>`)}${fld("PAGE", "1")}${r(`<w:t xml:space="preserve"> of </w:t>`)}${fld("NUMPAGES", "12")}</w:p>`));
const docx = Buffer.from(zipSync(Object.fromEntries(Object.entries(files).filter(([n, d]) => d.length || !n.endsWith("/")))));
const m = new Map();
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: k => m.get(k) ?? null, setItem: (k, v) => void m.set(k, String(v)),
removeItem: k => void m.delete(k), clear: () => m.clear(), key: i => [...m.keys()][i] ?? null, get length() { return m.size; } } });
const { Editor } = await import("@harbour-enterprises/superdoc/super-editor");
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const USER = { name: "Repro", email: "r@example.com" };
const open = () => Editor.open(docx, { document: dom.window.document, documentMode: "suggesting", user: USER, telemetry: { enabled: false } });
const exportParts = async (ed) => { const x = await ed.exportDocx();
const f = unzipSync(x instanceof Uint8Array ? x : new Uint8Array(await x.arrayBuffer()));
return Object.fromEntries(Object.entries(f).map(([k, v]) => [k, strFromU8(v)])); };
/** Tracked-replace the first >=4-letter word in `story`, then reject it. */
async function editAndReject(doc, story) {
const scope = story ? { in: story } : {};
const needle = doc.getText(scope).match(/[A-Za-z]{4,}/)[0];
const target = doc.query.match({ select: { type: "text", pattern: needle }, ...scope }).items[0].target;
doc.replace({ target, text: `${needle}X` }, { changeMode: "tracked" });
for (const c of ((await doc.trackChanges.list(scope)).items ?? []).filter(c => c.authorEmail === USER.email))
doc.trackChanges.decide({ decision: "reject", target: { id: c.id, ...(story ? { story } : {}) } });
}
const base = await exportParts(await open()); // no edit at all
const bodyEd = await open(); // CONTROL: body edit + reject
await editAndReject(bodyEd.doc, null);
console.log(`BODY CONTROL word/document.xml: ${(await exportParts(bodyEd))["word/document.xml"] === base["word/document.xml"] ? "IDENTICAL to no-edit export" : "DIFFERENT"}`);
const ed = await open(); // header + footer edit + reject
for (const p of (await ed.doc.headerFooters.parts.list({})).items ?? [])
await editAndReject(ed.doc, { kind: "story", storyType: "headerFooterPart", refId: p.refId });
const after = await exportParts(ed);
for (const part of ["word/header1.xml", "word/footer1.xml"])
console.log(`HEADER/FOOTER ${part}: ${after[part] === base[part] ? "IDENTICAL" : "DIFFERENT"}`);
const n = (x) => (x.match(/<w:rFonts\b/g) ?? []).length;
for (const part of ["word/document.xml", "word/header1.xml"])
console.log(`rFonts count ${part}: no-edit export ${n(base[part])} -> after edit+reject ${n(after[part])}`);
console.log(`\nfooter1.xml BEFORE (no edit):\n${base["word/footer1.xml"].match(/<w:p\b[\s\S]*?<\/w:p>/)[0]}`);
console.log(`\nfooter1.xml AFTER edit+reject:\n${after["word/footer1.xml"].match(/<w:p\b[\s\S]*?<\/w:p>/)[0]}`);
console.log(`\nheader1.xml BEFORE (no edit):\n${base["word/header1.xml"].match(/<w:p\b[\s\S]*?<\/w:p>/)[0]}`);
console.log(`\nheader1.xml AFTER edit+reject:\n${after["word/header1.xml"].match(/<w:p\b[\s\S]*?<\/w:p>/)[0]}`);
Output (console summary, verbatim)
BODY CONTROL word/document.xml: IDENTICAL to no-edit export
HEADER/FOOTER word/header1.xml: DIFFERENT
HEADER/FOOTER word/footer1.xml: DIFFERENT
rFonts count word/document.xml: no-edit export 0 -> after edit+reject 0
rFonts count word/header1.xml: no-edit export 0 -> after edit+reject 1
The script then prints the full footer1.xml and header1.xml before/after pairs; the field diff quoted above is that output, reflowed for reading.
SuperDoc version
1.46.1 — V1. (Filing against V1 explicitly since main is now V2 and V1 lives on the v1 branch. We have not checked whether V2 behaves the same way.)
Browser
None — headless Node 24 + jsdom. Nothing here depends on a browser.
Related
What happened?
Any touch of a header or footer re-serializes that part, and the re-serialized part does not round-trip. Rejecting the edit does not undo it: the part still differs from what was imported, permanently, for the life of the document.
Two concrete losses, both from a tracked edit that was rejected, so the intended net change is zero.
1. The PAGE field's cached result run is deleted.
Before (as imported, and as exported when nothing is touched):
After edit-then-reject —
separateis immediately followed byend, and the result run is gone:The cached result is the only part of a field that a consumer which does not compute fields can render — plenty of DOCX→PDF and DOCX→text pipelines fall in that bucket, as does any viewer showing the document before a field update. The field is also not marked
w:dirty, so nothing signals that it needs recomputing.What makes this look like a bug rather than a policy is that the adjacent NUMPAGES field in the same paragraph is handled differently: it keeps its cached result run, but loses
<w:noProof/>from that run and gainsw:dirty="true"on itsbegin. Two structurally identical constructs, three inconsistent outcomes.Smaller losses in the same paragraph: the field instruction
" PAGE "loses its trailing space (" PAGE"), and every field-code run loses<w:szCs w:val="18"/>from itsrPr.Expected: a field the edit never touched keeps its structure and its cached result —
begin/instrText/separate/ result /end— across a save, and if a result is invalidated,w:dirty="true"says so. Not byte-for-byte identity; just the field and its cached result preserved.2.
w:rFontsis stamped onto a run that had none.The edited header run had no
<w:rFonts>at all — it inherited its font from the document defaults. After edit-then-reject it carries an explicit<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>, so the paragraph is now pinned to Calibri and no longer follows the theme or thedocDefaults. #2767 fixed exactly this on the body side (zero-edit open→save injectingw:rFontsinto heading runs); the header path still does it.Body control arm: the same tracked edit + reject performed in the body exports
word/document.xmlbyte-identical to the untouched export, and stamps now:rFonts. This is header/footer-specific, not a general export defect.The trigger is "the header/footer part got re-serialized at all", not "the edit was kept". Rejecting is just the cleanest way to show it, because it makes the intended net change zero while the exported bytes still differ.
Steps to reproduce
Test fixture:
test_contract.docx— synthetic, no third-party content. It does not contain a Word-realistic PAGE field, so the script splices one intoword/footer1.xmlfirst — eachfldChar/instrTextin its own run, with a cached result run betweenseparateandend, the way Word writes it. That splice is the first 12 lines of the script, so the fixture is regenerable. Thew:rFontsclaim and the body control arm need no splice.Output (console summary, verbatim)
The script then prints the full
footer1.xmlandheader1.xmlbefore/after pairs; the field diff quoted above is that output, reflowed for reading.SuperDoc version
1.46.1— V1. (Filing against V1 explicitly sincemainis now V2 and V1 lives on thev1branch. We have not checked whether V2 behaves the same way.)Browser
None — headless Node 24 + jsdom. Nothing here depends on a browser.
Related
w:rFontsinjection. The fix does not cover the header/footer export path.exportDocx()drops imported tracked changes from a header/footer (V1) —exportToXmlJsonstrips marks from the caller's tree in place #3893 — the secondexportDocx()dropping imported tracked changes from the same parts, caused by in-place mutation of the import-time tree.