diff --git a/src/core/raw/CLAUDE.md b/src/core/raw/CLAUDE.md index cc37d06..3be4f2e 100644 --- a/src/core/raw/CLAUDE.md +++ b/src/core/raw/CLAUDE.md @@ -88,7 +88,11 @@ mutation is in-memory until `cli/raw/commit.ts` saves after the last gate. `validationXmlFor` applies **MCE preprocessing to a CLONE** first — strip `mc:Ignorable`-declared attrs/elements, resolve `mc:AlternateContent` → `mc:Fallback` — because real Word markup (`w14:paraId` on every `w:p`) is - valid OOXML that the base schema doesn't know. Gate 6 is a **baseline diff** + valid OOXML that the base schema doesn't know. The same clone also unwraps + the ``/`` Word writes INSIDE a math run (`…`), + its documented extension for a tracked equation edit ([MS-OI29500] Part 1 + §22.1.2.87 (e)) that `CT_R` has no slot for; what the mark wraps is still + checked. Gate 6 is a **baseline diff** (`diffValidationIssues`, multiset on message text): a third of real documents carry pre-existing violations, so a raw edit is judged only on errors it ADDS. `docx validate` reuses the engine standalone, per WML part (parts are diff --git a/src/core/raw/validate.ts b/src/core/raw/validate.ts index 7323869..8251c8a 100644 --- a/src/core/raw/validate.ts +++ b/src/core/raw/validate.ts @@ -91,10 +91,12 @@ export const WML_ROOT_TAGS = new Set([ "w:glossaryDocument", ]); -/** Serialize a part root for validation: clone, apply MCE preprocessing, emit. */ +/** Serialize a part root for validation: clone, apply MCE preprocessing and + * Word's math-run revision unwrap, emit. */ export function validationXmlFor(root: XmlNode): string { const cleaned = root.clone(); mceStrip(cleaned, new Set()); + unwrapMathRunRevisions(cleaned); return XmlNode.serialize([cleaned]); } @@ -185,6 +187,21 @@ function mceStrip(node: XmlNode, ignorableAbove: Set): void { node.children = kept; } +/** Word records a tracked equation edit INSIDE each math run — + * `x` — and documents that it does, + * though ECMA-376's `CT_R` has no slot for it ([MS-OI29500] Part 1 + * §22.1.2.87 (e)). Every file Word saved with such an edit failed validation, + * including docx-cli's own `` edits once Word re-saved them. + * Unwrapping the mark in the clone keeps what it wraps checked against + * `CT_R`. */ +function unwrapMathRunRevisions(node: XmlNode): void { + for (const child of node.children) unwrapMathRunRevisions(child); + if (node.tag !== "m:r") return; + node.children = node.children.flatMap((child) => + child.tag === "w:ins" || child.tag === "w:del" ? child.children : [child], + ); +} + /** Compile the schema once per process and keep it (libxml2 objects need * explicit disposal, but a process-lifetime validator is the point here). * Cross-file `xsd:import`s resolve through a virtual input provider over the diff --git a/tests/core/raw.test.ts b/tests/core/raw.test.ts index 7624a33..46c69ad 100644 --- a/tests/core/raw.test.ts +++ b/tests/core/raw.test.ts @@ -346,6 +346,38 @@ describe("gate 6 — XSD engine", () => { ); expect(fresh).toEqual([]); }); + + // Word marks a tracked equation edit inside each math run (MS-OI29500, + // Part 1 §22.1.2.87 (e)); the transitional XSD has no slot for it, so every + // such file Word saved used to fail `validate`. Shape copied from Word 365. + function mathDocument(run: string): XmlNode { + const root = XmlNode.findRoot( + XmlNode.parse( + `${run}`, + ), + "w:document", + ); + if (!root) throw new Error("no document root"); + return root; + } + + test("Word's revision marks inside a math run validate clean", () => { + for (const tag of ["w:del", "w:ins"]) { + const root = mathDocument( + `<${tag} w:id="0" w:author="Word User" w:date="2026-10-28T11:55:00Z">=`, + ); + expect(validateWmlXml(validationXmlFor(root))).toEqual([]); + } + }); + + test("what a math-run revision mark wraps is still checked", () => { + const root = mathDocument( + ``, + ); + const issues = validateWmlXml(validationXmlFor(root)); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toContain("bogusElement"); + }); }); describe("diffValidationIssues", () => {