From 30d47ddff741c4fc23c096f3f2aa2eb082d980e0 Mon Sep 17 00:00:00 2001
From: xsvm <2479667226@qq.com>
Date: Thu, 29 Oct 2026 19:35:07 +0800
Subject: [PATCH] fix(validate): accept Word's revision marks inside a math run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Word records a tracked equation edit inside each math run,
x, and documents it as supported
(MS-OI29500, Part 1 §22.1.2.87 (e)), but ECMA-376's CT_R has no slot for it.
So `docx validate` and the raw gate's baseline reported "del: This element
is not expected" for every file Word saved with such an edit, including a
docx-cli `` edit once Word re-saved it (Word rewrites it to
its own shape).
validationXmlFor now unwraps those marks in its clone, next to the MCE
pass; what they wrap is still checked against CT_R. The schemas stay as
published.
---
src/core/raw/CLAUDE.md | 6 +++++-
src/core/raw/validate.ts | 19 ++++++++++++++++++-
tests/core/raw.test.ts | 32 ++++++++++++++++++++++++++++++++
3 files changed, 55 insertions(+), 2 deletions(-)
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">=${tag}>`,
+ );
+ 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", () => {