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
6 changes: 5 additions & 1 deletion src/core/raw/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w:ins>`/`<w:del>` Word writes INSIDE a math run (`<m:r><w:del>…`),
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
Expand Down
19 changes: 18 additions & 1 deletion src/core/raw/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

Expand Down Expand Up @@ -185,6 +187,21 @@ function mceStrip(node: XmlNode, ignorableAbove: Set<string>): void {
node.children = kept;
}

/** Word records a tracked equation edit INSIDE each math run —
* `<m:r><w:del …><m:t>x</m:t></w:del></m:r>` — 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 `<w:del><m:oMath>` 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
Expand Down
32 changes: 32 additions & 0 deletions tests/core/raw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMath>${run}</m:oMath></w:p></w:body></w:document>`,
),
"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(
`<m:r><${tag} w:id="0" w:author="Word User" w:date="2026-10-28T11:55:00Z"><m:rPr><m:sty m:val="p"/></m:rPr><w:rPr><w:rFonts w:ascii="Cambria Math" w:hAnsi="Cambria Math"/></w:rPr><m:t>=</m:t></${tag}></m:r>`,
);
expect(validateWmlXml(validationXmlFor(root))).toEqual([]);
}
});

test("what a math-run revision mark wraps is still checked", () => {
const root = mathDocument(
`<m:r><w:del w:id="0" w:author="Word User"><w:bogusElement/></w:del></m:r>`,
);
const issues = validateWmlXml(validationXmlFor(root));
expect(issues).toHaveLength(1);
expect(issues[0]?.message).toContain("bogusElement");
});
});

describe("diffValidationIssues", () => {
Expand Down
Loading