Skip to content

Editing a header/footer and then rejecting the edit permanently diverges the part from the imported bytes — PAGE field cached result is deleted (V1) #3894

Description

@ohcedar

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.1V1. (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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

status: investigatingThe report is being investigated or reproduced.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions