Skip to content

About

OOXML validators and redline/comment helpers for .docx and .pptx files. XSD-backed, TypeScript, and ESM, for the neurotic developer.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1,301 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

docx-validate

CI CodeQL codecov Known Vulnerabilities npm downloads types bundle min bundle gzip tree-shakeable dependencies license node github

OOXML validators and redline/comment helpers for .docx and .pptx files. Portable Word checks by default for DOCX, with optional XSD validation and real Microsoft Word verification. TypeScript, ESM, runs under Node (managed with pnpm) for the neurotic developer.

  • Repo: jandira-tech/docx-validate
  • npm: docx-validate
  • Maintainer: jandira.tech — We are building legal tech. Jandira Technologies is the studio behind tools like Cicero (a legal workbench that turns messy inputs into redlines, issue lists, and memos), PII redaction models for Brazilian Portuguese, and AI/contract-drafting benchmarks. docx-validate falls out of that work — when redlines have to round-trip through Word, validating the OOXML directly beats trusting the renderer.
  • docs: https://jandira-tech.github.io/docx-validate/

Install

bun add docx-validate
# or
npm install docx-validate

Layout

src/
  index.ts                       — barrel re-exports of the public surface
  lib/
    xml-helpers.ts               — DOM parse/serialize/pretty-print, NS helpers
    types.ts                     — shared types (validation results, OOXML namespaces)
    run-cli.ts                   — one-liner replacement for `if __name__ == "__main__"`
  scripts/
    accept-changes.ts            — accept tracked changes
    comment.ts                   — comment helpers
    office/
      pack.ts                    — repack an unpacked OOXML directory
      unpack.ts                  — unpack an OOXML file
      validate.ts                — top-level validate CLI
      soffice.ts                 — LibreOffice (`soffice`) wrapper
      schemas/                   — bundled OOXML XSD schemas (ISO/IEC 29500-4, ECMA OPC, MCE, Microsoft)
      helpers/
        merge-runs.ts            — merge adjacent w:r runs
        simplify-redlines.ts     — collapse/clean tracked changes
      validators/
        base.ts                  — XSD schema dispatch (libxml2-wasm via lib/xsd-validator.ts)
        docx.ts                  — DOCX validator
        pptx.ts                  — PPTX validator
        redlining.ts             — w:ins / w:del / w:moveTo etc. validator
scripts/                         — repo tooling (not shipped in the package)
  fixture-fingerprint.ts         — docx content fingerprint (JSZip + validate())
  derive-fixture-name.ts         — deterministic <subject>.<comment-or-error> naming
  apply-fixture-names.ts         — rename/sort fixtures by content (dedup, categories)
  update-manifest.ts             — regenerate tests/fixtures-all.manifest.json
tests/                           — vitest specs
  fixtures/
    broken/                      — synthetic single-defect specimens
    working/                     — valid / repaired specimens
    eigen/                       — real-world Plate/SuperDoc specimens (content-named)
    external/                    — vendored third-party specimens (see NOTICE/LICENSE)

Conventions

  • File names: kebab-case (merge-runs.ts, not merge_runs.ts or mergeRuns.ts).
  • Modules: ES modules (import/export), no CommonJS.
  • Imports: relative paths between sibling modules; no path aliases.
  • XSD validation goes through libxml2-wasm behind the XsdValidator interface (src/lib/xsd-validator.ts).
  • DOM work goes through @xmldom/xmldom + xpath via lib/xml-helpers.ts (parseXml, serializeXml, prettyXml, getElementsByTagNameNSAll) — do NOT call DOMParser directly so the implementation stays swappable.
  • Temp dirs use the tmp package via withTempDir(async (dir) => { ... }) (see lib/run-cli.ts).
  • CLIs use commander. Wire each script with runCli(...) from lib/run-cli.ts.
  • Validation results: return the ValidationResult shape from lib/types.ts; do not invent per-validator shapes.
  • Tests: vitest, one test file per module, fixtures under tests/fixtures/.

Dependencies

Runtime:

  • jszip — Zip read/write
  • @xmldom/xmldom — DOM API
  • xpath — XPath queries against @xmldom
  • libxml2-wasm — XSD validation (WASM libxml2; replaced the native libxmljs2 addon, which cannot build on Node 26)
  • commander — CLI argument parsing
  • tmp — temp directory lifecycle

Dev:

  • @types/tmp, @types/node, typescript, vite-plus, bumpp

Running CLIs

The installed package ships a docx-validate bin that runs the validator CLI (dist/cli.mjs):

npx docx-validate <path>          # or: node node_modules/docx-validate/dist/cli.mjs <path>
docx-validate --help              # prints only the validator's usage

dist/index.mjs is the library entry only — running or importing it never starts a CLI.

From source, every script is still its own tsx entry point:

pnpm exec tsx src/scripts/office/validate.ts <path>
pnpm exec tsx src/scripts/office/unpack.ts <path>
pnpm exec tsx src/scripts/office/pack.ts <dir>
pnpm exec tsx src/scripts/accept-changes.ts <path>
pnpm exec tsx src/scripts/comment.ts <path>

The comment CLI's commentId and --parent values must be canonical decimal integers from 0 to 9007199254740991 (Number.MAX_SAFE_INTEGER) with no leading zeros. 0 is accepted; 01, a sign, whitespace, and any larger integer are refused.

Programmatic use

The validator and packaged document helpers are available as plain function/class imports. The package's barrel (src/index.ts) is hand-maintained (the export * section and the four-class surface/jubarte helpers are curated). Only the exports listed there are reachable from the package root; the registry, scheduler, provenance, and verifier implementation modules remain source internals.

Validate a .docx / .pptx

import { validate } from "docx-validate";

const result = await validate("./contract.docx");
//                ^? Promise<ValidateRunResult>
//                   { valid: boolean; issues: ValidationIssue[]; suffix: ".docx"; repairs: number }

if (!result.valid) {
    for (const issue of result.issues) {
        console.error(`${issue.severity}${issue.path ? ` [${issue.path}]` : ""}: ${issue.message}`);
    }
}

DOCX and DOCM default to word-valid: no known Word-opening defect detected by the complete portable check set. This is a prediction, not an observed Word open. PPTX keeps the lenient default and its existing XSD validator. The Word-valid behavior below applies to DOCX/DOCM. Pass { profile: "lenient" } (CLI: --profile lenient) to retain the earlier DOCX policy.

The PPTX slide and master checks judge every slide and slide master the presentation relates, wherever it is stored, beside ppt/slides/ and ppt/slideMasters/. These are notes-shared (a notes slide two slides relate, one slide relates twice (#1045), or the two stored spellings of one slide's relationships part each relate, as every stored spelling is judged (#1017, #1052)), slide-layout-duplicate (a slide with more than one slideLayout relationship), sldlayout-missing-rid (a master's sldLayoutId naming no slideLayout relationship) and sldlayout-missing-rels (a master with no relationships part). The presentation is the part the package's officeDocument relationship names (#1013). The two historical folders match as OPC compares part names, ASCII case-insensitively and with escaped unreserved characters decoded, so ppt/slides/%5Frels/ is one of them (#1037), and every stored spelling of two equivalent part names in them is judged (#1017). A master's relationships part is found under any equivalent spelling (#1018). Outside ppt/slides/, a slide's relationships part is judged only when the slide is stored. A related slide that is not stored has its own finding, the presentation's rels-broken (#1019). Under word-valid and strict, the three checks read a relationship as the slide walk does: exactly the Office notesSlide or slideLayout type, Transitional or Strict, and internal. So an External relationship, or a foreign type that merely contains the name, neither shares a notes slide, nor adds a layout, nor satisfies a sldLayoutId. Lenient keeps the historical reading, as it keeps nested relationships (#1051). Under word-valid and strict, rels-id-mismatch reads a PresentationML element's relationship type exactly too: the type, spelled Transitional or Strict, must be the Office segment the element expects in its real casing (slide, slideMaster, notesMaster, handoutMaster, slideLayout). Unlike the slide checks it does not read TargetMode. Before, it passed when the lower-cased last segment of the type merely contained the expected name, so a p:sldId naming a slideLayout relationship passed as slide. An element in another namespace, such as a foreign element in a p:ext sharing a PresentationML local name, keeps that reading, as lenient and DOCX do (#1058, #1061). Outside lenient the id walk reads the Strict relationships namespace too, so a Strict package's ids are judged: r:id for its type here, and r:id, r:embed and r:link for rels-id-missing. Lenient reads only the Transitional namespace (#1065, #1073). The two codes' severity differs by format under word-valid. For DOCX/DOCM they are warnings, because isWordBlockingIssue does not list them, so a dangling or mistyped id that no Word rule judges leaves the package valid; strict reports them as errors. PPTX has no word-valid severity policy, so under word-valid and strict they stay errors and the package is invalid (#1091, #1092). A master's sldLayoutId that sldlayout-missing-rid reports is one finding under word-valid and strict: the run drops the id walk's rels-id-mismatch or rels-id-missing copy of the same element and id, as DOCX hands a story reference's copy to its Word rule. That check reads only the Transitional r:id, so a Strict rs:id beside it keeps its own rels-id-missing. Lenient keeps both findings (#1093).

The strict profile runs the Word rules and applicable XSD schemas. Schema conformance is stricter than Word openability. A leading BOM is at most a warning under every profile and never fails a document on its own:

const result = await validate("./contract.docx", { profile: "strict" });

Cross-check tracked changes against an original (the --original CLI flag):

const result = await validate("./redlined.docx", {
    original: "./baseline.docx",
    author: "Alice",
});

Word-openability checks run without XSD. The path API retains its XSD walk trace:

const result = await validate("./contract.docx", { profile: "word-valid" });
console.log(result.xsdWalk); // { started: false, parts: [], eligible: 0 }
console.log(result.metadata?.schemaCompleted); // false

For DOCX/DOCM, the path API and document validator do not compile or walk XSD schemas under word-valid, so schemaCompleted is always false. Full lenient and strict runs walk eligible parts; a malformed part can stop a lenient run before XSD. The XML part list uses OPC-equivalent extensions: ASCII case is ignored and unreserved escapes are decoded, so .XML, .RELS and .%72els parts are seen by the passes that iterate that list and by the XSD walk. It also holds every other part [Content_Types].xml declares with an XML type (application/xml, text/xml, or an application/…+xml type), whatever its name: Word reads a part by its relationship and content type, so a main document stored as word/document.bin under the main document's Override is checked as word/document.xml would be, and Word 16.115 refuses it truncated (#620, session 32). Image types such as image/svg+xml are not XML parts here. DOCX relationships validation passes use the same OPC-equivalent .rels predicate. Relationship-id and hyperlink checks also resolve sidecars by OPC part-name equivalence in packed and directory inputs, including full, named and quick runs: a .RELS sidecar resolves like .rels, matching Word 16.115's clean open. An escaped stored name such as .%72els still fails full Word-valid and strict validation, and quick Word-valid runs, through opc-part-name-escaped-unreserved (OPC [M1.8]), even though sidecar lookup resolves it (#402). Named selections diagnose the escaped name when they include that check. Empty-part auto-repair retains its exact lowercase .rels scope. The trace describes the schema walk, not a Microsoft Word invocation. Under strict, ISO Strict OOXML parts use the bundled schemas/iso-strict/ schemas. Lenient skips those parts with the info-level xsd-strict-skipped; Word-valid emits no XSD skip issue because it runs no XSD. The walk sends a part to the schema its identity maps to. The glossary document's parts (word/glossary/*.xml) use wml.xsd, and the DrawingML diagram parts (diagrams/dataN.xml, layoutN.xml, quickStyleN.xml, colorsN.xml under word/, ppt/ or xl/) use dml-diagram.xsd (#628). A part with no mapped schema, such as customXml/item1.xml or a diagram's drawingN.xml cache, is not sent: xsdWalk.parts lists exactly the parts that were. Each such well-formed part draws one info-level xsd-no-schema issue naming it, so the parts the walk skipped can be audited; validity, xsdWalk and metadata.schemaCompleted do not change, lenient's --original comparison never removes it, and a malformed part, a Strict part lenient skips and a part the run's deadline never reached draw none (#628). A direct validateAgainstXsd() call on a validator parses each unmapped part before auditing it, so a malformed one draws none there either (#867). In a PPTX, the slides, slide layouts and masters, notes slides and masters, handout masters, legacy comments and tags one folder below ppt/ use pml.xsd, matched by folder and file name (#864). PowerPoint's modern comments (ppt/comments/modernComment_*.xml), whose root pml.xsd does not declare, are among the unmapped parts. Directly in ppt/, the five conventional names (presentation.xml, presProps.xml, viewProps.xml, commentAuthors.xml and tableStyles.xml) go to pml.xsd by name (#872). Any other part there is routed by its root element (#887): it goes to pml.xsd when that root is a global element declared by pml.xsd or a schema it imports or includes, for the part's conformance class, and draws the audit otherwise. A renamed main presentation or a relocated slide therefore keeps its check, while ppt/authors.xml and ppt/revisionInfo.xml, whose Microsoft roots no bundled schema declares, stay unmapped. Each schema walk reads the roots afresh (#902). Only the first base validateFileAgainstXsd call the walk's own active call makes for a part keeps that part's root, and only once; every other call reads it afresh, so a validator reused after a part is rewritten routes it by its new root (#910), and so does a second or nested call for the same part on the same validator (#941), a call for another part, a call from another validator, or one made after the walk's call settled by a task that call started (#926). That first call routes by the root its walk read, whatever a nested call or walk reads meanwhile: each walk and each call keeps its own roots (#949). A task that outlives its walk or call reads the root afresh, even when it calls _validateSingleFileXsd directly; before, it routed by the root the walk or call had read (#964). Each schema walk keeps its own trace and engine-failure count, and the walk that finishes last leaves its own on the validator, so a walk nested in another on the same validator no longer lists parts twice or loses the outer walk's engine failures (#943). The earlier basename routes (core.xml, app.xml, custom.xml, …) still come first. The CLI prints the finding with --json; its human output lists errors only.

Full DOCX/DOCM validation reports duplicate relationship Id values as rels-id-duplicate, judging the relationships part of every owner part in the package, whatever the owner's name suffix. Under word-valid, duplicates block opening only in the package relationships part (_rels/.rels) and the relationships part of a story root: the main document, resolved through the package's Transitional or ISO Strict officeDocument relationship wherever it is stored, and every glossary document a main-document glossaryDocument relationship reaches. Word 16.115 offers recovery even for identical or unreferenced duplicates there; a header part's own duplicate unreferenced hyperlinks opened clean, and so did the same pair in an orphan word/document.xml sidecar no main document owns, while a reached glossary's own sidecar was refused, as was the same pair in the sidecar of a main document or reached glossary stored as document.bin (#615, session 29, #617, session 30, and #618, session 31, in the Word probe evidence). Other sidecars retain their strict error and Word-valid warning, and a .rels whose owner part is not in the package is not judged by this walk. Lenient keeps its existing sidecar errors and XSD-only package diagnosis. This legacy ID walk is outside the named-check registry, so named and quick runs omit it.

Under Word-valid and strict, the legacy relationship walks read direct Relationship children after markup compatibility, using the same relationships namespace capabilities as the named checks. Duplicate IDs split between a direct child and a selected Fallback are counted together; unselected Choice relationships do not supply IDs, types or referenced targets. Word 16.115 offered recovery for the package duplicate in session 34, s34a; the analogous split in a story-root sidecar is unobserved in Word. The selected footer and main relationships that opened clean in session 33, s33d/s33e, no longer draw false rels-id-missing or rels-unreferenced findings (#635, Word probe evidence). Strict still reports stored relationships markup compatibility and schema defects. Lenient keeps its raw traversal and existing findings.

Under Word-valid and strict, the file-reference walk behind rels-broken and rels-unreferenced also skips TargetMode="External" relationships: an External target names a resource outside the package, never a part (ECMA-376 Part 2 §9.3), so a file:///…, UNC or ftp:// target — such as the attachedTemplate relationship Word writes to a local template — is no longer resolved as a package path and reported as a broken reference. Word 16.115 opened External hyperlinks to file:///missing.rels, file:///C:/docs/_rels/missing.xml and ../customXml/missing.xml clean, as direct children and inside a selected w14 Choice, and one a w:hyperlink names; these are the targets Word-valid's rels-broken still blocked, so they no longer block (Word probes session 40, s40a/s40b). Lenient keeps its historical scan, which skips only http… and mailto: targets (#665). Word-valid and strict skip only the http:, https: and mailto: schemes, so a relative target that merely starts with http resolves as a part: Word refused httpimage1.png/. as a folder and opened the plain httpimage1.png clean (Word probes session 79), and word-valid used to accept the folder target (#988). Any other scheme (ftp:, httpfoo:) makes the target an absolute URI that names no part, reported as rels-broken. It blocks word-valid in every relationships part, whatever the relationship type: Word refused it in each one shown. Word offered recovery for a used header and the styles part related through one, beside the well-formed stored word/ftp:… part (Word probes session 87, #1127), and for an unused header, a _rels/.rels styles relationship, an unused image, and the main document's settings, numbering, comments, footnotes and a used and an unused footer (session 88, #1178). Session 89 repeated the refusal in _rels/.rels for the core, extended and custom properties (s89a), in a used header's own sidecar for an ftp: image it embeds and one it does not use (s89b), and for an unused ftp: image in a used footer's, an unused header's, the styles part's and an unreferenced part's own sidecar (s89c). Two runs each, all four readings agree; each plain target opened clean (#1184). Only the scheme arm extends to every relationships part; the folder, OPC-relationships and customXml arms keep their narrower scope in _rels/.rels or a story root's own sidecar (#987). They read the scheme ASCII case-insensitively, as RFC 3986 does, so an HTTP: or MAILTO: target is skipped as its lowercase spelling is, where it used to be resolved as a part; lenient's scan stays case-sensitive. Word has been shown neither spelling as an internal target (#1008). An internal broken target blocks Word-valid when the part it resolves to is a relationships part or a customXml part outside word/, whatever the Target's spelling: Word 16.115 refused an absent package-root customXml/item1.xml spelled ../customXml/item1.xml, /customXml/item1.xml, ..\customXml\item1.xml, ../CUSTOMXML/%69tem1.xml and ../../customXml/item1.xml (an extra .. stays at the package root), and opened clean an absent image spelled media/_rels/x.png, notes.rels or word/../customXml/x.png and a reached glossary's absent ../customXml/item1.xml (Word probes session 54, s54a/s54b; #734). A target whose .. segments climb above the package root names the part where the climb stops, as RFC 3986's dot-segment removal resolves it, under every profile: both file-reference walks used to call it broken even when that part is stored, so word-valid rejected a stored root customXml/item1.xml named by ../../customXml/item1.xml, ../../../customXml/item1.xml or /../customXml/item1.xml, and every profile reported the part as unreferenced. Word 16.115 opened each of those clean (Word probes session 76), applied a root styles part named by ../../styles.xml or /../styles.xml and rendered a root header named by ../../header1.xml (session 75), and read a root notes part named by /../footnotes.xml (session 74). A climb whose stopped part is absent stays broken under that part's name, so session 54's absent root customXml row still blocks, and rels-target-missing reads resolves to '<part>' which does not exist; Word opened such a styles target clean but applied none of word/styles.xml, so the climb does not fall back to the source folder (session 75). Only a symbolic link can still leave the unpacked tree, and stays escapes the unpacked directory (#984). An internal target naming a folder is broken under every profile, and blocks Word-valid whatever the relationship type: a final . or .. segment or a trailing / leaves a folder, as RFC 3986 resolves it (styles.xml/., styles.xml/x/.. and styles.xml/ name word/styles.xml/, . names word/), and so does a target reaching the package root. The walks behind rels-broken and rels-target-missing resolved a file-system path, which drops the final /, so they found the stored part the path spells. Word 16.115 offered recovery for such a target beside that stored part for every relationship type it was shown, officeDocument, footnotes, customXml, styles, settings, a used and an unused header, an unused image and the core properties, and opened each plain target clean (Word probes sessions 70 to 73; #975). These blocking targets, the folder, relationships-part and customXml shapes, block only in a relationships part Word reads to reach the stories: _rels/.rels and the own relationships part of a main document or a reached glossary. Word opened clean a folder target in the relationships part of an unreferenced part (Word probes session 79), of a used header whose text it rendered, of an unused header and of the styles part (session 80), and an absent escaping customXml target in that of the unreferenced part and of the used header, so there the finding is a word-valid warning (#987). The customXml shape in an unused header's or the styles part's relationships part is unprobed (#1006).

The processed view is DOCX/DOCM-only: every observation behind it is Word opening .docx files, and PowerPoint was never shown markup compatibility in a relationships part, so PPTX keeps the stored view under every profile (#643). A DOCX run builds each relationships part's view once, through the run context's cached effectiveXml (#642).

Named checks and quick checks use the same options in the path and byte APIs:

import { readFile } from "node:fs/promises";
import { Validate, jubarte, validate } from "docx-validate";

const quick = await validate("./contract.docx", { quick: true });
console.log(quick.metadata?.omittedChecks);
const bytes = new Uint8Array(await readFile("./contract.docx"));
const selected = await new Validate({ checks: ["tables"] }).run(bytes);
const equivalent = await jubarte.validate(bytes, { checks: ["tables"] });

Validate and jubarte.validate also forward profile, strict, schemasDir, xsdValidator, verifyWord, wordVerifier, and deadlineMs. Set suffix on byte APIs for formats other than their default .docx (for example, .pptx or .docm).

  • --quick / { quick: true } selects QUICK_CHECK_GROUPS: package, xml, content-types, relationships, tables, and drawings.
  • --checks tables,word-run-misplaced / { checks: ["tables", "word-run-misplaced"] } selects check ids or group names, plus prerequisites. Unknown ids and explicitly named checks for another format are configuration errors.
  • A partial run applies its profile as a full run does, so it never fails a package the full run passes. lenient runs neither the Word rules nor the ZIP-envelope checks: only archive-only checks and Word rules are refused — naming one, or only groups made of them, is a configuration error. Under word-valid, an error Word tolerates is reported as a warning.
  • --list-checks lists every registered id, group, format, and description without reading a file or starting XML/XSD engines. --help also needs no engine startup.
  • --strict / { strict: true } is shorthand for profile: "strict". Strict rejects partial selection; --quick and --checks are mutually exclusive. --strict also rejects an explicit profile other than strict. A partial run never repairs, so --auto-repair / autoRepair: true with --quick or --checks is refused rather than ignored (#652). API conflicts throw ModeConflictError; CLI configuration errors exit 1.
  • Partial runs perform no XSD validation and report omittedChecks. A successful partial run prints “Selected checks passed”, followed by counts, never “All validations PASSED!”, even when it omitted no registered check (a PPTX or XLSX registers only the envelope and XML syntax checks). An Also not run: line names what has no check id: the format validator's own rules, where a full run walks schemas the schema walk, and for a DOCX given --original the redlining validation against it. It does not establish full-document validity. Selections run the registered checks only, without the full validator's other checks or severity remapping.
  • --json emits { valid, issues, metadata } for a completed validation, including warnings and available locations. Exit status is 0 for valid and 1 for invalid; configuration/startup errors go to stderr. The path API also returns suffix, repairs, and xsdWalk, which are not included in CLI JSON.
  • --deadline-ms <ms> / { deadlineMs } bounds the whole run, counted from the call; the byte APIs count their temporary write too (#630). The bound is cooperative: each step asks the budget before it starts — the package stages, every named check, every rule of the DOCX or PPTX validator, every part of the schema walk, the redlining validation, and Word verification — and a step already running finishes, so a run overruns by at most its longest single step. With a deadline the DOCX validator's rules run one after another rather than all at once, so the budget can stop one between two others. When the budget stopped any step, the run is invalid and reports one error-severity validation-deadline finding naming what did not run: a run cut short is never a pass, whatever the steps that ran found (timeout > engine error > invalid > valid). The profile's severity policy still applies to what ran. metadata.deadline reports { budgetMs, notRun }. A schema walk the budget stopped leaves schemaCompleted: false and counts the parts it never reached in xsdWalk.notExamined. Word verification gets what is left of the budget, at most its own 45 seconds; once the budget is spent it does not run and is unavailable. The budget is a finite number of milliseconds, 0 or more (RangeError otherwise; the CLI exits 1). It cannot be combined with --auto-repair: a repair writes, and a deadline could stop it part-way.
docx-validate --list-checks
docx-validate --quick contract.docx
docx-validate --checks tables,word-run-misplaced --json contract.docx
docx-validate --strict contract.docx
docx-validate --deadline-ms 30000 --json contract.docx

Full lenient and strict CLI runs first compile a small startup probe schema to check the XSD engine. This is separate from document validation and is not recorded in xsdWalk. Word-valid runs compile no schema, so they skip that probe, as do help, discovery, and partial runs (#318).

The registry in src/scripts/office/checks/index.ts includes WORD_RULE_CHECKS, which full DOCX validation runs under word-valid and strict, alongside its other validators. Full lenient validation does not run those Word rules. A ValidationContext caches part bytes and DOMs per run. Checks declare requires; the scheduler runs prerequisites once, in registration order. xml-syntax gates by part: dependent rules skip malformed parts and keep judging well-formed parts. A failed prerequisite without part-scoped findings skips its dependents. A throwing check yields check-crashed. For unpacked directories, runChecks() marks archive-only checks unavailable; use packed input to check ZIP integrity.

The complete named check catalogue is below. Each Word check except word-markup-compatibility uses its id as its finding code. Package checks are zip-bytes-after-eocd, zip-bytes-before-archive, zip-eocd-comment-overrun, opc-equivalent-part-names, opc-part-name-escaped-unreserved, opc-part-name-escaped-slash, opc-part-name-word-refused, and opc-item-name-refused; xml-syntax is the shared XML prerequisite. All word-* checks apply to DOCX/DOCM only. ct-duplicate-declaration, ct-override-partname-invalid, ct-override-partname-word-refused and ct-markup-compatibility apply to every package format; word-content-type-invalid, word-part-content-type-generic, word-part-content-type-inexact, word-part-content-type-mismatch and word-part-content-type-unrecognized apply to DOCX/DOCM only.

zip-bytes-before-archive applies to DOCX/DOCM, PPTX, and XLSX package selections, including the package quick group, and reads the archive bytes before extraction. It reports the bytes in front of the archive JSZip loads — the rebase offset plus the lowest local-header offset as JSZip resolves it, so a prepended ZIP, junk with relative offsets, junk with the absolute offsets a self-extractor writes, and the ZIP64 spelling (a saturated offset whose 0x0001 extra field carries the value) all measure the same prefix — as one blocking error under strict and Word-valid. The measure is undefined exactly where JSZip's reader throws — the local header's extent, its name and extra fields (each bounded on its own read, so a small negative resolved size cannot mask a name/extra overrun), the central-declared compressed stream (bounded on both sides: stream past the buffer, or a compressed size that reads negative — the 32-bit field 0x80000000 or a ZIP64 0x0001 value below zero — sending the final read's index below zero), and a known compression method (store or deflate) are modelled too — so a package whose local part JSZip cannot read reports no prefix (#524, #555, #566). Word 16.115 offers to recover every prefixed package probed (#464; probe session 14 in docs/plans/evidence/word-probes-2026-10-24.md). Lenient runs no ZIP-envelope check. A concatenated [zip][docx] is reported once, by this check: the earlier archive lies wholly in front of the archive JSZip loads, so zip-bytes-after-eocd no longer counts the live archive as undeclared trailing bytes — the earlier record's fixed extent decides, even when its declared comment reaches into the live archive (#491). Trailing data behind a prefixed archive is still zip-bytes-after-eocd's finding, measured from the loaded archive's own record.

opc-equivalent-part-names applies to DOCX/DOCM, PPTX, and XLSX package selections, including the package quick group. It reads stored ZIP item names before full-run extraction, or walked file names for directory inputs, and reports one finding per equivalent group under OPC [M1.12], listing every stored spelling. Strict and word-valid (DOCX/DOCM) report a blocking error: Word 16.115 offers to recover every duplicate probed, even when both spellings hold identical bytes (#351). Lenient, and word-valid for PPTX/XLSX, report a warning. The existing entry choice is unchanged: two entries stored under the identical spelling report the same finding, naming the spelling once per entry (Word offers to recover these too), and the package view reads the last stored entry (#411). The stored names come from the central directory of the archive JSZip loads: the last end-of-central-directory signature, with its offsets rebased past any prepended bytes, so the names always describe the package view, even behind trailing data or a prepended archive (#447, #462). This check, the item-name refusal and the package view the checks judge all use those central-directory names, with one shared CP437 decoder for names without general-purpose bit 11 and UTF-8 for flagged names (#480). Re-keying uses the central spelling, as Word 16.115 does, and ignores the local file header's name and the Info-ZIP unicode-path extra field (0x7075) (#445). Re-keying covers ZIP64 packages: the ZIP64 end-of-central-directory record's directory size and offset are rebased with the directory, while the 32-bit record keeps its saturated fields and the locator's record offset keeps naming the record's absolute position — JSZip reads that field before its rebase, so a prefixed self-extractor package whose locator names the record absolutely, even one whose EOCD comment carries a fake ZIP64-record signature, still loads after re-keying (#479, #521). If re-keying is unavailable (for example, an entry whose local-header offset or compressed size lives in a ZIP64 extra field), JSZip still prefers a valid central Unicode Path field. An entry's kind follows the central name as well: JSZip decides dir from the name it reads, so a central name ending in / is a folder in the package view and its part leaves the view (#482). unpack is the exception: it extracts under raw JSZip's own names, the local header's or the unicode-path field's (#469), with UTF-8 as its default decoder. A flag-less CP437 caf\x82.xml therefore extracts as caf�.xml, while the validator's archive view uses café.xml; directory validation of that extracted tree sees caf�.xml (#480).

opc-part-name-escaped-unreserved uses the same formats, package-group selection, pre-extraction timing, directory support, and severity policy. It reports one finding per stored name containing a percent-encoded unreserved character (A–Z, a–z, 0–9, -, ., _, ~), as OPC [M1.8] forbids, in code-unit order with the stored name as path. Word 16.115 offers recovery for every escape probed: letters in either case, a digit, - in either hex case, ., _ and ~. It does so even on an unreferenced extra item (word/%78tra.xml), and opens the %20 control clean. Other escapes such as %20, %25, and %5B, malformed escapes, and plain names do not trigger this check; an escaped separator has its own check below. An escaped spelling beside its decoded twin reports both M1.8 and M1.12; validation does not rewrite either name (#408).

opc-part-name-escaped-slash uses the same formats, package-group selection, pre-extraction timing, directory support, and severity policy. It reports one finding per stored name with a segment holding a percent-encoded / or \ (%2F, %2f, %5C, %5c), as OPC [M1.7] forbids, in code-unit order with the stored name as path; the escape must be whole, so %252F (an encoded % before 2F) stays legal. Word 16.115 offers recovery for an unreferenced word%2Fxtra.xml, refuses word%5Cxtra.xml, and opens the well-formed word/xtra.xml control clean (#436).

opc-part-name-word-refused reports one finding per stored file-entry name containing a percent-escaped RFC 2396 mark (!, ', (, ), *), a percent-escaped octet at or above 0x80, or a literal non-ASCII character. It reads stored ZIP file-entry names before extraction, including unreferenced entries and refused package views, and walked file names for directories. Folder entries — a trailing /, the MS-DOS directory attribute, or both — are not judged, and Word was not probed for a folder entry whose only defect is its name. Hex is case-insensitive and whole triplets are consumed: %2521 stays clean, as do %20, %26, %3A, %5B, %7F, and a literal !. Unreserved escapes keep their existing M1.8 finding.

opc-item-name-refused reports one finding per distinct stored file-entry name that places no package part: one that starts with / or \, names a drive (c:), or has an empty, ., .. or other dot-only segment, read with \ as /. The finding's path is the stored name and its message is the package view's refusal. It reads the same stored names as the checks above, and folder entries are not judged. Every such name still refuses the staging, so the finding is an error under every profile, and it is Word-blocking: Word 16.115 rejects every kind, even for an unreferenced entry. It offers recovery for a leading / or \, a drive, and an empty, ., .. or ... segment (#411; sessions 3, 42 and 43). The package-extract-failed the refusal raises names only the first such name; this check names each one (#629).

ct-override-partname-word-refused applies the same escaped-mark, high-octet and literal non-ASCII rules to every Override PartName, even when no part is stored under that name. It reports one finding per declaration at the stored content-types stream, quoting the PartName. A literal non-ASCII PartName is reported here exactly when the §9.1.1.1 grammar accepts it — a literal ucschar Part IRI such as /word/café.xml passes ct-override-partname-invalid (#455), so only this check names Word's refusal — and never repeats the grammar finding a name outside both forms already has. A spelling containing both %41 and %21 reports both applicable codes once.

Both checks are errors for DOCX/DOCM under word-valid and strict (strict includes every Word-blocking defect), warnings under lenient, and warnings for PPTX/XLSX under every profile: these spellings are spec-valid and PowerPoint/Excel refusal has not been observed. Full, quick and named selections share this policy; strict still rejects partial selection, and XLSX supports partial runs only. Word 16.115 offers recovery for these spellings in DOCX packages, as recorded with SHA-256 hashes in probe sessions 6–12 (#451, #408, #443). Validation leaves names and input bytes unchanged.

Group Named checks
package zip-bytes-after-eocd, zip-bytes-before-archive, zip-eocd-comment-overrun, opc-equivalent-part-names, opc-part-name-escaped-unreserved, opc-part-name-escaped-slash, opc-part-name-word-refused, opc-item-name-refused
math, tracked-changes word-math-run-insertion, word-math-run-deletion
math word-math-parse, word-math-spre-body
story word-shading-color, word-page-margin-attributes, word-run-misplaced, word-run-properties-misplaced, word-underline-misplaced, word-paragraph-properties-misplaced, word-section-properties-misplaced, word-footnote-reference-missing, word-endnote-reference-missing, word-shared-story-part, word-numbering-picture-bullet, word-sdt-id-empty, word-text-contains-paragraph, word-field-unbalanced
tables word-grid-column-attributes, word-cell-margin-order, word-table-properties-required, word-table-properties-misplaced, word-row-without-cell, word-cell-ends-with-table
drawings word-textbox-content-namespace, word-drawing-extent-missing, word-drawing-extent-misplaced, word-picture-nvpicpr-missing, word-picture-sppr-missing, word-empty-drawing, word-anchor-missing-attribute, word-position-offset-and-align, word-drawing-scalar-whitespace, word-drawing-arc-radius, word-drawing-extent-negative, word-part-reference-relationship
content-types word-content-type-invalid, ct-duplicate-declaration, ct-override-partname-invalid, ct-override-partname-word-refused, ct-markup-compatibility, word-part-content-type-generic, word-part-content-type-inexact, word-part-content-type-mismatch, word-part-content-type-unrecognized, word-package-thumbnail-type, ct-stale-override
relationships word-duplicate-theme-relationship, word-duplicate-font-table-relationship, word-duplicate-web-settings-relationship, word-duplicate-settings-relationship, word-duplicate-styles-relationship, word-duplicate-numbering-relationship, word-duplicate-endnotes-relationship, word-duplicate-footnotes-relationship, word-hyperlink-relationship, word-header-footer-relationship, word-main-document-part, opc-relationships-structure, opc-relationships-target-mode, word-part-reference-relationship
tracked-changes word-textbox-deleted-text, word-move-from-deleted-text, word-move-range-unpaired
settings word-custom-property-filetime-empty, word-attached-toolbar-relationship, word-attached-toolbar-utf8
xml word-markup-compatibility, word-document-root, word-choice-requires-undeclared, xml-package-markup-compatibility
comments comment-markers, comment-threading
schema word-unknown-element, word-link-without-namespace

Twenty-six rules in this table judge the markup of the story parts Word reads (#1113), as word-hyperlink-relationship does: every main document and every glossary a main-document relationship reaches, wherever stored, and the headers, footers, comments, footnotes and endnotes their references make Word read. Before, they judged every part named like a story, wherever stored.

Word session 83 (s83a–s83d, two runs each, all readings agree) put eleven of their shapes in two placements. It refused all eleven in a main document stored as word/main.xml, which the rules missed, and opened all eleven clean in an unrelated word/header83.xml, which they rejected. The eleven are word-row-without-cell, word-cell-ends-with-table, word-drawing-arc-radius, word-drawing-extent-negative, word-empty-drawing, word-anchor-missing-attribute, word-text-contains-paragraph, word-textbox-deleted-text, word-move-from-deleted-text, word-sdt-id-empty and word-choice-requires-undeclared.

The other fifteen are word-table-properties-required, word-table-properties-misplaced, word-grid-column-attributes, word-cell-margin-order, word-drawing-extent-missing, word-drawing-extent-misplaced, word-drawing-scalar-whitespace, word-position-offset-and-align, word-picture-nvpicpr-missing, word-picture-sppr-missing, word-textbox-content-namespace, word-math-run-deletion, word-math-run-insertion, word-page-margin-attributes and word-shading-color. They read the same parts, and their shapes in these placements are unprobed.

Some of them also read parts by name:

  • word-cell-margin-order also reads word/styles.xml;
  • word-shading-color also reads the parts named styles.xml or numbering.xml;
  • word-table-properties-misplaced judges the order inside a w:tblPr or w:tblPrEx in the read stories, and a table-property element outside its containers in every WordprocessingML part.

When the package names no stored main document, the rules keep every part named like a story, headers, footers, comments and notes parts included; that reading is unprobed. The note checks' fallback there (#816) is narrower: it judges references in no header, footer, comments or notes part (#1119).

word-duplicate-endnotes-relationship reports the main document's relationships part when it holds two or more exact-Type endnotes relationships and that main document's own content holds a w:endnoteReference: Word 16.115 offered recovery for every such shape probed — whichever part held the note, and whether the second part was typed endnotes+xml or application/octet-stream — and opened the same pair with no endnote reference clean (#807; session 62, s62c and s62e, in docs/plans/evidence/word-probes-2026-10-24.md), while a referenced pair in a reached glossary's own sidecar is unprobed and stays unjudged. The count reads exact Types whatever their target or TargetMode, in either relationship namespace, so two relationships to the same part, an internal plus an External one and a Strict-Type pair are judged too; session 62's rows are two internal Transitional relationships to distinct parts, so those shapes are unprobed.

word-duplicate-footnotes-relationship reports the main document's relationships part when it holds two or more exact-Type footnotes relationships and that main document's own content holds a w:footnoteReference: Word 16.115 offered recovery for every such shape probed — whether the second part held another footnote id or the same footnote — and opened the unreferenced pair clean (#827; session 63, s63d, and session 45, s45g, in docs/plans/evidence/word-probes-2026-10-24.md), while a referenced pair in a reached glossary's own sidecar and a reference only in a header, footer, endnote or comment are unprobed and stay unjudged. The count reads exact Types whatever their target or TargetMode, in either relationship namespace, so two relationships to the same part, an internal plus an External one and a Strict-Type pair are judged too; session 63's s63d rows are two internal Transitional relationships to distinct parts, so those shapes are unprobed.

word-content-type-invalid also reports an explicitly empty ContentType="" on the main document's Override, resolved through the package officeDocument relationship and the shared content-type table after Markup Compatibility processing (#581). Word 16.115.26100424 offers recovery for this specimen (session 22 in docs/plans/evidence/word-probes-2026-10-24.md). Sessions 49 and 50 (2026-10-26) observed the same recovery offer for an empty ContentType="" on the Override of the styles part the main document relates (s49b) and of a related settings or fontTable part (s50c), and session 53 (s53d) for a related numbering part, so the finding now covers those Overrides too, each citing its session (#720, #740); those sessions stored Transitional packages, so in a package with an ISO Strict part the finding keeps its error without the session (#1016); one finding per part, named by the first relationship in the shared reporting order. It is a blocking error under Word-valid and an error under strict. Lenient runs no Word rule, so it reports nothing here beyond the XSD rejection. Full, quick and named Word-valid runs agree. Empty values on every other Override — a reached glossary's parts included — and on Defaults stay silent under Word-valid until probed; strict continues to reject them through XSD.

word-content-type-invalid reports invalid-* content types only when they apply to a stored part: an Override must name an existing part under OPC equivalence, and a Default must cover a stored part without its own Override. Word 16.115 opens an Override for a missing part and an unused Default cleanly; an invalid type on an existing part is refused or recovered.

ct-duplicate-declaration reports each repeated Default@Extension (ASCII case-insensitive) or Override@PartName (OPC part-name equivalence), even when the declarations have identical content types. Each finding names both spellings at the stored content-types stream path. It is an error under strict and under Word-valid for DOCX/DOCM, and a warning under lenient and Word-valid PPTX/XLSX. Word 16.115 offered recovery for all five observed duplicate shapes; the unchanged minimal control opened clean (#431). Full runs, quick runs and named selections share the rule. An unparseable stream reports XML diagnostics without duplicate findings. Strict continues to reject partial selectors; full XLSX validation remains unsupported.

ct-override-partname-invalid reports every Override whose PartName is not a valid OPC part name (ECMA-376 Part 2, 4th edition (2012), §9.1.1.1, the edition of the bundled schemas/ecma/fourth-edition/ OPC schemas, which strict follows): a part name shall be an IRI and shall be encoded as either a Part URI (§9.1.1.1.2: segment = 1*( pchar ), RFC 3986) or a Part IRI (§9.1.1.1.1: isegment = 1*( ipchar ), RFC 3987, which allows a literal ucschar), so a name that does not start with /, holds an empty, a ./.. or a dot-only (...) segment, ends with / or ., has a folder segment ending with ., carries a ?, a # or a \, percent-encodes a separator (OPC [M1.7]), an unreserved character (OPC [M1.8]) or — once a literal ucschar leaves only the Part IRI form — an iunreserved character (OPC [M1.8]), or holds a character in neither the RFC 3986 pchar nor the RFC 3987 ipchar set, is reported. /word/café.xml (a Part IRI) and /word/caf%C3%A9.xml (a Part URI, ECMA-376 Example 9–1) are therefore valid, while /word/caf%C3%A9-é.xml is neither, and a literal non-ASCII character that is not ucschar (a C1 control, private use, a noncharacter) is in neither grammar (#455). The ucschar set is RFC 3987 §2.2's: %xA0-D7FF, %xF900-FDCF, %xFDF0-FFEF, each astral block %x10000-1FFFD … %xD0000-DFFFD, and the last one, %xE1000-EFFFD — so U+E0000..U+E0FFF is in neither grammar and is reported, while U+E1000 and U+EFFFD are valid (#522). A percent-escape run that does not decode as UTF-8 (a lone %E9, an overlong %C0%AF) is pct-encoded octets rather than an encoded character, and the scan resynchronizes at the next octet, so the %C3%A9 behind one still encodes an iunreserved character and is reported (#523). A literal ucschar the grammar accepts stays refused by Word, which ct-override-partname-word-refused reports (#451). Such a PartName names no part, so the declaration is malformed whatever its content type. Resolution is unchanged: content types are still resolved through the canonical spelling, so a malformed PartName can still resolve a stored part, as word/document.xml without its leading slash resolves the main document, while a PartName with a dot segment resolves nothing (#457). One finding is reported per offending Override, at the stored content-types stream, quoting the PartName and naming the rule it breaks; a folder segment ending with . is named only when no other rule is broken. An Override without a PartName is reported as having none, and Word offers to recover it too (#458, session 13). A content-types stream that does not parse is reported by the XML syntax gate instead, and nothing is reported from it here. Word 16.115 offers to recover a package for every shape probed, even when nothing is stored under that name and the content type is well formed, except a folder segment ending with . that holds another character (/word./x.xml), which it opens clean, as it opens a well-formed PartName for a missing part. A dot-only folder segment (/word/.../x.xml) is refused (#414, #450, #453; probe sessions 3c, 4, 5, 7 and 12 in docs/plans/evidence/word-probes-2026-10-24.md). The finding is an error under strict, and an error and Word-blocking under word-valid for DOCX/DOCM, where a folder segment ending with . is a warning; lenient and the unobserved PPTX/XLSX report a warning. Full, quick and named runs agree.

ct-markup-compatibility reports one finding for a content-types stream that uses Markup Compatibility: an attribute in the MC namespace (mc:Ignorable, mc:ProcessContent, mc:MustUnderstand), an element in it (mc:AlternateContent and its branches), or an element or attribute in a namespace an mc:Ignorable lists. A bare xmlns:mc declaration that nothing uses is not reported. The message lists every such element and attribute once per occurrence, in document order — an element before its own attributes (#495, #526). Microsoft Word 16.115 does not process Markup Compatibility in this stream: it offers recovery for every shape probed — an mc:Ignorable attribute, an ignored foreign element, and an AlternateContent in either orientation — while the declared-only control opens clean (#459; probe session 14, docs/plans/evidence/word-probes-2026-10-24.md). The check reads the raw stored stream, while ct-duplicate-declaration, ct-override-partname-invalid, ct-override-partname-word-refused, word-content-type-invalid, word-part-content-type-generic, word-part-content-type-inexact, word-part-content-type-mismatch, word-part-content-type-unrecognized and content-type resolution all judge the stream after Markup Compatibility processing, as OPC [M1.19] requires — so an Override in an ignored mc:Choice no longer draws a ct-override-partname-invalid finding, and a bad mc:Fallback still does. That processor understands the stream's own vocabulary: an mc:Ignorable naming the content-types namespace itself blinds no coverage or resolution pass, and .rels parts read through the same view add the package-relationships namespace (#493). The finding is an error, and Word-blocking, under word-valid for DOCX/DOCM and under strict (whose schema walk already refuses the same stream); lenient and the unobserved PPTX/XLSX warn. Full, quick and named runs agree.

word-part-content-type-generic reports a part Word reads whose content type resolves to a generic XML type — media type application/xml or text/xml, compared ASCII case-insensitively with any MIME parameter stripped, so application/xml; charset=utf-8 counts as generic — from either source: an Override, or the <Default> for the part's own extension, which the finding names (#504, #517, #540). The reached parts are the main document (the package officeDocument relationship's target) and the parts the main document's relationships of type styles, settings, fontTable, webSettings, numbering or theme name, wherever they are stored. Resolution follows ECMA-376 Part 2 §10.1.2.4 through the shared table: a part with a specific non-generic Override — right or wrong — is out of scope, and under word-valid and lenient customXml stays outside this check. Under strict the check also judges every other Part 1 WordprocessingML part type a main-document relationship names — header, footer, endnotes, footnotes, comments and glossaryDocument — whose findings cite the content type ECMA-376 Part 1 (4th edition), clause 11.3, assigns rather than a Word observation (#506). Under strict, a Default-typed main or theme finding is suppressed only when ct-undeclared-part actually reports an error on that same path; renamed main parts, including non-XML names and parts under docProps/, retain their generic-type error (#535). A part reached by two relationship types is judged against every type that reaches it — a Default-typed theme also named by an unreferenced footer relationship still counts as the theme for that suppression — and the finding names the segment the check's fixed reporting order ranks first, an observed relationship before a spec-only one, so the order of the relationships in the relationships part decides neither the verdict nor the message (#593). One finding is reported per part, at the part's path, naming the relationship type and the content type the part needs — for the main document, all four Word main-document content types, since the package does not say whether it is macro-enabled (#505). Word 16.115 offers to recover the package for all five parts probed typed only by the generic Default — the main document and styles with their Override removed, and settings, fontTable and webSettings added with a relationship but no Override — and opens every proper-Override control clean (#432, session 8a in docs/plans/evidence/word-probes-2026-10-24.md). Session 20 adds related numbering (word/numbering.xml) and theme (word/theme/theme1.xml) typed only by the application/xml Default: Word 16.115.26100424 raised a repair warning for both, and opened their exact Override controls clean (session 20, chunks s20c and s20d, in docs/plans/evidence/word-probes-2026-10-24.md; #571). Session 23 also observed recovery for a theme only a reached glossary relates, typed by the application/xml Default; its exact-Override control opened clean (#595). The finding names the glossary's theme relationship. Session 24 also observed recovery for its styles part typed by the generic Default, while the exact Override control opened clean (#601). The finding names the glossary's styles relationship: error and Word-blocking under word-valid, error under strict, warning under lenient. Strict retains this finding because no ct-undeclared-part error replaces it at that path. Only theme and styles join the glossary's generic-type scope; settings, fontTable, webSettings, numbering and stories remain unprobed. Other generic spellings are inference from resolution. Other generic spellings for numbering and theme are inference from the same resolution. Strict keeps only ct-undeclared-part for the observed Default-typed theme, while numbering keeps word-part-content-type-generic; full, quick and named word-valid runs agree, and lenient warns. This does not widen the separate inexact-type check's scope. Session 15 probed the main document and styles with text/xml by Default or Override and with application/xml by Override, and Word offers to recover every spelling; the Override and text/xml spellings for settings, fontTable and webSettings are inference from the same resolution (#517). Session 16 probed styles typed application/xml; charset=utf-8 by an Override — the media-type comparison above comes from that observation — and the parameterized spellings on settings, fontTable and webSettings are inference from the same resolution (#540). The finding is an error, and Word-blocking, under word-valid for DOCX/DOCM, and an error under strict, where a Default-typed main is not reported twice when ct-undeclared-part rejects that same path; lenient reports a warning. Full, quick and named runs agree.

word-part-content-type-mismatch reports a related part whose declared media type matches none of the judged relationship segments, but is another part's expected type in the shared Word type map (#600). Media types are trimmed, compared ASCII case-insensitively, and stripped of parameters. Generic XML, an inexact spelling of the part's own media type, and unknown types such as image/png stay outside this check, so the generic, inexact and mismatch checks are disjoint. A part read through story references is judged against the segments those references use. An unused matching relationship does not exempt it; a match through either of two used references still exempts the story reaches. Since #739 a type that matches one reach no longer exempts a mismatch on another observed reach: every observed reach is judged over all of the part's reaching entries, so a read footer cannot hide an eagerly parsed reach's defect behind it. Unread parts are judged against all reaching segments under strict (#607).

Word-valid reports the observed shapes only: read header/footer swaps — a referenced footer typed …header+xml, or a referenced header typed …footer+xml, from the main document or a reached glossary — the eager reaches sessions 49 and 50 (2026-10-26) probed (#722): the main document typed with another mapped Word type (s50d), and a part the main document's own relationships part relates by styles, settings or numbering typed with another mapped Word type — the main styles part with the settings type (s49b), a related settings part with the styles type and a related numbering part with the settings type (s50d); those parts are eagerly parsed, so a relationship is enough and no usage lookup gates them — and the read story co-reach: session 53 (s53a/s53b, 2026-10-26, #739) an eagerly read part — the main document, a related numbering part or the styles part — that a footer reference the body makes also reaches, typed with the main document, numbering or styles type alike, and the styles part also targeted by the main document's settings relationship, typed styles or settings alike; session 57 (s57c–s57e, #778) the reaches with no eager reach: a footer the body references typed with the main document, numbering, styles or settings type, a header the body references typed with the main document or styles type — the exact-typed styles part a read header relationship targets included (s57e) — and a reached glossary's own styles part a main footer relationship also reaches (s57d); and session 58 (s58b, #785) the other read stories: a used comments, endnotes or footnotes part the body references holding its own Transitional root (#803) and typed with the styles type, a spelling with a parameter included (session 62 s62c, #802). Session 63 (s63b) showed a comments part whose stored root is an mc:AlternateContent holding one Choice requiring the w14 namespace with only its own Transitional root, then one empty Fallback, typed styles exactly (#818); XML whitespace, comments and processing instructions preserve that citation. Session 65 (s65b–s65d, two runs, all readings agree) showed five more shapes: text before the root inside that Choice, text in the Fallback, a Choice requiring the Transitional Word namespace instead of w14, a Strict comments root inside the w14 Choice, and a Strict root stored directly in a Transitional package. Each was refused typed styles exactly and opened clean typed comments, so these findings cite session 65 (#847). Text directly between the branches was refused typed comments too, so it supports no content-type citation; #851 owns its mce-malformed finding. Other Requires namespaces, a Strict root in the Fallback, text elsewhere inside the Choice or Fallback, or combinations of these edits, the endnotes and footnotes twins, and other spellings of the new shapes are unprobed and keep their previous verdicts without borrowing a citation, as do CDATA anywhere in the AlternateContent, its Choice or its Fallback, between the branches included (#857). Every session behind these read-story co-reach citations — sessions 53 and 57 for the header and footer pairs, 58, 62, 63 and 65 for the comments and notes parts — stored a package whose main document is a Transitional w:document, so in any other package, a uniformly Strict one included, the co-reach keeps its verdict without a citation (#861). Those sessions stored every other part Transitional too, so a Strict part elsewhere — a styles part, a glossary, docProps/app.xml — drops the citation as well. Only the comments and notes shapes name their story part, whose root s65c and s65d stored Strict, so that part's own class keeps session 65's citation (#1016). Other stored roots that the effective view unwraps to the story's root, including a selected Fallback and a Choice with no Fallback or beside another Choice, remain unprobed for this type mismatch (#837, #843). The exact-typed styles part that a comments, endnotes or footnotes relationship nothing references also targets opens clean (s58a/s58b) — the observed shape is the read story typed styles, not the styles part co-reached. Word refused every co-reach shape whatever the Override says, so a matching reach exempts none of them; typings no session showed on a story part stay silent. Session 24 observed Word 16.115 offer recovery for the main document's header and footer swaps and the glossary footer swap, including …header+xml; charset=utf-8; their exact-type controls and the minimal package opened clean. Session 26 observed recovery for the glossary header typed …footer+xml and for a read footer typed …header+xml despite an unused header relationship to it; the latter's …footer+xml control opened clean. The recovery sentence cites session 24 for the main header, main footer and glossary footer swaps, and session 26 for the glossary header swap (#610); every eager pair names the session that showed it — session 49 the settings type on the styles reach (s49b), session 50 the styles type on the main document and the settings reach and the settings type on the numbering reach (s50d), session 53 the other mapped types it ran on the main document (s53d–s53f) and on the settings, numbering and styles reaches (s53f–s53h), session 56 the footer type on the styles reach (s56a), session 57 the thirteen pairs no earlier session had shown: the main document typed webSettings, the settings reach typed webSettings, glossary, footer, footnotes or endnotes, the numbering reach typed webSettings, glossary, theme or footer, and the styles reach typed webSettings, glossary or footnotes (#779), and session 59 the remaining three pairs: the styles reach typed endnotes and the numbering reach typed footnotes or endnotes (s59a) (#789) — all 48 eager pairs are observed, and every recovery sentence names the session that showed its pair. Sessions 24, 26, 49, 50, 53, 56, 57 and 59 stored Transitional packages, so in a package with an ISO Strict part the swaps and eager pairs keep their error without the recovery sentence (#1016); the footer co-reach pairs cite sessions 53 and 57, the header co-reach session 57 (#739, #778), and the comments, endnotes and footnotes pairs typed styles session 58 for a directly stored Transitional root, or session 62 when that type carries a parameter (#785, #802); the observed comments AlternateContent and Strict-root shapes cite sessions 63 and 65 as described above. The finding names the relationship, declared type and expected type, errors and blocks Word-valid; lenient warns. Unread stories, a related fontTable or webSettings part, the glossary's reaches, and comments/footnotes/endnotes typed with each other's types stay silent under Word-valid until probed.

Strict judges every related part in the shared map, read or unread, citing ECMA-376 Part 1 (4th edition), clause 11.3's content-type assignments (theme is DrawingML and cites clause 14.2.7). The main document is named the Main Document part and cites clause 11.3.10 (#609). Candidate relationships are ranked in the same fixed order as the generic check, so relationship entry order does not change the reported segment or clause (#608). Only an observed finding — a read header/footer swap or read story co-reach, the main document, or a related styles, settings or numbering part whose pair a session showed — carries the Word recovery sentence; unobserved shapes and a story part typed with a type no session showed keep spec wording. Full, quick and named selections agree (#381).

word-part-content-type-inexact — renamed from word-part-content-type-parameter for #550 — reports a part Word reads whose declared content type is not exactly the part's own Word type ignoring ASCII case, while its media type — trimmed, parameters stripped — is: any ; … parameter section, with or without a space and whether charset or not, and padding whitespace. Word 16.115 refuses ; charset=utf-8 even where RFC 7303 §9.1 registers the parameter, refuses a trailing space, and opens an upper-case spelling clean — it folds ASCII case and nothing else (#540, #550; probe sessions 16–17, 2026-10-24, the table living with issue #550). Scope is the parts sessions 17 and 20 observed: the main document — all four Word main-document content types (#505) — styles, settings, fontTable, webSettings, a header the main document references through w:headerReference, a related numbering or theme part (both eagerly parsed), and a footer, footnotes, endnotes or comments part the document uses; a related-but-unused lazy part opens clean (#516) and stays silent. Session 20 probed the types #550 left open: an inexact Override gets the repair warning on each of them, while the same spelling on a glossaryDocument opens clean and stays silent under word-valid. Session 21 observed a trailing space refused on the same parts, and a related but unused footer, footnotes, endnotes, comments or header part with a parameter opening clean (#570; sessions 20 and 21 in docs/plans/evidence/word-probes-2026-10-24.md). Sessions 21 and 22 probed the parts a reached glossary's own relationships part names: an inexact Override on its styles part — eagerly parsed, so related is enough — and on a header its w:headerReference uses gets the repair warning, and the finding names the glossary's relationship, not the main document's (#579). Session 23 observed the same recovery offer for a ; charset=utf-8 Override on a footer, footnotes, endnotes or comments the glossary references, with every exact type control opening clean (#596). These stories are gated by wordReadParts just like the glossary's header: unreferenced parts stay silent under word-valid, while strict uses the spec text without a Word recovery claim (#589). The glossary's settings, fontTable, webSettings, numbering and theme were not probed for an inexact type and stay silent under word-valid; strict judges every Part 1 WordprocessingML relationship — and theme — on the glossary too, citing clause 11.3 for the unprobed WordprocessingML segments; the theme, a DrawingML part, cites no clause. A part the main document and the glossary both relate by the same type is reported once, naming the main document's relationship unless only the glossary reads the part through it. A part several relationship types reach is judged against each: the finding names the type whose expected content type's media type matches the declared value — the types are pairwise distinct, so at most one can — and an unreferenced lazy relationship neither reports nor hides a read one, because usage is decided per (part, type) pair (#593); a read story relationship hides no eager one either — session 56 (s56a, 2026-10-26) observed Word recover the styles part typed …styles+xml; charset=utf-8 with a read footer relationship also targeting it, and session 87 (s87a, 2026-10-30) the same part with a used header, comments, footnotes or endnotes relationship in place of the footer (#776), so the spelling is judged over every reach the story roots read (#760). Strict judges the same probed set — theme included, though it is a DrawingML part, not a clause-11.3 one — plus the unprobed Part 1 WordprocessingML glossaryDocument type, citing the content type ECMA-376 Part 1 (4th edition), clause 11.3, assigns (#506). A strict finding for a lazy part Word does not read — a related-but-unused header, footer, comments, footnotes or endnotes part, the main document's or a reached glossary's header — cites clause 11.3 the same way and claims no Word recovery: session 21 (s21b) observed those specimens open clean (#589; session 21 in docs/plans/evidence/word-probes-2026-10-24.md), so the recovery sentence stays on the parts Word reads — eager parts and used lazy parts alike, whose findings keep the observed wording under strict exactly as word-valid reports it. Lenient reports a warning. One finding is reported per part, naming the declared value — quoted, so padding is visible — and whether a Default or an Override produced it; the generic check judges the media type with parameters stripped, so a part is never reported by both checks. Under strict, a Default-typed main finding is suppressed only when ct-undeclared-part actually reports an error on that same path, exactly as the generic check's (#535). Full, quick and named runs agree.

word-part-content-type-unrecognized reports a part Word reads whose resolved media type — trimmed, parameters stripped, compared ASCII case-insensitively — is none of the types the package's own relationships expect: not its own Word content type, not a generic XML type (word-part-content-type-generic's class), and not another mapped Word part's type (word-part-content-type-mismatch's class), so the four part content-type checks stay disjoint (#707, #719: an invalid-* declared value is word-content-type-invalid's class alone). Sessions 46, 49 and 50 (chunks s46e, s49a/s49b and s50c/s50d in docs/plans/evidence/word-probes-2026-10-24.md) observed Word 16.115 offer recovery for the main document itself — its Override typed application/octet-stream (session 49), APPLICATION/OCTET-STREAM, application/octet-stream; charset=utf-8, image/png or application/foo+xml (session 50) — for the main document's styles part — word/styles.bin typed application/octet-stream by a Default, whether the part held non-XML bytes or well-formed styles XML, while the same XML typed as styles opened clean (session 46), and word/styles.xml typed by its Override with the same five spellings (session 49) — and for the settings, fontTable and numbering parts the main document's own relationships relate, typed application/octet-stream by their Override (session 50) and typed the same four other spellings — APPLICATION/OCTET- STREAM, application/octet-stream; charset=utf-8, image/png or application/foo+xml — (session 53, s53b–s53d) (#721, #740). That observed reach — the main document and the parts its own relationships part relates by styles, settings, fontTable and numbering — carries the Word recovery sentence and blocks under word-valid, the sentence dropped in a package with an ISO Strict part, as sessions 46 (s46e), 49, 50 and 53 stored Transitional packages (#1016); every other segment the check's spec-set walk reaches — a related webSettings or theme part, a header, footer, footnotes, endnotes, comments or glossaryDocument part, and the parts a reached glossary's own relationships relate, its styles included — cites the content type ECMA-376 Part 1 (4th edition), clause 11.3 assigns it (14.2.7 for theme) as an error under strict and a warning under word-valid. An undeclared type stays with ct-undeclared-part, and an empty one with word-content-type-invalid on the main document and on the styles, settings, fontTable and numbering parts the main document relates (#720, #740; sessions 22, 49, 50 and 53); every other segment's empty Override was not shown to Word and stays unreported. An invalid-* declared value stays with word-content-type-invalid alone; lenient warns. One finding is reported per part, and when several relationship types reach it the observed reach names the finding whenever it exists. Full, quick and named runs agree.

word-package-thumbnail-type reports one error per internal package thumbnail relationship in _rels/.rels whose stored target resolves to image/png, using Override precedence over Default and case-insensitive type/subtype comparison. It blocks word-valid and strict and runs in quick and named selections; lenient runs no such rule. Word 16.115 offered recovery for 1×1 and 96×96 PNG thumbnails and opened 96×96 JPEG thumbnails clean with either declaration (Word probes sessions 89 and 90, #1186). The recovery sentence is omitted in packages containing an ISO Strict part. Other image types, parameterized types, External relationships, absent targets, unrelated PNG parts and thumbnails related from other parts are not judged.

The schema group contains structural checks, not an XSD walk. The comments group runs comment-markers (range pairing and references to existing comments) and comment-threading (marker counts, paragraph ids, parent ids, and durable ids across comment extension parts). Threading reads the main document, the comments part and all three extension parts after markup compatibility's branch selection, as comment-markers reads its parts (#799, #808). Both apply to DOCX/DOCM under every profile, including lenient; they are separate from WORD_RULE_CHECKS. They retain their own comment-marker-parse / comment-thread-parse diagnostics rather than requiring xml-syntax. A named run that selects them also runs xml-syntax and reports a malformed part once, through xml-syntax, as the full run does (#385). Under lenient that run stops where xml-syntax rejects a part, as the full lenient run stops at a malformed part, so the comment checks then do not run (#391). Full validation runs them once and records their ids in executedChecks; --quick does not select them. Like every other check, they find the comments part a relationship names under an OPC-equivalent stored name (word/comments.XML).

A dangling w:commentReference is a Word-valid error; a range start or end naming a missing comment without that reference stays a warning. The comment-marker-missing finding carries a registered reference fact, so its verdict does not depend on message text. The story checks word-footnote-reference-missing and word-endnote-reference-missing likewise reject references whose ids name no matching note. Session 58 (s58a, two runs, all four readings agree) showed recovery for the dangling comment and endnote references and clean opens for their matching-id calibrations (#784). comment-markers collects markers and comment ids in both the Transitional and the Strict WordprocessingML namespaces, the ids attributes in the marker's own namespace, so an ISO Strict package's comments are judged too: session 63 (s63a in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all readings agree) offered recovery for a Strict w:commentReference naming no comment its comments part defines, and opened the unchanged Word-saved Strict calibration clean (#817), so the Strict dangling reference blocks as its Transitional counterpart does. A Strict orphan range start or end, now paired too, is unprobed. The ids are pooled across the two namespaces, so a reference in one namespace satisfied only by a comment in the other passes; mixed-namespace satisfaction, in either direction, is unprobed. The comments are read only from the part a comments relationship of the main document names: session 69 (s69a in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all four readings agree) offered recovery for a body w:commentReference whose comment only a word/comments.xml no relationship names defines, with and without a main-document relationships part, and opened clean the calibration relating that part and the same unrelated part with range markers only (#896). So a main document a relationship names, whose relationships name no stored comments part, reads none: every marker names a missing comment, the reference blocking and the range markers warning, and the Microsoft thread parts are not judged. A reference with no comments part stored at all reads the same way; that shape is unprobed. When the package view cannot be read, or no relationship names a stored main document, the checks still fall back to the comments.xml in the main document's folder (#711, #816). Beside a related comments part, the thread parts still fall back by name in its folder when no relationship of their type names one; that reading is unprobed. No reference inside a notes part is judged, matching session 16's clean open, and none inside a comments part either (#852, below): Word does not follow those references. The defined notes are read only from the parts a footnotes / endnotes relationship of the main document or of a reached glossary names, whatever the part is called: session 60 (s60a in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all readings agree) opened clean a used notes part stored as word/notes-foot.xml and word/notes-end.xml and related by its relationship, a shape the name-only reading rejected (#793), and session 61 (s61a/s61b, same evidence, two runs, all readings agree) offered recovery for a body reference whose note sat only in a word/footnotes.xml or word/endnotes.xml no relationship names, while the same part with nothing referencing it opened clean (#797). A notes part the content-types stream types application/octet-stream defines no note, whatever it is called: session 63 (s63c, same evidence, two runs, all readings agree) offered recovery for a body footnote reference whose note sat in word/notes.bin typed octet-stream by its Override, and session 64 (s64b, same evidence, two runs, all readings agree) repeated that refusal for the same row and for word/notes.xml and word/footnotes.xml alike, so the type, not the name, makes Word refuse the part (#829). References read through the main document use only the notes parts its own relationships name: session 63 (s63c, same evidence, two runs, all readings agree) offered recovery for a body footnote reference whose note only a reached glossary's notes part defines, while the main-related matching calibration opened clean (#828); the finding's message then names the glossary (#838). The endnotes twin is unprobed; generalising from footnotes follows #793's footnote-to-endnote reading. References read through a reached glossary keep the union of the main document's and reached glossaries' definitions; that reading is unprobed. Both note checks read references only from the story roots — every main document and reached glossary, found by relationship wherever stored — so a story-named part no relationship reaches is judged for none (#800): session 62 (s62a/s62d, same evidence, two runs, all readings agree) offered recovery for a dangling footnote or endnote reference in a main document stored as word/main.xml, and opened clean an unrelated word/header9.xml holding one. References in a read header or footer are not judged (session 62 s62d/s62e for a header, #806; session 63 s63d for a footer, #826); nor is a reference inside a notes part or a comments part (#852): session 16 (s16a) had opened an endnote reference inside a footnote clean, and sessions 65 and 66 (s65a, s66a, s65d, in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all readings agree) opened clean every nesting they stored — footnote 1 holding a footnote reference to 99 and endnote 1 an endnote reference to 99, in-package or behind the escaping target (s65a), endnote 1 a footnote reference to 99 with no footnotes part anywhere (s66a), and a comment a footnote reference to 99 or to 1 only a reached glossary's notes part defines, the comments part read by the main document or by a glossary (s66a, s65d) — so session 16's special case is subsumed and gone, and a dangling reference in a notes or comments part no longer blocks. A comments part both the main document and a glossary read is refused, but by the shared reach, not the reference (s66a refused it with no note reference at all), which is #850's rule. An endnote reference in a comments part, and a note reference in a glossary's own notes part, are unprobed. Both note checks compare ids by integer value: session 62 (s62b, same evidence, two runs, all readings agree) opened clean a footnote or endnote reference spelled 01 or +1 against a note spelled 1 (#801); any other spelling compares as written, unprobed. When the package view cannot be read, or its officeDocument relationship names no stored main document, the read model and the relationship walks return nothing, so both note checks fall back to the reading they used before #800 — references from the name-based story list but its headers, footers, comments and notes parts, which are not judged (#806, #826, #852), definitions from the parts named footnotes.xml / endnotes.xml wherever stored — as the comment checks fall back to a by-name lookup when no relationship names the main document, and a dangling reference still draws its finding beside the package defect's own (#816); no Word session has shown a note reference in such a package, so the fallback reading is unprobed.

A comment range whose start only an unselected mc:Choice holds, so the effective view holds an unmatched end, opened clean, and word-valid accepts it with warnings (session 63 s63b, same evidence, two runs, all readings agree; the selected-w14 spelling is its clean calibration, #820); the mirror, an end only such a Choice holds, is unprobed. A body footnote reference whose note sits in word/footnotes.xml while the main document's only footnotes relationship is External gets the recovery offer and blocks (session 63 s63c, same evidence). When that relationship instead targets outside the package, the note definitions resolve the target by stopping its .. segments at the package root, as RFC 3986's remove_dot_segments does: session 63 (s63c, same evidence) and session 64 (s64a in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all readings agree) opened word/'s ../../footnotes.xml and ../../../footnotes.xml clean with the note at /footnotes.xml — and the endnotes twin ../../endnotes.xml with its note at /endnotes.xml — while the same escaping target with the note only in word/footnotes.xml got the recovery offer and stays rejected, and the in-package ../footnotes.xml calibration opened clean (#830). A package-absolute target's climb is stopped at the root the same way since #947, so /../footnotes.xml reads the note at /footnotes.xml, as Word does: Word session 74 opened clean, with the note rendered, /../footnotes.xml and /../../footnotes.xml with the note at /footnotes.xml, /../word/footnotes.xml with it in word/footnotes.xml and the endnotes twin /../endnotes.xml, and offered recovery for /../footnotes.xml with the note only in word/footnotes.xml (#982). The file-reference walks stop such a climb at the root too since #984, and every other relationship the read model walks, and the main-document checks, since #986: Word session 77 opened clean, with "Commented [1]: Note" rendered, a body comment reference whose comment sits at /comments.xml, named by ../../comments.xml or /../comments.xml, and offered recovery when the comment sat only in word/comments.xml; session 78 opened clean, with the main document rendered, a main document named from _rels/.rels by ../word/document.xml, ../../word/document.xml or /../word/document.xml. Word-valid rejected each of those clean rows, with comment-marker-missing or word-main-document-part; session 75 had read an escaping styles and header target from the root. Session 81 then offered recovery for what such a climb reaches, for the reason Word refuses its in-package twin, and opened each calibration clean: a read footer at /footer1.xml named by ../../footer1.xml and typed styles, an unclosed docProps/core.xml named by ../docProps/core.xml or /styles.xml named by ../../styles.xml, and a word/footnotes.xml the body and a glossary both use when the glossary's target or its notes target climbs. Word-valid blocks each, with word-part-content-type-mismatch, xml-syntax or word-shared-story-part (#996).

word-shared-story-part rejects a comments, footnotes or endnotes part that the read model lists for both a main-document entry and a glossary entry (wordReadTypedTargets) when each of the two roots is known to use it — a reference in its readable effective XML; the model also keeps a reach whose use it cannot decide, so a root whose XML fails the byte-level checks reports no shared part, and its own XML diagnostics block the run (#858): Word session 66 (s66a in docs/plans/evidence/word-probes-2026-10-24.md, two runs, all four readings agree) offered recovery for a word/comments.xml the main document and a reached glossary both relate and reference — its comment holding nothing but text — while the same part read by the main document alone or by the glossary alone opened clean, and session 67 (s67a, same evidence, same readings) showed the refusal needs both roots to use the part — related by both but referenced by one, or by neither, opens clean — and covers notes parts too: a word/footnotes.xml the glossary relates as ../footnotes.xml and both roots reference is refused, as is its endnotes twin, while the same part referenced by the body only opens clean (#850). The finding cites session 66 for comments and session 67 for footnotes and endnotes, and reports the part through the main document's spelling. Both sessions stored Transitional packages, so in a package with an ISO Strict part the finding keeps its error without the recovery sentence that cites them (#1016). Shared header and footer parts, a part two glossaries share, and a part one root reads under one of these segments and the other under another are unprobed and unjudged.

Named word-math-parse runs and full runs that cannot stage the archive retain the positioned XML diagnostics without repeating word-math-parse for the same malformed part (#500).

The math-run insertion/deletion, shading-color, grid-column-attributes, cell-margin-order, table-properties-required, page-margin-attributes, and textbox-content-namespace checks emit warnings. They remain advisories in Word-valid; strict additionally enforces the applicable schemas. Shading checks cover stories, styles.xml, and numbering.xml; math revision checks support both Transitional and ISO Strict namespaces. Other registered Word findings are errors before full Word-valid policy is applied. Compatibility finding codes and profile-specific severities are described below.

XML syntax is checked from original part bytes with libxml2, without parser recovery. UTF-8 and UTF-16 parts are decoded by BOM/declaration for the DOM layer. Diagnostics use xml-syntax, xml-undeclared-prefix, and xml-encoding, with the part name and available 1-based line/column. Every part whose extension is .xml or .rels as OPC compares part names (any case, word/COMMENTS.XML; escaped, word/comments.%78ml) is checked, by full and partial runs alike (#390). Word-valid blocks on a malformed part by reachability, not by folder (#437): the main document wherever it is stored, its own relationships part — Word 16.115 offers to recover a word/_rels/document.xml.rels that is zero bytes or missing its root end tag, wherever the main document is stored (#514; probe session 15) — and the parts a main-document relationship names: eagerly parsed types always (styles, settings, fontTable, webSettings, numbering, customXml, theme, glossaryDocument), lazy types only when the document uses them (footer and header through their w:*Reference r:id, comments through w:commentReference, footnotes and endnotes through their references). Word 16.115 reads a used header or endnotes part and a theme or glossaryDocument part wherever they are stored — offering repair or refusing the file when one is malformed — and opens a related-but-unused header or endnotes part clean, even under word/ (#516; probe session 15). A glossary document reached through a glossaryDocument relationship is a second story root: Word 16.115 reads its own relationships part — offering repair for one that is unclosed or zero bytes, even when the glossary references nothing — and reads the header, footer, comments, footnotes and endnotes it references (w:headerReference / w:footerReference r:id in its w:docPartBody w:sectPr; w:commentReference, w:footnoteReference or w:endnoteReference in its runs), while a header it relates but does not reference opens clean (#539, #549; probe sessions 16 and 17). References from inside a footnote are not followed — an endnotes part referenced only from there opens clean. A reached glossary's styles, settings, fontTable, webSettings, numbering and theme relationships are eagerly parsed like the main document's — a malformed one gets the repair offer even when the glossary references no story, while a customXml or nested glossaryDocument it relates is observed, not read — malformed, both open clean — and stays unfollowed (#562, #569; probe sessions 19 and 20). When the main document or a glossary fails byte-level XML checks, including an unsupported encoding declaration, lazy usage is unknown and those related parts stay reached in full, partial and staging-fallback runs (#536). Any other part under word/ that a known Office relationship of an unobserved type names also blocks — a type has an Office segment only when it is exactly an Office relationship-type namespace plus one segment, so …/relationships/custom/styles is an unobserved type, not styles (#519); on the content-types stream and the package relationships part _rels/.rels it always blocks. word/ is matched as OPC compares part names, so WORD/document.xml counts, and the stream is matched in any case ([CONTENT_TYPES].XML), as System.IO.Packaging finds it (#392). Word 16.115 offers to recover a package whose _rels/.rels is zero bytes, truncated, or uses an undeclared prefix (#388). An undecodable part (xml-encoding) follows the same policy: Word refuses a word/document.xml that declares an unknown encoding (#397), and one that declares a real encoding libxml2 cannot decode (macintosh, x-mac-cyrillic, IBM866, UTF-7), so the blocking covers those labels too (#410). Word-valid also blocks xml-syntax, xml-encoding, and xml-undeclared-prefix on a properties part named by a core, extended, or custom properties relationship in the package relationships part. Word reads properties through those relationships: Word 16.115 offers recovery for all three defects in the referenced docProps/core.xml, docProps/app.xml, and docProps/custom.xml specimens (#409). The relationship types include the legacy core spelling and Strict extended/custom spellings. A properties relationship inside an mc:Fallback or an understood mc:Choice names the part as a direct child does (#634). Targets resolve from the package root (a leading / is allowed; external targets are ignored), and part names compare by OPC equivalence, including the stored _rels/.rels spelling. Missing or unreadable package relationships yield no properties targets; an unreadable _rels/.rels already blocks Word-valid itself.

Without a properties relationship, these findings stay warnings on docProps/*.xml. Word opens unreferenced malformed docProps/core.xml, docProps/app.xml and docProps/custom.xml clean. The SuperDoc annotations_import_2.docx, whose unreferenced docProps/custom.xml has an undeclared prefix, opens clean too and stays Word-valid. A truncated core part stored as props/meta.xml and named by the core-properties relationship makes Word offer recovery, and its well-formed control opens clean, so the rule follows the relationship, not the name. Full, quick and named runs share this policy; strict verdicts are unchanged. External entities are not loaded or expanded; XSD imports resolve through allowed local schema roots. Validation leaves source bytes unchanged unless you request autoRepair on an unpacked directory; a packed input is repaired only on a discarded staged copy.

Content-type checks, XSD dispatch, and semantic diagnostics find the ZIP stream ASCII case-insensitively and retain its stored name in findings. Stream lookup does not decode percent escapes: %5BContent_Types%5D.xml is an ordinary part, not the content-types stream (#396). Of two stored spellings, [Content_Types].xml is read, otherwise the first in code-unit order, where System.IO.Packaging reads the last in ZIP order; the second stream is reported by opc-equivalent-part-names (#351). Content-type coverage resolves an exact Override before an extension Default. An image covered only by Override needs no matching Default. Part-name comparison is ASCII case-insensitive and decodes unreserved percent escapes. The comparison rule does not make those stored spellings legal: the opc-part-name-escaped-unreserved check diagnoses them separately (#408). Reserved escapes stay distinct (%40 differs from @), while spaces and other characters outside the URI path character set are encoded. Truly uncovered parts still produce ct-uncovered-part.

Compatibility processing uses a validation-only clone. It selects the first mc:Choice whose non-empty Requires names only understood namespaces, otherwise the fallback, including nested alternatives; a mc:Choice without a usable Requires is malformed (mce-malformed) and never selected (#494). Inherited Ignorable, ProcessContent, and MustUnderstand directives apply. Word rules read effectiveXml() with WORD_UNDERSTOOD_NAMESPACES; the content-types stream and .rels parts add their own vocabulary, which the processor applying those streams' rules understands (#493); checks of the compatibility markup itself can read the raw DOM. Unselected branches do not produce structural Word findings. The schema view uses its own understood namespaces. Extension payloads in DrawingML/chart/presentation/spreadsheet ext slots survive for lax schema validation; genuinely empty required extensions still fail their schemas.

word-document-root resolves the main part through the package's officeDocument relationship and requires an effective document root in the Transitional or Strict WordprocessingML namespace. A foreign root makes Word refuse to open the file; an ignored root makes Word silently drop all content. Both are blocking errors in Word-valid and strict, and the check is included in the quick preset.

word-main-document-part reports a package that names no main part: _rels/.rels is missing, or it parses and none of its direct internal officeDocument relationships targets a part in the package (an empty ZIP, a package of only [Content_Types].xml or only core properties). ECMA-376 requires the main document part. Word 16.115 offers to recover an empty archive and a package without word/document.xml (docs/plans/evidence/word-probes-2026-10-24.md, session 3c), and a package whose officeDocument relationship is removed (session 4), so the finding is a blocking error in Word-valid and strict alike, and the check is included in the quick preset; lenient runs no Word rule (#380). An _rels/.rels that does not parse is left to xml-syntax, which Word-valid reports as a blocking error, because Word offers to recover such a package (#388).

The same check also requires exactly one direct internal officeDocument relationship. More than one is a blocking error in Word-valid and strict for DOCX/DOCM, reported on _rels/.rels with every relationship Id. Word 16.115 offers recovery both for distinct well-formed main targets and for a repeated target (#515, Word probes session 15, s15b/s15d). A main-typed part with no relationship still passes Word-valid; strict retains its rels-unreferenced error. Transitional and Strict relationship types count together; the mixed pair is inferred, not observed. TargetMode="External" relationships do not count. Full, quick and named runs use the same finding and severity; lenient runs no Word rule.

A package with no [Content_Types].xml stream at all is ct-missing — Word cannot type a single part and 16.115 offers to recover it (same evidence file), so that finding is Word-blocking too (#435).

opc-relationships-target-mode requires a present TargetMode to be exactly Internal or External in the package relationships part (_rels/.rels), the main document's relationships part and a reached glossary's sidecar, found by relationship wherever the story root is stored, with OPC part-name comparison. Word 16.115 offered recovery for external on a referenced chart or diagramData relationship, external, External (trailing space) or an empty value on an unreferenced hyperlink in the main sidecar, and external on that hyperlink in the package relationships part. The Internal chart and diagramData calibrations and External hyperlinks opened clean. The same hyperlink with external in a referenced header's own sidecar also opened clean; headers keep their verdict. Those rows are from #753, session 55, s55b–s55e in docs/plans/evidence/word-probes-2026-10-24.md. Session 57 (s57f) refused the same unreferenced hyperlink with external in a reached glossary's sidecar, while its External calibration opened clean (#775). Other relationships parts (footnotes, comments and so on) are unshown and keep their verdict. Full word-valid and named runs block the same findings; strict reports errors, and lenient runs no Word rule. An absent TargetMode is not this check's finding. A hyperlink naming a relationship with a misspelled present mode in this scope is reported here alone, without a second word-hyperlink-relationship error for that attribute (#773).

opc-relationships-structure requires every relationships part to have a Relationships root in the package-relationships namespace and only Relationship elements in that namespace as direct children after Word's markup compatibility processing. It reports one finding for a wrong root, or one per surviving offending child, at the stored part name. The root is judged on the processed view in the package relationships part (_rels/.rels, compared by OPC part-name equivalence): Word 16.115 opened a root that markup compatibility resolves to exactly one Relationships element clean, whether an mc:AlternateContent Fallback, a selected mc:Choice or an Ignorable mc:ProcessContent wrapper holds it. It offered recovery for a root resolving to two Relationship elements or to none (the Relationships element in an unselected Choice beside an empty Fallback), which keep the finding because the processor returns the stored view there (#646, #640; Word probes sessions 36 and 38, s36c and s38a). Combinations resolve the same way: Word opened six clean in session 39 (s39b), and again in both runs of session 68 (s68b) — nested Fallback wrappers two and three deep, an unselected Choice before a selected one, the first of two selected Choices, a wrapper carrying mc:MustUnderstand="w14", and a wrapped Relationships element with an ignorable child (#662). A root-wrapped sidecar is unobserved and keeps the stored-root verdict, as does every part under lenient, whose XSD walk judges the stored root. Malformed XML is left to xml-syntax. The check runs under Word-valid and strict, including quick and named relationships selections; explicit lenient keeps its existing checks. Word-valid blocks malformed package relationships and malformed relationships parts under word/, and warns on other sidecars, which have no Word evidence yet. Strict reports errors on all of them; under strict and lenient the XSD walk still judges the stored _rels/.rels root, so a root wrapper the Word rule accepts stays invalid there. Main-part discovery uses the processed package relationships and ignores relationships still nested inside foreign wrappers (#433, #434). opc-relationships-markup-compatibility reports each stored child that processing removes or replaces with only direct Relationship children (including an empty replacement). Word-valid warns; strict keeps an error because stored OPC relationships allow no such children. A foreign child left in a selected branch still gets opc-relationships-structure. Word 16.115 opened each of the four probed shapes of this class cleanly — the footer with its relationship in an unknown-Choice Fallback, in a w14 Choice, and beside an Ignorable foreign child, and the package with its main relationship in a Fallback; other shapes are unobserved. A foreign child left in that Fallback prompted recovery (Word probes session 33, s33d/s33e; #632). The same processed view drives reachability: the parts Word reads (the Word-read parts, the story roots and the properties parts) are found through the relationships markup compatibility selects, so a part reached only through a relationship inside an mc:Fallback or an understood mc:Choice blocks Word-valid exactly as its directly related twin does. Word 16.115 refused a truncated main document behind a Fallback officeDocument relationship (open error), and offered recovery for that Fallback main document's zero-byte relationships part, for a truncated docProps/core.xml behind a Fallback core-properties relationship, and for a footer typed as a header whose relationship sat in the Fallback — each beside its direct-relationship twin, which it refused identically (session 34, s34a/s34b; #634). A relationship nested inside a foreign wrapper that survives processing stays unfollowed (#434). Word also honours markup-compatibility errors in relationships parts: mc:MustUnderstand of a namespace it does not understand on the _rels/.rels root, and an mc:Choice whose Requires prefix is undeclared in word/_rels/document.xml.rels, each prompted recovery (session 34, s34c/s34d; #636). The word-markup-compatibility check reports those findings (see below). Hyperlink and toolbar resolution, duplicate counts and the validators' semantic relationship checks read the processed view too, still taking direct children only, under Word-valid and strict; for the semantic checks that view is DOCX/DOCM only (#635, #643, above). Legacy lenient traversal remains unchanged, and so does the repair inventory's raw counting — it selects which parts count as styles parts through the same relationship walks the style checks use (#745), but its counters still read the stored bytes.

Word 16.115 (build 16.115.26100424, macOS, 2026-10-24) offered recovery for all three minimal package mutations: a foreign sibling with every relationship unchanged (43205d2359aaac36), a foreign wrapper around the main relationship (176e6d8f133812b3), and a root renamed to Foo (1a5c659713cf7f9b). These are SHA-256 prefixes from the recorded Word probes; the unchanged minimal control (0160dfc1db4c5ce1) opened cleanly. A foreign element appended to word/_rels/document.xml.rels got recovery too (e7dae629d3e792a1, session 4), beside a clean control (f67e18fa7a50dae1).

word-hyperlink-relationship requires every w:hyperlink with an r:id in a part Word reads to name a relationship whose Type is exactly the Transitional or the ISO Strict hyperlink relationship URI, with TargetMode External. Word 16.115 accepts exactly those two Types, byte for byte, and offers recovery for http://example.com/hyperlink, …/relationships/custom/hyperlink and …/relationships/HYPERLINK alike; a hyperlink relationship without TargetMode still warns (#614; session 28, chunk s28a, in docs/plans/evidence/word-probes-2026-10-24.md). A present TargetMode other than exactly Internal or External on an otherwise accepted hyperlink relationship is left to opc-relationships-target-mode wherever that check judges the relationships part. This also applies to a named word-hyperlink-relationship run: select opc-relationships-target-mode too to report that shape. An exactly Internal or absent mode, another Type, an unresolved id, and a misspelled mode in a header's sidecar still receive the hyperlink finding (#773). A reached glossary's misspelled mode now belongs to the target-mode check (session 57, s57f; #775). The finding is an error, and Word-blocking, under word-valid and strict; lenient runs no Word rule. In a full word-valid or strict run a hyperlink this rule refuses is reported once, through this rule: the legacy rels-id-mismatch or rels-id-missing copy of its id, or of its id in the other relationships namespace, is handed off as the header/footer rule's is (#1089). The parts Word reads are the read model's, as for word-part-reference-relationship: every main document and every glossary a main-document relationship reaches, wherever stored, and the headers, footers, comments, footnotes and endnotes their references make Word read (#1106). Word session 82 (s82a–s82c, two runs each, all readings agree) offered recovery for a hyperlink whose relationship has the Type …/relationships/HYPERLINK in a main document stored as word/main.xml, a used header stored as word/hdr.xml, a reached glossary and a used footnotes part, which the rule missed or judged by name, and opened the same shape clean in an unrelated word/header9.xml, a glossary no relationship reaches, a header no section references and a footnotes part nothing references, which it rejected. When the package names no stored main document, the read model reads nothing and the rule keeps every part named like a story, headers, footers, comments and notes parts included; that reading is unprobed. The note checks' fallback there (#816) is narrower: it judges references in no header, footer, comments or notes part (#1119).

word-header-footer-relationship requires every w:headerReference and w:footerReference in a story root's w:sectPr to name a relationship whose Type is exactly the Transitional or the ISO Strict header (respectively footer) relationship URI. The story roots are every main document and every glossary a main-document glossaryDocument relationship reaches, and the reference resolves in that part's own relationships part, in either relationship namespace. Word 16.115 offers recovery for every refused shape probed: header→footer and footer→header relationships in the main document's body w:sectPr (whether the target's root was w:hdr or w:ftr, typed with the other story's content type, a parameterized footer type, or styles), both mirrors from a reached glossary's w:docPartBody w:sectPr, a w:headerReference naming the main document's own styles relationship, and every other Type tried in place of the two accepted URIs — http://example.com/header, …/relationships/custom/header, …/relationships/HEADER, the URI padded with spaces, wrapped in tab and newline character references, and the bare header, the footer pair likewise (#613). A matching reference and relationship opens clean even when the target's root element is the other story's, so the check judges the relationship Type alone (#606, #613; sessions 26 and 27, chunks s26a/s26b/s26c and s27a/s27b/s27c, in docs/plans/evidence/word-probes-2026-10-24.md). A reference whose r:id names no Relationship in the part's own relationships part is this check's finding too — when that relationships part exists and parses, as Word 16.115 offered recovery for a body w:footerReference r:id="rId9" beside a kept word/_rels/document.xml.rels holding no rId9 (#619; session 32 in the same evidence file). In a full run, a part with no relationships part keeps rels-missing-sidecar as its only finding for a non-empty r:id; the legacy relationship walk that reports it runs only in full runs, so quick and named runs report nothing for that shape.

word-part-reference-relationship requires every c:chart r:id, dgm:relIds r:dm and o:OLEObject r:id in the selected markup-compatibility view of a part Word reads to be present and non-empty and to name, in that part's own relationships part and in either relationships namespace, a relationship whose Type is exactly the one the element reads: an internal chart relationship for c:chart (an External chart relationship is refused too), an internal diagramData relationship for dgm:relIds r:dm, and a package or oleObject relationship for o:OLEObject — internal or External alike for package, which session 52 opened clean as an External (linked) package relationship (B1), and whose External oleObject spelling was not shown to Word and stays unjudged. Word 16.115 offered recovery for every refused shape probed — an image relationship to a real PNG or to a real chart part, a diagramData, package or chart relationship, an External chart relationship with and without a part, an External hyperlink relationship, the near-miss Type spellings …/relationships/chart/ and …/relationships/Chart naming a real chart, an Id no relationship has, and an empty r:id/r:dm present on the element (dgm:relIds r:dm="" drew an open error on one specimen and the recovery offer on another) — on the minimal Transitional package and as one-attribute mutations of the Word-clean ISO Strict word-clean-strict01.docx, while the matching relationships opened clean (#705, #728; sessions 45, 46 and 52, chunks s45b–s45k, s46a–s46c and s52a–s52d, in docs/plans/evidence/word-probes-2026-10-24.md; r:dm alone naming the real relationship is the s52d confound and opens clean). An id the element does not carry at all is still not judged. The parts judged are the parts Word reads, found by relationship — every main document and reached glossary wherever they are stored, plus the headers and footers a section reference names and the comments, footnotes and endnotes parts the document uses — not the parts whose name looks like a story: a header the main document relates but no w:headerReference names, with its relationships part and without one, and a glossary no relationship reaches, all open clean, while the same chart reference in word/headerFirst.xml or in the main document stored as word/main.xml gets the recovery offer (#726; session 51, chunk s51a, in the same evidence file). Partial named-check runs walk the same read set, so their verdict is the full run's (#381). The target part never matters: the relationship's Type decides, as the header/footer rule's does. r:lo, r:qs and r:cs are deliberately not checked — Word opened them clean naming the image and the chart relationship, with the part's cached diagramDrawing rendered — and neither are a:blip r:embed nor v:imagedata o:relid, which Word opened clean naming a relationship of another type. A c:chart carrying an id attribute in both relationships namespaces — r:id and the ISO Strict rs:id together, whatever their values — cannot be opened: an open error with no recovery offer (session 55, s55b and s55d; #756), while rs:id alone opens clean and draws the chart; the same pair on the other references was not shown to Word and stays unjudged. A part with no relationships part is judged like one whose relationships part lacks the id — session 55 (s55a) saw Word refuse a referenced word/header1.xml with no relationships part holding each of the three references, as it does one whose relationships part lacks the id, and open clean the same header with no r: reference. Session 57 confirms the recovery offer for c:chart r:id="rId1" with no relationships part in used footnotes and comments (s57f footnotes-nosidecar-chart, comments-nosidecar-chart), a reached glossary and the main document (s57g glossary-nosidecar-chart, main-nosidecar-chart); all four text-only calibrations opened clean (s57g, two runs, all readings agree; #775). Endnotes with no relationships part were not shown and keep the same rule. rels-missing-sidecar still reports the absent sidecar where its walk fires — an r:id, r:embed or r:link attribute in a relationships namespace, which the dgm:relIds shape's r:dm, r:lo, r:qs and r:cs are not, so that shape's only finding is this rule's; an unparseable sidecar is skipped for xml-syntax to report. The finding is an error, and Word-blocking, under word-valid and strict; lenient runs no Word rule. In a full word-valid or strict run, a dangling id on a c:chart or an o:OLEObject, in either relationships namespace, is reported once, through this rule: every legacy rels-id-missing copy of that Id, or of the element's id in the other relationships namespace (#1074, #1079), on the same element in the same part is handed off — including the unselected twin's copy of the two an element with an mc:Choice and an mc:Fallback twin yields, which the raw-part walk sees twice and this rule once (#727; the key is the same element name, namespace and local name, with the same Id in the same part, not the element instance) — as the header/footer rule's is (#612, #619); lenient keeps the legacy findings alone.

Under word-valid for DOCX/DOCM, rels-missing-sidecar blocks only when the part is a story root, a member of the run's Word-read set, or a part that a known Office relationship of an unobserved type names wherever it is stored, and a counted relationship reference survives in its processed markup-compatibility view. The Word-read set holds such parts only under word/; Word was never shown one outside it with a reference and no sidecar, so an unobserved type's reach keeps the blocking decision it had before #638. The chart type is the one exception: session 36 observed that Word 16.115 never loads a chart that no drawing references — a chart part related from the main document's sidecar (…/relationships/chart) but referenced by nothing opened clean, stored under word/ and outside it — so a part every reaching relationship names through the chart type blocks only when a Word-read part's effective XML names one of those relationship Ids (c:chart/@r:id in a drawing, read in the processed view: a drawing only inside an unselected mc:Choice opened clean and one inside a selected Choice got the recovery offer, session 37 s37b). Session 44 opened clean every part an image relationship reaches, under word/ and outside it, unreferenced or named by a picture's a:blip r:embed, and session 46 (s46d) opened the same chart clean when a VML picture's v:imagedata r:id or a linked picture's a:blip r:link names the relationship; session 52 (E1, E2) opened the same no-sidecar chart clean again when a w:name r:id names the reach and when a VML picture's v:imagedata r:id inside a selected mc:Fallback does: an image reach never loads the part, so rels-missing-sidecar always warns for it, however its Id is named (#706; #701's extension is replaced by #705's rule, which blocks the c:chart r:id, dgm:relIds and o:OLEObject r:id shapes naming an image relationship for the reference's Type). Session 44 also opened clean an unreferenced diagramData, oleObject or Microsoft hdphoto reach, so those types join the rule; session 45 (S1) opened clean a picture's a:blip r:embed naming one of them — session 52 (D1) opened the same clean hdphoto reach again, a chart part with no sidecar an hdphoto relationship a picture's a:blip r:embed names — so every other attribute in a relationships namespace (or VML's o:relid) naming the relationship counts as their reference, and a dgm:relIds r:dm keeps blocking (#647; #702). A part any other relationship type also reaches keeps the reach-based verdict (#645; chunks s36b/s36d, s37b and s44a–s44c in the same evidence file). Session 39 (s39a, s39c) showed Word the rule beyond the main document's sidecar: an unused chart relationship from a referenced header's sidecar or from a reached glossary's footnotes part or document, two unused chart relationships to one part, a relationship targeting an OPC-equivalent spelling of the chart's name, and a drawing only in an mc:Fallback Word does not select all opened clean, and each shape's referenced twin got the recovery offer (#661); session 68 (s68a, s68c) repeated every outcome in two runs, with a clean control opened first and last in each chunk. Session 35, chunks s35a/s35b, observed clean opens for ids in unreferenced word/xtra.xml (both relationships namespaces), unreferenced docProps/app.xml, an unreached glossary, on w:name in reached styles, and inside an unselected mc:Choice (both relationships namespaces). These findings now warn under word-valid; strict and lenient output is unchanged (#638). A footer reference's Strict relationships id inside w:sectPrChange still blocks: Word offered recovery for it in s35b.

The processed-reference test uses declaration sets generated from all bundled XSDs by scripts/generate-relationship-reference-elements.ts, with a test that regenerates them in memory to check for drift. The sets follow global and local element declarations, element references, type extension and restriction bases, and attribute groups. They union declarations sharing an element name and map Transitional namespaces to their ISO Strict counterparts. A non-empty id, embed or link in either relationships namespace stops counting only when the schemas declare the element and no declaration gives it that attribute. w:name declares only w:val, so its added relationship id does not qualify. Undeclared elements and namespaces, including extension namespaces such as asvg, keep counting; an unresolved type also cannot prove absence. Charts, ambiguous names with a reference-bearing declaration and unobserved extensions retain the previous blocking decision. This conservative classification is not fresh Word evidence for those shapes. It loads or compiles no XSD during word-valid validation.

A story root — the main document or a reached glossary — keeps precedence over the header/footer basename tolerance, under any .xml name, even word/header1.xml: session 33 observed recovery for both renamed roots whose .rels was gone beside their story reference, while the renamed controls opened clean (#625; session 33, chunk s33b, in the same evidence file). A genuine word/header1.xml or word/footer1.xml bearing relationship references with no sidecar keeps its word-valid warning — Word's own regenerated output opens clean carrying that finding. A story root stored without a .xml suffix (word/document.bin) is not checked as XML, so it gets no rels-missing-sidecar at all (#620). An empty or missing id in both relationships namespaces is this check's finding regardless of whether the sidecar exists or parses (#623). Session 33 observed Word 16.115 offer recovery for r:id="" on a header or footer reference in the main document's body w:sectPr (s33a/s33c), and report an error opening the file when either reference has no id (s33c/s33e). An id in a third namespace counts as missing, even when it names a real footer relationship (s33d). An ISO Strict relationships id naming a real footer relationship on a Transitional reference opens clean and renders the footer (s33c, #631 refuted). The check applies to the same placements and story roots as before; session 33 observed these empty/missing shapes only in the main document's body w:sectPr, while sessions 26/27 established the footing for the other placements and reached glossaries. Set a non-empty id in either relationships namespace naming a Relationship of the required Type, adding one with that Id and Type when the part has none. Strict also keeps the XSD error for a missing r:id. The finding is an error under strict and Word-blocking under word-valid for DOCX/DOCM; lenient runs no Word rule. In a full word-valid or strict run neither rels-id-mismatch nor rels-id-missing repeats an element this check reported — the run drops every copy of the same Id, and of the element's id in the other relationships namespace (#1079), on the same element name (namespace URI and local name, not the element instance) in the same part for a non-empty id, including the unselected twin's copy of the two copies an element with an mc:Choice and an mc:Fallback twin yields (#727), pairing the findings by the element identity both checks register on the issue (part path, namespace URI, local name and raw r:id; the namespace the r:id was read in does not distinguish an element name's other spelling), never by message text — while validateAllRelationshipIds() called on its own still reports every rels-id-mismatch and rels-id-missing (#612, #616, #619). Empty/missing-id findings register no such identity, because the legacy walk reads only truthy ids and reports neither shape. A glossary no main-document relationship reaches is not a story root, and Word does not read it, so neither this check nor word-hyperlink-relationship judges it (#1106): the rels-id-mismatch or rels-id-missing of a header or footer reference or a w:hyperlink there stays, an error under strict and a warning under word-valid, and lenient keeps every one of them (#1102). Full, quick and named Word-valid runs judge the findings they report alike (#381).

rels-missing-sidecar reports a part that uses a non-empty id, embed or link attribute in either the Transitional or ISO Strict relationships namespace but has no .rels sidecar. Session 33 observed Word 16.115 offer recovery for a Strict document's body w:footerReference with a Strict r:id="rId9" and no sidecar (s33a), while the same Strict package without the reference opened clean (s33c) (#624; docs/plans/evidence/word-probes-2026-10-24.md). A missing sidecar alone does not trigger this finding. Counting Strict embed and link attributes extends the existing Transitional rule on the same footing; those shapes were not observed in Word in this session.

word-markup-compatibility reports processor errors for the XML parts under word/ and for every relationships part, at the stored part name (#636). The judged names are compared as OPC compares part names — canonical, ASCII case folded, escapes of unreserved characters decoded — so word/FOO.XML and word/foo.%78ml (an escaped x) are judged as word/foo.xml is, on both the check and the named runs' root-Ignorable pairing (#639). An escaped spelling keeps its own opc-part-name-escaped-unreserved rejection, so no verdict changes; the escaped shapes themselves were not shown to Word. Word 16.115 applies markup compatibility to relationships parts before judging their children (session 33, s33d/s33e) and honours its errors there: mc:MustUnderstand of a namespace it does not understand on the _rels/.rels root (session 34, s34c) and an mc:Choice whose Requires prefix is undeclared in word/_rels/document.xml.rels (session 34, s34d) each prompted recovery. Word-valid therefore blocks mce-malformed and mce-must-understand in _rels/.rels and in the sidecar of the main document and of every reached glossary, found by relationship even when the main document is renamed (sessions 34 and 36, #644). Sidecars that are not a story root's — stray, styles, footer, settings, numbering and header — opened clean with a malformed sibling or root mc:MustUnderstand (sessions 36 and 37), so Word-valid demotes them to warnings, as it does in the remaining unobserved relationships parts; strict keeps the error. Lenient runs no Word rule. Full, quick and named runs report the same findings and severities. If unwrapping a part's root through AlternateContent or ProcessContent would leave zero or multiple element children, the processor reports error-severity mce-malformed and returns the part unprocessed (#640). One element continues to be unwrapped. The source is unchanged. The stored-root rules keep their verdict, including blocking opc-relationships-structure for a wrong relationships root and word-document-root for a wrong main document root — except the package relationships part's root, which opc-relationships-structure judges on the processed view (#646). Word 16.115 offered recovery for a _rels/.rels root AlternateContent whose Fallback holds two Relationship elements (session 36, s36c) and for one that unwraps to none, its Relationships element in an unselected Choice beside an empty Fallback (session 38, s38a); both keep their blocking opc-relationships-structure. Roots that unwrap to the Relationships element opened clean and pass Word-valid (#646). Other zero- and multiple-element root shapes are unobserved. Word-valid and strict return findings instead of throwing or reporting check-crashed; full, quick and named runs agree on their shared checks. Lenient retains its prior findings, including its legacy XSD diagnostic for multiple roots and its empty-view diagnostic for zero roots.

Root validation, the processor, and the schema walk use these stable codes:

Code Meaning and profile behavior
word-document-root The main part has a foreign or missing effective root: Word refuses to open it or silently drops all content. Blocking in Word-valid and strict.
mce-malformed Invalid compatibility structure, including AlternateContent without a Choice, AlternateContent holding non-whitespace character data directly inside it (Word offered recovery for every position sessions 65 and 67 stored, in a used comments part's root and a body run; whitespace only, and text inside a Choice or the Fallback, opened clean; other parts and relationships parts are unprobed, #851; the message cites those sessions only for a DOCX/DOCM package with no ISO Strict part, and never for PPTX or XLSX, which Word was not shown, #1016), or root unwrapping that leaves zero or multiple elements. Blocking in Word-valid and strict in the XML parts under word/, except in Word-valid a part Word does not read (one no Relationship element names, in any namespace, #1135, or a header, footer, comments or notes part that only a story root relates and the root does not use, a comments or notes part only a header or footer references counting as unused, #1155), where Word opened an undeclared Requires clean (session 83 s83d in an unnamed part, #1116; session 84 s84a in an unused header, #1125; the unused footer, comments and notes kinds, and a comments or notes part only a header or footer references, were shown an unknown mc:MustUnderstand, which sessions 85 and 86 opened clean, #1155, #1164; session 86 s86c opened clean an undeclared Requires in a footnotes part only a read header references); in relationships parts in _rels/.rels and the sidecar of the main document or any reached glossary, found by relationship even when renamed (sessions 34 and 36, #644). Malformed siblings in stray, styles and footer (session 36) and settings, numbering and header (session 37) sidecars opened clean; other sidecars are unobserved and keep their Word-valid warning. Strict keeps all errors.
mce-must-understand A declared required namespace is not understood by the selected view. Blocking in Word-valid and strict in the XML parts under word/, except in Word-valid a part Word does not read (one no Relationship element names, in any namespace, #1135, or a header, footer, comments or notes part that only a story root relates and the root does not use, a comments or notes part only a header or footer references counting as unused, #1155), where Word opened an unknown mc:MustUnderstand clean (session 84 s84a in an unnamed part and an unused header, #1116, #1125; session 85 s85a and s85b in an unused footer, comments or notes part and in a comments or notes part only a header references, #1155; session 86 s86a and s86b in one only a footer or a reached glossary's used header references, and s86c in an unused header related through a selected mc:Fallback or an understood mc:Choice, #1158, #1164); in relationships parts in _rels/.rels and the sidecar of the main document or any reached glossary, found by relationship even when renamed (sessions 34 and 36, #644). Root MustUnderstand in stray (session 36), styles and footer (session 37) sidecars opened clean; other sidecars are unobserved and keep their Word-valid warning. Strict keeps all errors. Word and schema capabilities can differ.
mce-unbound-prefix A directive names an unbound prefix. Error in lenient/strict schema processing; warning in full Word-valid. A repeated root Ignorable finding is retained once as ignorable-undeclared.
mce-invalid-token An invalid ProcessContent QName. Error in lenient/strict schema processing; warning in full Word-valid.
mce-root-ignored An ignorable root disappears from the schema view. Informational for non-main parts; not emitted by the full Word-valid path.
mce-main-root-ignored The required main document root disappears from the schema view. Error in lenient/strict; the Word view reports word-document-root for a missing main root.
mce-extension-not-ignorable Office extension attributes lack an Ignorable declaration and are removed from the schema view. Warning from schema preprocessing; not emitted by the full Word-valid path.

Under Word-valid and strict, full DOCX/DOCM runs retain root Ignorable findings once as ignorable-undeclared even when staging fails. Named Word-valid runs share that identity, severity and count. Descendant-only findings keep mce-unbound-prefix (#499).

Strict bundles both Transitional and ISO Strict schema trees, with local Dublin Core imports. Strict does not subtract errors shared with original; only lenient retains that XSD behavior. No profile suppresses schema messages by their text: IGNORED_VALIDATION_ERRORS is empty (#1209). Strict preserves each schema error occurrence and available engine line. If preprocessing changes the view, the message marks the line as referring to the preprocessed view. Schema-load failures remain blocking xsd-schema-load-failed issues; arbitrary unmapped custom XML is outside schema eligibility.

Optional real-Word verification is independent of portable checks:

const observed = await validate("./contract.docx", { verifyWord: true });
console.log(observed.metadata?.wordVerification);
docx-validate --verify-word --json contract.docx

The built-in verifier requires macOS, Microsoft Word, and Accessibility automation permission for the host terminal. It opens a uniquely named temporary copy of the exact input bytes, without repair. The matching document must remain open and dialog-free for the default 3-second observation period. The default launch, observation, and cleanup budget is 45 seconds, with up to 2 seconds reserved for closing the document. Cleanup closes that copy without saving and never quits or force-kills Word. Portable validation and Word verification both contribute to valid; requesting verification does not make a portable failure pass.

wordVerification records requested, satisfied, outcome, fileSha256, platform, observedAt, observationMs, details, and available wordVersion and wordBuild. Outcomes are clean-open, repair-warning, open-error, crash, password-prompt, unknown-dialog, timeout, or unavailable. Only clean-open satisfies verification. Unavailable or inconclusive results make an explicitly requested run invalid even when issues contains no errors. An unpacked directory has no packed bytes to verify and returns unavailable; other platforms or missing Word also return unavailable. crash requires a Microsoft Word crash report written after this run's open; a Word process that vanishes without one (an external kill or quit) is unavailable (#305). The Word version and availability calls run inside the same budget as the rest of the run, so a hanging call ends as timeout instead of outliving it (#320). A verifier that throws is reported as unavailable with its message; validate does not reject because of it (#333).

Before opening, the verifier observes Word once. If a dialog is already showing, it returns unavailable without opening or closing anything: that dialog cannot be attributed to this file. Verification needs exclusive use of Word: runs must not overlap and Word must not be in use, because a dialog that appears after the pre-open observation is attributed to this file (#331). macOS cleanup dismisses the Word dialog showing at close, preferring Cancel, No, then OK; under exclusive use that is the dialog this run's open raised. When the open itself overran its budget, cleanup first lets it settle within the rest of the budget, so a late open never lands after the close (#328). It does not relaunch Word after a crash. The source implementation (src/lib/word-verifier.ts) supports injected transport and clock dependencies, plus a wallNow source: observedAt and the crash-report open instant are wall time, independent of the deadline clock (#329); public validation options accept a wordVerifier callback for deterministic tests or a custom verifier.

Compose individual checks with the validator classes

import { DOCXSchemaValidator, defaultSchemasDir } from "docx-validate";

const v = new DOCXSchemaValidator({
    unpackedDir: "./unpacked",
    schemasDir: defaultSchemasDir(), // override if you bundle your own XSDs
    profile: "lenient",
});

const xsdResult = await v.validateAgainstXsd();
const idResult = await v.validateUniqueIds();
const relResult = await v.validateAllRelationshipIds();

PPTXSchemaValidator, BaseSchemaValidator, and RedliningValidator follow the same shape — see src/scripts/office/validators/ for the full method list.

Side helpers

import {
    pack, // repack an unpacked dir into .docx/.pptx/.xlsx
    unpack, // unzip + pretty-print + optional run-merging
    addComment, // append a w:comment to an unpacked DOCX
    mergeRuns, // collapse adjacent w:r runs with identical formatting
    simplifyRedlines, // collapse adjacent same-author tracked changes
} from "docx-validate";

// e.g. unpack → mutate → repack:
await unpack("./contract.docx", "./unpacked");
// (your edits go here)
await pack("./unpacked", "./contract.modified.docx");

pack refuses symbolic links to files or directories inside the input directory, returning { ok: false, message } with the link's relative path using / separators and writing no output file (#1199).

unpack checks every entry before it writes one (#675). It refuses, writing nothing, an entry whose stored name leaves the output directory, even one JSZip would load inside it (../evil.txt loads as evil.txt; #676), and an entry whose destination runs through a symbolic link already in the output directory. The output directory itself may be a link. When a read or write fails part way, unpack removes the files and folders it created, including the output directory when it created it; a file it overwrote keeps the new bytes. Messages print control and bidirectional formatting characters in entry names as \u{…} escapes.

These helpers, and validate()'s auto-repair, keep every XML part in the encoding it is stored in (#866). The byte order mark, then the XML declaration, decide it: a UTF-16 part is written back as UTF-16 in its own byte order, with its byte order mark if it had one. An encoding pseudo-attribute a helper writes or rewrites in the declaration names UTF-16 beside a mark, and UTF-16LE or UTF-16BE without one, since XML requires the mark of a part declared plain UTF-16 (#878). A declaration with no encoding pseudo-attribute keeps none, as XML then reads the encoding from the mark, or UTF-8 when there is none (#888). A UTF-8 part is written without a byte order mark, as before. A part whose declaration names an encoding this package cannot decode (windows-1252, macintosh, …) is left byte-for-byte untouched, and addComment returns an error naming it instead of editing it, changing nothing in the tree. The auto-repair writers then neither count nor report a repair for such a part: writeXmlPart returns whether it wrote (#880), and a part's repairs reach the repairs count, and its verbose Repaired: lines, only after a write — a refused write never reached disk. Three repairs rename one value in several parts: repairCommentThreading, repairParaId and repairDurableId's hex rewrite. Each leaves every part it would write untouched when one cannot be decoded (#889) or written (#909); repairDurableId's independent numbering.xml renumbering still runs when its hex rewrite is refused (#911). A write that fails after that check stops the repair, which counts only the parts already written; before, it rejected repair() and validate() with the other part already changed. So does a write writeXmlPart refuses after the check; before, the repair went on writing the later parts with the new value, splitting the rename (#920). In repairDurableId a failed write of a numbering.xml carrying only decimal durableIds does not stop the hex rewrite, and its decimal renumbering runs whether the hex rewrite is refused by the check or stopped by a failed write; before, such a failed write between two hex parts split the rename (#925). A numbering.xml that also carries a durableId the hex rewrite renames is a hex part like any other: its failed write stops the remaining hex renames (#942). No other repair renames a value across parts.

For a document's first comment, addComment relates the four comments parts from the relationships part of the main document _rels/.rels names (#906). The named main document is found as the read model finds it — \ read as /, the query and fragment dropped, the stored part matched under OPC equivalence — and the comments are wired through the stored part's own name: word/_rels/document.xml.rels when there is no _rels/.rels or it names a main document stored as exactly word/document.xml, word/_rels/main.xml.rels for a main document stored as word/main.xml, and main/_rels/doc.xml.rels for one stored as main/doc.xml; before, a target with a query or fragment was refused, and so was an escaped spelling of any main document but word/document.xml (#919). The relationships part itself is the stored part equivalent to <main folder>/_rels/<main basename>.rels when one is stored, and that derived name when none is — found under OPC equivalence like the main document, whatever spelling either has; before, a main document stored as word/Document.xml or word/docum%65nt.xml was wired through word/_rels/document.xml.rels, a second, OPC-equivalent part beside its own sidecar, and a sidecar stored under another spelling got a second one beside it (#946). Each target is relative to the main document's folder (../word/comments.xml from main/). The stored names are the ones the package view reads, so a main document stored as a symbolic link, or under one, that resolves inside the unpacked tree is found under the link's own name, and one that resolves outside it, or is broken, is not; before, every link was dropped (#938). A directory that cannot be listed hides only the parts stored under it; before, it made addComment reject with the file system's error (#961). An internal comments relationship, of the Transitional or the Strict type, whose target names word/comments.xml under OPC part-name equivalence once resolved as the read model resolves it (\ read as /, the query and fragment dropped, a URI scheme naming no part), relative or from the package root, counts as already there; an External relationship, or one of another type, does not, and the four relationships are added beside it; before, it stopped them being added, so the comment's markers named a comment Word does not read (#917), and a target spelled with a query, a fragment or a backslash was not counted, so a second set of relationships was added beside it (#928). A package-absolute target's dot segments now resolve as a relative target's do; before, /word/../word/main.xml named no part, so the read model found no main document and addComment refused it or added a second set of comment relationships (#947). Word read the package-absolute /word//main.xml from the root (session 70), and stopped a package-absolute notes target's climb at the root as #947 does, reading /../word/footnotes.xml as word/footnotes.xml (session 74, #982); a package-absolute main-document target with dot segments is unprobed. Either kind also collapses an empty segment, as path.posix.join does, so word//main.xml and /word//main.xml name word/main.xml, as Word read them (session 70); a final . or .. segment leaves a folder, as RFC 3986 does, so word/main.xml/. and word/main.xml/x/.. name no part: the checks find no main document or notes part there, matching Word's recovery offer (session 70), and addComment refuses such a main-document target; before, they named word/main.xml (#962). addComment also refuses a main-document target naming the package root, such as . or word/.., which names no part even beside a part stored as %2e, as the read model reads it; before, it wired the comment through that %2e part while validate found no main document (#977). The relationships parts are read and written in the package-relationships namespace, whatever prefix they bind it to: a prefixed _rels/.rels names its main document as an unprefixed one does, the new relationships are created under the root's prefix, and their ids are numbered past every rId<N> id the part holds, whatever its namespace, and however many digits its number runs to; before, a prefixed _rels/.rels was refused as naming no officeDocument relationship, and a prefixed main-document relationships part got unnamespaced relationships with a repeated id (#918), and a part holding an rId<N> id with N at 2^53 or past it got four relationships sharing one id (#940). Both relationships parts are read as the checks read them, after markup compatibility selects a branch: a relationship a selected mc:Fallback or an understood mc:Choice holds counts, one only an unselected branch holds does not; before, a selected branch's relationship was not read, so a package whose officeDocument relationship sat in one was refused and a comments relationship in one got a second set beside it (#932), and before #918 an unselected branch's relationship counted (#927). When the main document has no relationships part, addComment creates one; before, it reported success and left the comments parts related to nothing (#895). It types the created part in [Content_Types].xml as a relationships part under its own name: it adds an Override when nothing covers the part or a .rels Default names another type, which it leaves alone, and corrects an Override that names the part with another type, under any OPC-equivalent spelling; before, a .rels Default of another type left the part mistyped (#901). It reads and writes [Content_Types].xml in its namespace, whatever prefix the stream binds it to (<ct:Types xmlns:ct="…">): each Override it adds is created in that namespace with the prefix the root uses, and an Override in another namespace is not read as typing a part; before, a prefixed stream got Overrides in no namespace, which left the comments parts typed application/xml and the created relationships part mistyped (#923). Each comment part is decided on its own, read as the checks read the stream: an Override already naming it under OPC equivalence (/WORD/comments.xml, an escaped spelling) counts, with whatever type, and is neither retyped nor answered with a second Override, so a stream that types only some of the four gains only the rest; before, the comparison was literal and one check on /word/comments.xml decided all four, so a stream naming the comments part under another spelling, or typing some of the other three but not it, got duplicate Overrides, and one typing it but not the others got no Override for them (#937).

A first comment is refused with an error naming the part, changing nothing, when the tree has no [Content_Types].xml, or when _rels/.rels cannot be decoded or parsed, names no officeDocument relationship that is not External, names a target outside the package (a URI) or one whose path is empty (no Target, an empty Target, or only a query or fragment), each with its own message (#1002), or names a main document that is not stored — the conventional word/document.xml included; before, a missing word/document.xml was written to (#919). A target climbing past the package root names the part its climb stops at, as validate reads it, so ../word/document.xml or /../word/document.xml wires the stored word/document.xml, which Word opened as the main document (session 78), and a comments target such as word/'s ../../word/comments.xml counts as relating word/comments.xml (#986). A later comment reads no _rels/.rels. A first or later comment is refused the same way when a part it would edit is not well-formed XML (#905); before, the call wrote some parts and then threw, which could leave relationships naming comment parts that do not exist. A part it would write whose path runs through, or ends at, a symbolic link is refused before that part is read and before any write, as unpack and the validators' write-back refuse it (#425, #675); before, the write followed the link, out of the unpacked tree when it pointed outside (#939). The comment parts themselves are read and appended to by namespace, whatever prefix they bind, and a first or later comment is refused before any write when a stored comment part's document element is not its expected root element; before, a part bound to another prefix made the call throw part way through, a used id was not refused, and a reply could not find its parent (#930), and a root nested under another document element was appended to, the call reporting success (#953).

Drive the CLIs programmatically

If you want the CLI behaviour (commander parsing, exit codes, the same status messages) without spawning a subprocess:

import { runValidateFromArgv, buildValidateCommand } from "docx-validate";

const exit = await runValidateFromArgv(["./contract.docx", "--profile", "strict"]);
process.exit(exit);

Each script ships its own build*Command / run*FromArgv pair: runValidateFromArgv, runPackFromArgv, runUnpackFromArgv, runCommentFromArgv (and corresponding build*Command factories that return the underlying commander Command).

Not in the package surface: LibreOffice helpers

acceptChanges() (LibreOffice macro for accepting tracked changes) and the underlying runSoffice() / getSofficeEnv() / ensureShim() helpers live in src/scripts/accept-changes.ts and src/scripts/office/soffice.ts but are intentionally not re-exported from the package barrel. They use an LD_PRELOAD shim to make soffice boot in sandboxed Linux VMs, and that pattern triggers supply-chain malware heuristics in automated scanners (it genuinely matches the shape, even though the use is benign — see SECURITY.md).

To use those helpers, run the scripts directly from a checkout (pnpm exec tsx src/scripts/accept-changes.ts <input> <output>) rather than importing from the published package.

Result shape — ValidationResult

import type { ValidationMetadata } from "docx-validate";

interface ValidationIssue {
    severity: "error" | "warning" | "info";
    message: string;
    path?: string; // file path inside the unpacked dir, when applicable
    code?: string; // stable string ID; safe to switch on
    line?: number; // 1-based, when available
    column?: number; // 1-based, when available
}

interface ValidationResult {
    valid: boolean; // requested checks and any requested Word verification passed
    issues: ValidationIssue[];
    metadata?: ValidationMetadata;
}

ValidationMetadata has optional profile, executedChecks, omittedChecks, schemaCompleted, and wordVerification fields. executedChecks reports the registry ids a complete run executed (#319): lenient lists opc-equivalent-part-names, opc-part-name-escaped-unreserved, opc-part-name-escaped-slash, opc-part-name-word-refused, and opc-item-name-refused first, then xml-syntax and the rest; Word-valid and strict add the ZIP-envelope checks for a packed file and every Word rule whose prerequisites passed. A partial run lists the selected checks that ran, so an archive-only check is not listed for an unpacked directory. A missing target (ENOENT or ENOTDIR) throws Error: <target> does not exist; other access failures throw Error: Cannot access <target>: <system error>. The CLI prints that error on one line and exits 1 (#574). An archive that does not open fails with package-open-failed in every mode, and only its envelope checks are listed (#332). A packed file that cannot be read at all — a mode-000 file fs.stat still answers for — fails the same way, with no check listed (#422). Its message says Cannot read followed by the system error; the encrypted, corrupted or truncated archive explanation is reserved for JSZip refusals (#547). One that opens but whose package view cannot be read — a stored item name that places no part — judged on the central-directory name, never the local header's or the unicode-path field's (#445) — or an unreadable entry — fails with package-extract-failed, as the full run's extraction reports it (#413). Each stored item name that places no part is also its own error-severity opc-item-name-refused finding, and a full or partial run of a packed file still judges the archive's other entries. It leaves the refused central-directory records out, so an entry a refused .. name hid in the package view is judged by its own bytes. A full run stages those entries and validates them as it would a clean package, the format validator's own rules and the schema walk included, then adds the package-extract-failed error; xsdWalk and metadata.schemaCompleted describe that walk. When the host cannot stage those entries either, because its file system folds two of their names into one (APFS folds Å and å), the run reports one package-extract-failed, naming the refused name, beside what the archive's own checks find in them (#874). A partial run judges them with its selected checks only. A ZIP64 archive is judged the same way when its end records can move with the shrunk directory. Neither judges anything more when the remaining names still collide (#413), when a ZIP64 end record cannot move (it holds extensible data or declares less than its fixed 44 bytes, or is not adjacent to its locator; #875), or when bytes sit between a non-ZIP64 central directory and its end record, and a directory input judges only its names (#629). Directory inputs apply the same stored-name refusal before either full or partial validation: literal backslash names with empty or dot-only segments are refused with the same code and message (#424). Direct DOCXSchemaValidator.validate() and PPTXSchemaValidator.validate() calls also return an error-severity package-extract-failed for such refused directory names, with the same message as validate(); programming errors still propagate (#548). They also apply the same directory walk refusal for broken symbolic links, non-regular files, unlistable directories and symbolic-link loops under every profile, before running any part-content check (#557). For either refusal, direct validators include the same stored-name findings, in the same order and at the same profile-dependent severities, as validate() for the same path string, and list the stored-name checks they ran in executedChecks, as validate()'s metadata.executedChecks does (#572). Its stored entry names and archive bytes are still read. Selected opc-equivalent-part-names, opc-part-name-escaped-unreserved, opc-part-name-escaped-slash, opc-part-name-word-refused, and opc-item-name-refused checks and selected envelope checks (such as zip-bytes-after-eocd) run and report what the full run reports. Every other selected check needs the package's parts: it runs on the entries a refused item name leaves, as above, and is otherwise listed in omittedChecks (#420, #629). It is not a per-method execution trace. In the source scheduler, use runChecks().outcomes for individual passed / failed / skipped / unavailable statuses. omittedChecks names ids left out by selection and, when the package view cannot be read, selected ids that could not run. schemaCompleted records completion of the eligible schema walk without schema-load failures; it is separate from whether schema findings made the document invalid. wordVerification is present only when requested.

Full validation accepts unpacked directories, including POSIX files whose stored names contain literal backslashes (for example word\document.xml). Such inputs are validated in a temporary copy under package part names (word/document.xml), using the same duplicate selection and extraction guards as packed inputs. Directories whose tree holds a symbolic link — a stored entry or a directory segment — are staged the same way: the stored bytes are read through the link into the package view, while the write-back never follows it (#425). A link whose target resolves outside the input root is not followed at all: the walk refuses it at the link's stored name (a symbolic link outside the input directory), so foreign files never become package parts, content outside the root never decides the verdict, and a link to an ancestor of the root is named at the link itself rather than under a path inside it (#576). A link that resolves inside the root keeps the behaviour above: the loop detection of #546 and the write-back refusal of #544. The bound fails closed: an input root realpath(3) cannot resolve is refused as an unreadable directory, and a link it cannot resolve is refused (a symbolic link the walk cannot resolve) rather than followed. A stored destination is refused when any path segment between the input root and that destination is a symbolic link. When several stored paths name one physical file and any of them reaches it through a link, the whole group is refused, so the link's target stays untouched and one physical file is never written twice (#544). A stored regular file whose link count is greater than one is also refused, because a hard-link alias may name a file outside the input directory (#545). Directories with ordinary forward-slash names and no links are validated directly. Validation leaves source files unchanged unless autoRepair: true / --auto-repair is used. For a staged directory, repairs write changed bytes back to each original stored file name and delete originals of parts removed by a repair; untouched files and dropped duplicate entries remain byte-identical. No forward-slash replacement files are added to the input directory. Quick and named-check runs continue to read the directory's package view directly (#374). A packed file (.docx/.docm/.pptx) is different: auto-repair runs on a temporary staged copy that is discarded, so the source file's bytes never change. The result then carries an auto-repair-staged-copy info finding — and the CLI prints the same sentence on its own line, after Auto-repaired N issue(s) when anything was repaired — disclosing that the repairs and the repair plan describe the staged copy (#649). It appears only when a repair ran: a packed .xlsx, which has no validators, and a packed file that cannot be extracted get none (#666).

A stored file that cannot be read fails the run with package-extract-failed, naming the stored file — the package view cannot be staged. This includes a stored entry that can never yield part bytes at all: a broken symbolic link, or a non-regular file such as a FIFO, which is reported by its stored name instead of silently vanishing from the package (#426); a FIFO is never opened, so it cannot hang the run. A subdirectory the walk cannot list fails the same way, naming the directory — every stored file under it would otherwise vanish with it, leaving the run to judge the parts it can still see (#543; the input directory itself is named .). A directory is no stored file entry, so it contributes no name to the package view: it is named only in the package-extract-failed detail, and the stored-name checks never judge a name no package holds (#573). A symlink loop — a directory link whose target is already on the path into it, or a link chain that never resolves — is reported as a symbolic link loop at the link's stored name (word/self), not as a broken link under a synthetic multi-level path (#546). A link out of the input root fails the same way, naming the link's own stored name (#576). When several entries are unreadable, the message names them one clause each, in code-unit order — the order the file list uses — so the same input yields the same message on every host, whatever the host's locale collation is (#575). Full, quick and named-check runs report the same failure. (Under ordinary forward-slash names the direct path reads parts lazily, so the same file yields check-crashed warnings instead.) When a write-back cannot reach its stored file — a read-only file, one removed mid-run, or a stored file that is a symbolic link, whose root-relative path contains a symbolic-link segment, has more than one hard link, or resolves outside the input directory, whose target the write-back never touches — the run reports a repair-write-back-failed error naming the part and keeps writing back the remaining repairs; the stored file is left untouched. That error is profile-independent: a repair the caller asked for and did not get is an operational failure, not a property of the document, so no profile downgrades it (#428). repairs counts repairs applied to the package view, so a repair whose write-back failed still counts (#412).

These write-back checks are not race-resistant. They inspect the stored path and inode, then later write by path; a symbolic or hard link swapped in between the check and the write can still be followed. The same check-then-use limit is documented by containRelationshipTarget; descriptor-based no-follow writes would be required to close that race (#545).

Distinct normalized part names that collide under ASCII case folding and NFC normalization now refuse the package view on every host, before extraction (#441). The same guard includes folder prefixes (word/a.xml beside WORD/b.xml), so a staging filesystem cannot silently rename their parts. Full, quick and named runs report package-extract-failed with the same detail, run only applicable stored-name and envelope checks, and omit part checks. This is a portable staging policy, independent of whether the host filesystem is case-sensitive. It does not turn every OPC-equivalent spelling into a staging refusal: identical stored names and slash/backslash aliases that normalize to one part retain their existing entry selection; escaped spellings retain their existing OPC diagnosis.

A later extraction failure on an otherwise readable view still merges the archive-backed findings under the selected profile (#454). For example, the host may refuse an overlong filename. Already-run stored-name and envelope checks are not repeated; merged checks appear in executedChecks. This keeps partial errors a subset of full errors (#381). Regression coverage pins case, NFC/NFD and NUL-bearing names for quick, XML-only and package-only selections under Word-valid and lenient (#423). A partial run can still pass when full extraction fails on a NUL-bearing name: omitted work can miss a defect.

ValidateRunResult extends this with suffix (e.g. ".docx"), repairs (number of issues auto-repaired when autoRepair: true), and xsdWalk: { started, parts, eligible }. Validate.run() and jubarte.validate() return the shared ValidationResult, including metadata, without those extra fields.

With autoRepair the run also reports repair outcomes for DOCX/DOCM and PPTX (#622). The plan appears in the path API's validate() result and in the CLI's --json output. The CLI's text mode lists error-severity issues only; the one other finding it prints is the auto-repair-staged-copy disclosure, on its own line. The byte APIs (new Validate().run(bytes), jubarte.validate(bytes)) have no autoRepair option, so neither surface carries the plan (#651). Non-info findings from before repair are grouped by path and code, then compared with the same group from that run's post-repair validation (#648).

new Repair().run(bytes) and new Normalize().run(bytes) (jubarte.repair, jubarte.normalize) return bytes only for a package that packs. When unpack cannot extract the bytes, or the package still fails pack's validation, they reject with a PackageStepError: step is "unpack" or "pack", and issues holds the issues the validation failed on (#1157). pack's PackResult carries the same issues when it refuses. Each info-severity plan line gives the original count and says "Auto-repair cleared N; M remain": repair-plan means none remain, repair-plan-partial means some cleared and some remain, and repair-plan-unavailable means none cleared. Remaining findings carry "Fix manually: …" when guidance exists — one line lists every distinct guidance its survivors carry — or "No manual repair guidance is available." otherwise. Counts describe group outcomes, including indirect repairs; they do not identify individual occurrences or promise future repairs. New post-repair groups remain ordinary validation findings. No repairs are added.

The paragraph-id repair stamps w14:paraId/w14:textId on the paragraphs and table rows that lack them. On each part it stamps, it also declares w14 on the part root and lists it in the root's mc:Ignorable, as Word writes it — reusing the prefix the root binds to the markup-compatibility namespace, else declaring mc — so the repaired part gains no mce-extension-not-ignorable finding. A root that binds w14 or mc to another namespace is left as it was. Word 16.115 opened the repaired output clean for a root with neither declaration, one listing w15 and one using a compat prefix, as it opened the earlier output with xmlns:w14 on each stamped element (Word probes session 40, s40a; #653).

The durable-id repair gives each over-cap w16cid:durableId one new value and writes it into every w16cex:durableId that named the old one, so commentsExtensible.xml stays paired with commentsIds.xml. Before, each entry got its own random value and every extensible entry was left orphaned (#892). The two parts' spellings are matched as xsd:hexBinary values, whatever the case of their hex letters, so ffffffff in one part and FFFFFFFF in the other get the same new value (#904). The decimal durableIds in numbering.xml, which no part references, keep their per-element renumbering, and it still runs when the hex rewrite is refused.

Development

pnpm install
pnpm test --coverage          # vitest with coverage
pnpm run test:coverage-gate   # full coverage suite + per-module thresholds
pnpm run check     # vite-plus check
pnpm run build     # vp pack: library, CLI, declarations, schemas

Coverage-enabled Vitest runs print Coverage: N% lines, M% branches, including failed runs, and write text, JSON-summary, HTML, and LCOV reports. The summary is printed only when coverage is enabled. scripts/coverage-gate.ts reads coverage/coverage-summary.json and enforces 80% lines / 70% branches for the listed new modules, and 90% / 85% for mode policy, scheduling, registry, and result aggregation. A gate with no matching coverage fails; run the full suite before using it. CI runs the same gate after its coverage test step.

tests/fixtures-all-{strict,lenient}.test.ts pin validator outcomes in tests/fixtures-all.manifest.json. tests/fixtures-all-word-valid.test.ts instead compares each attributable DOCX fixture against Word's hash-bound observation: clean-open means valid, observed rejection means invalid. Unknown, timed-out, crashed, and stale observations, and any outcome spelling the accounting does not know, are excluded (#316); floors of 358 clean opens and 67 rejections prevent silent loss of evidence. The 24 jubarte 0.13.0 specimens live separately under tests/lane/ with their own integration suite.

Manifest word observations use { outcome, sha256, observedAt?, wordVersion? }. The rejections are open-error, unreadable-content-warning, password-required, and unknown-dialog. In source, toManifestOutcome translates a verifier outcome to these spellings (repair-warning → unreadable-content-warning, password-prompt → password-required, unavailable → unknown). A mismatching hash makes the observation unknown; legacy strings remain readable but cannot be checked for staleness. mergeWordObservation preserves superseded observations in history and does not let a timeout/unknown re-probe displace attributable evidence for unchanged bytes. pnpm run test:fixtures:refresh uses scripts/update-manifest.ts, which preserves this provenance. Review expectation changes individually; current validator output is not independent Word evidence.

Run the installed-package smoke separately from the regular suite. It builds, packs, installs in a fresh consumer, validates with bundled Transitional/ISO Strict schemas, checks packaged CLI help, and confirms the library starts no CLI. The real-Word integration lane is also separate and opens the actual application:

PACKAGE_SMOKE=1 pnpm exec vitest run tests/package-artifact.integration.test.ts --coverage
WORD_VERIFY=1 pnpm exec vitest run tests/word-verifier.word.test.ts --coverage

The Word lane requires macOS with Word installed and host-terminal Accessibility permission. Regular portable runs do not perform fresh Word opens.

Acknowledgments

Many of the test fixtures under tests/fixtures/external/ are borrowed from other open-source OOXML projects' own test corpora. Thanks to the maintainers of these projects for keeping their fixtures public — they made it possible to validate against real-world malformed and edge-case documents instead of only synthetic ones:

Per-vendor NOTICE / LICENSE files live alongside the fixtures in tests/fixtures/external/<vendor>/, and tests/fixtures/external/README.md documents file-level provenance.

Validators (etc.) for the neurotic developer

A place for "why isn't this more complete?" questions. If you came here because something looked suspiciously trim, this is where it gets explained.

Why doesn't NS in lib/types.ts cover every namespace declared in the bundled XSDs?

NS is a runtime lookup table for code that needs namespace URIs — getElementsByTagNameNSAll(root, NS.W, "p"), el.getAttributeNS(NS.W, "author"), the XPATH_NS prefix map. It is not a registry of every namespace that exists in OOXML.

The bundled XSDs declare more namespace URIs than NS exposes. Schema-dispatch tables and compatibility-capability sets also describe namespaces used by the validators; NS remains a focused helper lookup. XSD validation is independent: the wasm engine reads the .xsd files directly, so NS membership has no effect on what gets validated.

When a future validator needs c:chart (DrawingML chart) or v:shape (VML), add the constant to NS next to the validator that uses it — don't pre-populate speculatively. The schemas under src/scripts/office/schemas/ remain the source of truth for element definitions; NS is the source of truth for namespace URIs the TS code references at runtime.

Releases and trusted publishing

pnpm run release:check runs the prerelease preflight: type-check, type-aware lint, formatting, spelling, coverage tests, build, bundle inspection, and an npm pack dry run. It verifies that the package contains its ESM entry, declarations, and bundled Word schema. It publishes nothing.

Update package.json and CHANGELOG.md, run the preflight, commit, and tag the tested commit. Create a GitHub release for that tag; mark versions such as 0.53.0-rc.1 as prereleases. Publishing uses npm trusted publishing from publish.yml, with the existing npm-publish environment, id-token: write, a GitHub-hosted Node 24 runner, and no dependency caches or npm publish token.

The npm trusted publisher must authorize organization jandira-tech, repository docx-validate, workflow filename publish.yml, environment npm-publish, and direct npm publish. Package ownership and the trusted-publisher configuration are separate settings.

The workflow checks out the exact release tag, verifies its package version and GitHub prerelease status, runs the preflight again, and publishes with provenance. Prerelease versions go to next; stable versions go to latest.

To retry publishing an existing tag through the same GitHub workflow:

pnpm run release:publish v0.53.0-rc.1

This dispatches Actions; it does not use local npm credentials. Monitor the publish workflow for the registry result. An npm version already published cannot be overwritten.

License

Apache-2.0 — see LICENSE.

Desired Conversion Methodology

Desired Conversion Methodology means fully parsing the original document into a semantic, format-independent representation — extracting its content and the meaning of that content (including its structure, formatting, and relationships, and every other discrete constituent component of the document (each, an "Element")), and not its byte form, such that the document can be faithfully reconstructed into any target format.

Where an Element cannot be parsed semantically by available means — including, by default, DrawingML (<w:drawing>), VML pictures (<w:pict>), and embedded OLE objects (<w:object>) (each, a "Non-Parseable Element") — the Desired Conversion Methodology nonetheless applies in full to the entire document and to every Element enclosing that Non-Parseable Element; only the single, irreducible Element that resists semantic parsing may be preserved verbatim, and only after custom parsing techniques have been exhausted to reduce that residue to the smallest possible extent.

For the avoidance of doubt, the Elements enumerated above are not the sole Non-Parseable Elements; every time an Element is classified as a Non-Parseable Element, such classification must (i) emit a warning during code execution (at runtime), and (ii) be documented in AGENTS.md, README.md and CLAUDE.md.

About

OOXML validators and redline/comment helpers for .docx and .pptx files. XSD-backed, TypeScript, and ESM, for the neurotic developer.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages