Saving must never silently destroy a user's document (#1060). The fidelity gate
opens every corpus .docx, saves it with no edits, and compares every package
part with the original. A loss nobody has accepted fails the build. The same
gate runs over .xlsx (#1064); xlsx below covers what differs.
It is a plain cargo test, docxcore/tests/fidelity.rs, and the fidelity gate (docx, xlsx) job runs it in CI on every PR.
The gate round-trips each file the way docxy saves an edited document:
load_package, then Editor::new, then save_package. That save regenerates
word/document.xml from the semantic model, so it is where untouched
paragraphs of an edited document lose what the model does not understand.
In docxy, a user who saves without editing gets
save_package_preserving_document, which writes the original parts back. The
gate asserts that path is byte-identical part for part. That check has no
baseline: any difference fails outright. "The gate reports a loss in X"
therefore means "X is lost once the user edits anything in the document". It
does not mean "X is lost by open+save" in docxy. The suite also writes the
original word/document.xml back while the body still equals the stored part
(Package::stores_document, then save_package_keeping_document, #1107), so
there too a reported loss means "lost once the user edits". That save is not
itself gated (see below).
Every part of the original is compared with its saved counterpart:
- XML parts (
.xml,.relsand.vml, plus any part[Content_Types].xmltypes as XML) are compared canonically:- Elements and attributes are matched by namespace URI and local name, so a prefix rename is equal.
- Attribute order, namespace declarations, the XML declaration, comments and whitespace-only text between elements are ignored.
- Line ends normalize to LF, and in attribute values a literal tab, CR or LF is a space (an XML processor's normalizations).
- References are decoded first (
&equals&), and CDATA is text. - "Whitespace" means space, tab, CR and LF; a non-breaking space is text.
- Element and attribute content and text are compared exactly, and so is a
whitespace-only text that is an element's whole content
(
<w:t xml:space="preserve"> </w:t>). - The exception is run text as Word reads it (#1084). In a
w:torw:delText(matched by namespace, not prefix) whosexml:spacedoes not resolve topreserve, the whitespace at the two ends of the text is dropped before the compare. The element's ownxml:spacedecides, else its nearest ancestor's, sodefaultcancels an outerpreserve. So<w:t>SDT </w:t>equals<w:t xml:space="preserve">SDT</w:t>, and both differ from<w:t xml:space="preserve">SDT </w:t>. An unpreserved<w:t> </w:t>therefore holds no text at all. - The same rule applies to a SpreadsheetML string's
t, the text of a shared or inline string and of each of its runs, as Excel reads it (#1153). So the xlsx sheet check reads an unpreserved<is><t> </t></is>as a blank cell, and an unpreserved<si><t> </t></si>as the empty string.
- Every other part must be byte-equal. So must any XML part that is not UTF-8
or is malformed. Malformed covers:
- mismatched or unclosed tags;
- a duplicate attribute;
- an ill-formed or illegal character reference;
- a
<in an attribute value; - content outside the root.
- Missing and extra parts are reported.
Child lists are aligned in two passes. Identical subtrees are matched first, then the gaps between them are matched by element name. So one dropped paragraph or property is one finding, not a cascade over its siblings. Order is significant (schema order), so a moved child is reported as a loss plus an extra. A lost or extra subtree is reported once, at its root.
Each file runs under catch_unwind: a panic becomes a panic finding and a load
failure a load-error finding, and the run goes on.
Each failure is one line:
NEW ext:table/Foo.docx | word/document.xml | lost-element | /w:document/w:body/w:tbl/w:tr[2]/w:trPr/w:cantSplit
STALE ext:bar.docx | word/document.xml | lost-attr | /w:document/w:body/w:p/@w:rsidR (fixed: remove it from baseline.txt)
The kinds are:
| Kind | Meaning |
|---|---|
lost-element |
An element, or the text of an element, is gone. |
lost-attr |
An attribute is gone. |
changed-value |
An attribute value or a text changed. |
extra-element |
An element or text appeared. |
extra-attr |
An attribute appeared. |
part-missing |
A part is gone. |
part-extra |
A part appeared. |
part-bytes |
A non-XML part (or unparsable XML) changed. |
load-error |
load_package refused the file. |
panic |
The round trip panicked. |
Files are named repo:<path> for repo-tracked .docx and ext:<path> for the
external corpus, relative to the FIDELITY_CORPUS directory. So a local copy and
CI's checkout produce the same keys.
The gate also compares the effective text of word/document.xml before and
after the save (#1101). This is the text Word shows, with its Range.Text
characters for run content that is not w:t. w:t and w:delText text
counts without its insignificant edge whitespace, as above:
| Content | Character |
|---|---|
w:noBreakHyphen |
U+001E |
w:softHyphen |
U+001F |
w:tab, w:ptab |
tab |
w:br, w:cr |
U+000B (U+000C for a page break, U+000E for a column break) |
w:sym |
( |
| paragraph end | CR |
A character lost inside a restructured run shows up here even when the element compare files it as one more run-restructuring entry.
A change in any file fails the gate, like a schema violation. It is never baselined, and the report names the file, both lengths and the first difference.
A save can lose nothing and still write a file Word refuses as corrupt (#1083:
an unwrapped smart tag left its w:smartTagPr directly under w:p). So the
gate also validates every saved package against a subset of the
WordprocessingML schema (the transitional wml.xsd of ECMA-376 Part 4), in
docxcore/tests/fidelity/schema.rs. It needs no Word, .NET or XSD, and checks
a subset of what the Open XML SDK validator does.
Every part whose root element is in the w: namespace is checked: the
document, headers, footers, footnotes, endnotes, comments, numbering and
styles. These containers have a content model, wherever they occur:
- ordered sequences:
pPr,tblPr,tblPrEx,tcPr,sectPr,pBdr,pgBorders,tblBorders,tcBorders,tblCellMar,tcMar(each side both physical and logical:startthenleft,endthenright),tbl(only range markup beforetblPr),tr; - repeatable choices with an ordered head or tail:
rPr(its properties come in any order and may repeat; a paragraph mark'srPrleads with its revision marks, andrPrChangeis last) andtrPr(row properties, thenins,del,trPrChange); - allowed children:
body(sectPrlast),p(pPrfirst),r(rPrfirst),hyperlink,smartTag(smartTagPrfirst),tc(tcPrfirst, then at least one block), and the block containershdr,ftr,footnote,endnote,comment,txbxContentanddocPartBody.
A revision's snapshot of properties (pPrChange/pPr, rPrChange/rPr,
tblPrChange/tblPr, ...) has the narrower model the schema gives it: no
nested change, and a paragraph's snapshot ends at cnfStyle.
A child breaks one of four rules:
| Rule | Meaning |
|---|---|
not-allowed |
Its parent's model has no place for it. |
order |
It comes after a sibling the schema puts after it. |
duplicate |
It repeats where the schema allows one. |
missing |
A required child is absent (tblPr, tblGrid, a cell's block). |
Children in another namespace (extensions, DrawingML, math) are not checked,
but w: containers inside them are, so text box paragraphs count. An
mc:AlternateContent subtree is skipped.
The original package is validated too. A violation is counted by part, parent path without indices, rule and child, and only the ones the save added fail: a count above the original's. A non-conforming original therefore does not fail the gate, and a violation the save fixed does not cover a new one. A failure is reported as:
SCHEMA ext:table/2007.docx | word/document.xml | not-allowed | /w:document/w:body/w:p[39]/w:smartTagPr
Schema violations have no allowlist and no baseline: Word rejects the file
whether or not anything was lost, so any new one fails the gate, also under
FIDELITY_UPDATE_BASELINE. When one appears, fix the writer, or fix the table
in schema.rs if the schema allows the construct.
corpus/tools/fetch-corpus.sh # or fetch-corpus.ps1; populates corpus/files
cargo test -p docxcore --test fidelity -- --nocaptureThe test always runs the repo-tracked .docx: assets/, corpus/word-import/,
corpus/legacy/word/ and docxcore/tests/fixtures/. It also runs every .docx
under the docxy-corpus checkout, searched recursively.
| Variable | Effect |
|---|---|
FIDELITY_CORPUS=<dir> |
The docxy-corpus files/ directory. The default is corpus/files; a relative path resolves against the workspace root. |
FIDELITY_REQUIRE_CORPUS=1 |
Fail when that directory is missing. Without it the test prints a SKIP external corpus notice and runs the tracked files only. CI sets it. |
FIDELITY_UPDATE_BASELINE=1 |
Rewrite baseline.txt from this run instead of judging against it (see below). |
fetch-corpus.sh shallow-clones yeroo/docxy-corpus
and replaces corpus/files/ and corpus/xlsx-ext/ with its payload. Run it
again to update. When the clone fails (offline), it prints a SKIP notice, leaves
any existing copy alone and exits 0.
docxcore/tests/fidelity/allowlist.txt lists the deliberate, benign
normalizations the gate tolerates in every file:
kind | part glob | index-free path glob | detail glob | [once-with <kind> <part> |] reason
extra-attr | word/document.xml | */w:t/@xml:space | preserve | The serializer writes xml:space="preserve" on every w:t. The loader drops ...
In the globs, * matches any run of characters, including /. The detail is
what the report prints after the path:
| Finding | Detail |
|---|---|
lost-attr, extra-attr |
the attribute's decoded value, unquoted |
changed-value |
"old" -> "new", each value Rust-Debug-quoted |
lost-element / extra-element of text (.../text()) |
the text, Rust-Debug-quoted |
lost-element / extra-element of an element |
its attributes as name="value" pairs, sorted by namespace URI and local name, values Rust-Debug-quoted (so " and \ are escaped); empty for a mismatched root element |
part-bytes, load-error, panic |
free text (sizes, the error, the panic message) |
part-missing, part-extra |
empty |
So a rule can tolerate one specific element, such as the content-type
Override for an added styles part, rather than every extra element at that
path.
An optional once-with <kind> <part> | field before the reason narrows a rule
further. The rule then applies only in a file that also has a finding of that
kind in that part, and it absorbs one finding per file. The styles Override
rule uses it, so a duplicate Override of a styles part the file already had
still fails.
A rule without a reason is a parse error. The test prints how many findings each rule absorbed.
Shrinking the allowlist is progress. Growing it needs review: a rule hides that difference in every file, now and later. A real loss that is not fixed yet belongs in the baseline, never here.
docxcore/tests/fidelity/baseline.txt holds the losses known today, one line
per file, part, kind and index-free path, tab-separated:
ext:table/Foo.docx word/document.xml lost-element /w:document/w:body/w:tbl/w:tr/w:trPr/w:cantSplit
The baseline may only shrink:
- A finding not in the baseline fails (
NEW). - A baseline entry that no longer reproduces also fails (
STALE): delete the line, so the fix cannot regress unseen. - Only the files present in the run are judged. An entry for a file the run did not see is neither new nor stale.
Entries are grouped under loss classes. Each class names the issue that owns
its fix, and the classes are defined in CLASSES in docxcore/tests/fidelity.rs.
The gate also fails on an entry that no class claims. A new kind of loss
therefore needs a class and an issue, not just a line.
Classes match by path, which can mislabel one case. A run that #1069 splits or
unwraps can make the comparator pair the wrong runs, and then intact run
properties read as lost or changed. Such entries are listed one by one in
MISALIGNED_RUNS, each checked per character against the original, so the
#1069 class claims them instead of #1068. An exact list cannot claim a loss in
a file or at a path it does not name.
The baseline keys on the index-free path, so one line covers every occurrence of that loss in that file. The trade-off: a new occurrence of an already listed loss at another index in the same file is not reported. The finding that does get reported always carries its full indexed path.
After a fix:
FIDELITY_UPDATE_BASELINE=1 cargo test -p docxcore --test fidelity round_trip_fidelity_gate
git diff docxcore/tests/fidelity/baseline.txt # should only remove linesThe rewrite replaces the entries of every file in this run and keeps the
entries of files it did not see. So run it with the full corpus fetched, or a
partial run leaves stale lines for absent files behind for the next full run to
flag. A diff that adds lines is a regression, not an update, with one
exception. The comparator reports a difference once, at the root of the subtree
that differs. So when a fix stops losing a whole container (a w:rPr, an
attribute), any difference inside that node, or among runs aligned through it,
becomes visible for the first time. Such an entry may be added under its own
class, but only when an entry for the same file at that node or an ancestor goes
away in the same diff. The PR lists each one (#1063 did this).
gridcore/tests/fidelity.rs runs the same gate over .xlsx. It shares the
comparator, the allowlist and baseline formats, and the NEW / STALE /
UNCLASSIFIED rules: it includes docxcore/tests/fidelity/mod.rs by path, so
there is one comparator. Its own files are gridcore/tests/fidelity/allowlist.txt
and gridcore/tests/fidelity/baseline.txt, and its classes are CLASSES in
gridcore/tests/fidelity.rs.
corpus/tools/fetch-corpus.sh # also populates corpus/xlsx-ext
cargo test -p gridcore --test fidelity -- --nocapture-
The round trip is
load_xlsx, thensave_xlsx_for_pathwith the file's own path: what xlsxy writes on save. It leaves outstamp_save, which rewritesdocPropswith the current time on purpose. xlsx has no separate no-edit save like docx'ssave_package_preserving_document: every save regenerates each worksheet's<sheetData>,<cols>and<dimension>from the model, so the gate measures that. -
The files: the repo-tracked
.xlsxinassets/,corpus/xlsx/,corpus/legacy/addin/,corpus/legacy/extra/,offxy-vscode/mcp/templates/anduiharness/fixtures/, plus every.xlsxunderFIDELITY_XLSX_CORPUS(defaultcorpus/xlsx-ext, the docxy-corpusxlsx-ext/directory), searched recursively.FIDELITY_REQUIRE_CORPUSandFIDELITY_UPDATE_BASELINEwork as for docx.FIDELITY_UPDATE_BASELINE=1 cargo test --workspacerewrites both baselines. -
The sheet check. A regenerated worksheet differs from its source in ways that change nothing a reader sees, and a structural finding cannot tell them from a loss. Examples:
0.14000000000000001written0.14; an inline string moved to the shared strings; a shared formula expanded per cell; a dropped<c r="B2"/>;customWidth="true"written1. So each worksheet is also read the way a spreadsheet reads it. A worksheet is any part underxl/worksheets/or one the workbook's worksheet relationships name. Parts are keyed by their OPC name (an entry writtenxl\sheet1.xmlisxl/sheet1.xml), and a part declaring windows-1252 or ISO-8859-1 is read by its declaration, as the loaders read it (#1108, #1116). The loss classes for a worksheet'ssheetData,dimensionand cells match the entry's path (the rootworksheet, or/cells/), so they apply to a worksheet wherever the package keeps it.Per cell either side lists, it compares three things:
- the value. Shared strings are found through the workbook's
relationship. Shared and inline strings are resolved, run formatting
included; phonetic runs are not read. Each
tis read as Excel reads it (see the whitespace rule above). An inline string with no text is a blank cell. Numbers compare as doubles, along with booleans and errors. - the formula: its text, plus kind and range for array and data-table formulas. A shared formula's follower is resolved from its master by shifting the master's relative references. The test does that with its own shifter, not gridcore's. A follower that has no master covering it does not resolve.
- the effective style: the cell's own
s, else its row's (withcustomFormat), else its column's.
Per column, it compares the
<col>attributes: width (as a double),customWidth,style,hidden,bestFit,phonetic,outlineLevelandcollapsed.A difference is a
changed-valuefinding at one of these paths:/cells/<ref>/value/cells/<ref>/style/cells/<ref>/formula-text: the same formula in other words, meaning spaces, case or the_xlfn.prefix;/cells/<ref>/formula: any other formula change, including a lost, added or unresolved formula;/cols/<letters or range>/<attribute>: one path covers a run of adjacent columns that differ the same way.
Each baseline line covers that cell or column run only. A new loss in another cell is NEW, and so is a changed formula filed where only re-serialization is baselined.
Two more findings judge the saved worksheet alone, whatever the original said (#1152), and no loss class claims them, so they always fail:
/cols/<letters or range>/no-width: a<col>with nowidth, which Excel opens zero wide. The column comparison above reads it as no<col>at all, so it would miss one;/cols/<letters or range>/overlap: a<col>starting at or before the end of the one before it (a duplicate, an overlap, or one out of order).
The structural findings the check covers are dropped. Covered are:
- all of
<cols>; - a lost or extra
<c>whose attributes are withinr,sandtand whose children are<v>,<f>(witht,si,ref) and<is>(<t>and runs); - the
<v>and<f>text; - the
<is>text and runs; - the cell attributes
r,sandt; - the formula attributes
t,siandref; - rows carrying nothing but
randspans.
Everything else stays structural. That includes other row attributes, other cell attributes (
vm,cm,ph), other formula attributes, phonetic runs, and a lost cell that carried any of them. Those baseline lines are index-free like docx's, so one line covers every occurrence in that sheet. The test prints how many findings the check covered. - the value. Shared strings are found through the workbook's
relationship. Shared and inline strings are resolved, run formatting
included; phonetic runs are not read. Each
-
Pivot parts round-trip unchanged (#1154). Save no longer adds
refreshOnLoadto an untouched pivot's cache, so the allowlist has no entry for it: Excel rebuilt such a pivot with its own captions and lost its page-field selection.the_pivot_files_save_their_pivot_parts_as_they_werechecks the five files the oracle found: every part underxl/pivotTables/andxl/pivotCache/is kept and compares equal, with no allowlist or baseline. -
Directory entries are no parts (#1156). Some writers put directories in the ZIP as empty entries without a trailing
/and mark them only in their ZIP attributes (tdf124525.xlsx has_rels,docProps,xl, ...). The loader and the comparator both treat an entry as a directory, not a part, when its name ends with/, or when it is empty and either another entry lies under it or its name has no extension. An empty part with an extension (docProps/thumbnail.wmf), or one without that anOverrideor an internal relationship names, is still a part. Before #1156 the save wrote those entries back as empty parts with no content type, and Excel opened the file with its repair prompt. -
Package and schema validation (#1156). Like docx's schema validation, every original and every save is validated, and a violation the save added fails the gate. Violations have no allowlist and no baseline, also under
FIDELITY_UPDATE_BASELINE, because Excel repairs the file whether or not a value was lost. The checks live ingridcore/tests/fidelity/schema.rs.The package rules follow OPC (ECMA-376 Part 2). Entry names,
Overridenames and relationship targets go through one normalization (/separators, percent-decoded, ASCII case-insensitive), andDefaultextensions compare case-insensitively. Targets are resolved against their source part, andTargetMode="External"targets are skipped. Which entries are parts is read from the ZIP central directory, as Excel reads it: an entry is a directory when its name ends with/or its attributes say so. The size rule above does not apply here, because an empty extensionless entry is exactly the part a save must not write.Rule Meaning missing-partNo [Content_Types].xmlor no_rels/.rels.no-content-typeA part with neither an Overridenor aDefaultfor its extension.override-no-partAn Overridenames no part.duplicate-partTwo entries name one part. dangling-targetAn internal relationship targets no part. The content models are a subset of the transitional
sml.xsdof ECMA-376 Part 4. They apply to every part whose content type is XML, whatever its name, and check the children ofworksheet(CT_Worksheet order,sheetDatarequired),sheetData(row),row(c, thenextLst) andworkbook(CT_Workbook order,sheetsrequired), by name only. A violation uses docx'snot-allowed,order,duplicateandmissingrules, orr-order: aroworcwhoserdoes not come after its previous sibling's. A row or cell withoutris skipped. Children in another namespace (mc:AlternateContent, extensions) are not checked. A failure reads:SCHEMA ext:libreoffice/sc/qa/unit/data/xlsx/tdf124525.xlsx | xl | no-content-type | /xl
- Fixing the losses it found:
- The unmodeled property children and attributes were fixed by #1063.
- The other classes in
baseline.txteach name their issue.
- Whether Word opens the file. Schema validation catches the structural part of that, by a subset of the schema. Attribute values, relationship targets and the containers it has no model for are not checked.
- Save paths other than
save_packageandsave_package_preserving_document: the HTML bundle, Markdown, compare and merge, and the suite's unedited savesave_package_keeping_document(#1107). That one is the preserving save plussave_package's table-style pass, so it can add table styles tostyles.xmland[Content_Types].xmlwhere the preserving save does not. - For xlsx:
- attribute values, cell contents (
f,v,is), and every part but the workbook and its worksheets; package and schema validation cover the package and those containers only; - other file types (
.xlsm,.xltx,.xlsb), and Save As to another type; - what xlsxy does around a save, such as recalculation on open and
stamp_save. Only the library's round trip is checked.
- attribute values, cell contents (