OOXML Corner Cases
August 13, 2026 · View on GitHub
This document tracks edge cases and quirks in Open XML document processing where Word's behavior differs from a strict interpretation of the specification, or where the specification is ambiguous.
Table of Contents
Numbering and Lists
Legal Numbering with Multi-Level Format Strings
Status: Fixed (December 2024)
Discovered: 2024-12-22
Test File: NVCA-Model-COI-10-1-2025.docx
The Problem
When a paragraph uses a deeper indentation level (ilvl) with a format string that references parent levels (e.g., %1.%2), Word may not display the parent level numbers as expected.
Example Document Structure
<!-- abstractNum 3, level 0 -->
<w:lvl w:ilvl="0">
<w:start w:val="1"/>
<w:numFmt w:val="decimal"/>
<w:lvlText w:val="%1."/>
</w:lvl>
<!-- abstractNum 3, level 1 -->
<w:lvl w:ilvl="1">
<w:start w:val="4"/>
<w:isLgl/>
<w:numFmt w:val="decimal"/>
<w:lvlText w:val="%1.%2"/>
</w:lvl>
Document paragraphs:
Para 1: ilvl=0, numId=3 → Word displays: "1."
Para 2: ilvl=0, numId=3 → Word displays: "2."
Para 3: ilvl=0, numId=3 → Word displays: "3."
Para 4: ilvl=1, numId=3 → Word displays: "4." (NOT "3.4")
Expected vs Actual Behavior
| Renderer | Item 4 Output | Notes |
|---|---|---|
| Microsoft Word | 4. | Period at end, not middle |
| LibreOffice Writer | 4. | Matches Word |
| LibreOffice HTML export | 4 | Uses <ol start="4"> |
| Docxodus (current) | 3.4 | Incorrect - includes parent level |
Key observation: Word outputs "4." (number then period) even though level 1's format string is %1.%2 (which would produce "3.4" if evaluated literally).
Note that level 0's format string is %1. (number then period). This suggests Word may be:
- Detecting that item 4 at
ilvl=1is an "orphan" (no proper parent-child nesting) - Falling back to level 0's format
%1.but using the level 1 counter (4) - Result: "4." - which matches the observed output!
This "orphan detection" hypothesis would explain the behavior: Word recognizes when a deeper-level item doesn't have proper hierarchical nesting and reverts to simpler formatting.
Analysis
Our converter (ListItemRetriever.cs) builds levelNumbers for each paragraph by:
- For
ilvl=1, looping from level 0 to level 1 - For level 0: inheriting the counter from the previous paragraph (3)
- For level 1: using the
startvalue (4) - Result:
levelNumbers = [3, 4] - Format
%1.%2produces:"3" + "." + "4"="3.4"
Word appears to use different logic where:
- The
%1token in the format string is either:- Omitted when there's no "active" parent paragraph at that level
- Or interpreted differently when transitioning level depths
Potential Causes
-
Orphan nesting detection: Word may detect that para 4 at
ilvl=1doesn't have a proper parent-child relationship with para 3 atilvl=0(they're effectively siblings in a flat list that happens to use different levels). -
Level entry tracking: Word may only include
%Ntokens in the output when level N has been "entered" as part of the current nesting chain, not just referenced from previous items. -
Start value heuristics: When a level's
startvalue (4) suggests continuation of an overall sequence, Word may apply special formatting rules.
Relevant Code
Docxodus/ListItemRetriever.cs:FormatListItem()(lines 1100-1144): ProcesseslvlTextformat tokens- Level number calculation (lines 980-1079): Builds
levelNumbersarray
// Current logic in FormatListItem:
int levelNumber = levelNumbers[indentationLevel];
// This always uses the levelNumbers array, even if the level wasn't "entered"
The Fix
Implementation: Added "continuation pattern" detection in ListItemRetriever.cs.
Detection criteria:
A paragraph at ilvl > 0 is in a "continuation pattern" when:
- It's the first paragraph at this level in the current sequence, AND
- The level's
startvalue equals the parent level's counter + 1 (continues the sequence)
OR it inherits continuation status from a previous paragraph at the same level.
What the fix does: When a continuation pattern is detected, the converter uses level 0's properties instead of the declared level's:
- Format string (e.g.,
%1.instead of%1.%2) - Run properties (e.g., no underline instead of underline)
- Paragraph properties (e.g., tab stops and indentation)
Code changes:
-
Docxodus/ListItemRetriever.cs:- Added
ContinuationInfoannotation class to track continuation state per paragraph - Added
GetEffectiveLevel()helper method that returns 0 for continuation patterns - In
InitializeListItemRetriever, after calculatinglevelNumbers:// Detection logic if (levelNumbers[ilvl] == startValue && startValue == levelNumbers[ilvl - 1] + 1) { isContinuation = true; } - In
RetrieveListItem, uses level 0's format string with current level's counter
- Added
-
Docxodus/FormattingAssembler.cs:NormalizeListItemsTransform: UsesGetEffectiveLevel()to get list item level's rPrParaStyleParaPropsStack: UsesGetEffectiveLevel()to yield correct level's pPr and rPrAnnotateParagraph: UsesGetEffectiveLevel()for numbering paragraph properties
Result:
- Items that continue a flat list sequence now render correctly (e.g., "4." instead of "3.4")
- Formatting (underline, bold, etc.) from the effective level is applied consistently
- Tab stops and indentation match the effective level's paragraph properties
Test Cases Needed
- Standard multi-level list with proper nesting (1., 1.1, 1.2, 2., 2.1)
- "Orphan" nesting like the NVCA example (1., 2., 3., then jump to level 1)
- Legal numbering (
isLgl) vs non-legal numbering behavior - Various
startvalues and how they affect parent level display
References
List Numbering under Tracked Changes (deleted paragraphs don't consume numbers)
Status: Fixed (August 2026)
Discovered: 2026-08-02 (the README's NVCA voting-agreement marquee redline)
Test: Docxodus.Tests/TrackedChangesNumberingTests.cs (TCN001–TCN006)
The Problem
In a tracked-changes document, which paragraphs consume list numbers? A literal reading of
the numbering spec says nothing about revisions — every w:p with a resolvable w:numPr
is a list item. Rendering a comparer-produced redline that way numbers deleted, moved-away
and inserted paragraphs in one continuous sequence, so every number after a deletion
disagrees with the final document.
Minimal XML Reproducer
A numbered list Alpha, Bravo, Charlie where Bravo is fully deleted (content in
w:del, pilcrow marked deleted — the shape both WmlComparer and DocxDiff emit):
<w:p><!-- Alpha: plain list item --></w:p>
<w:p>
<w:pPr>
<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>
<w:rPr><w:del w:id="1" w:author="Reviewer" w:date="..."/></w:rPr>
</w:pPr>
<w:del w:id="2" w:author="Reviewer" w:date="...">
<w:r><w:delText>Bravo</w:delText></w:r>
</w:del>
</w:p>
<w:p><!-- Charlie: plain list item --></w:p>
Renderer Comparison
| Renderer | Alpha | Bravo (deleted) | Charlie |
|---|---|---|---|
| Microsoft Word (All Markup) | 1. | 2. (struck) | 2. |
| LibreOffice Writer (Show Changes) | 1. | 2. (struck) | 2. |
| Docxodus before fix | 1. | 2. (struck) | 3. |
| Docxodus after fix | 1. | 2. (struck) | 2. |
Analysis
Word ties numbering to the paragraph mark. A pilcrow marked deleted
(w:pPr/w:rPr/w:del, or w:moveFrom on a move source) merges its paragraph into the
successor when the revision is accepted, so the paragraph does not survive as a numbered
item — and Word renumbers the display as if every change were already accepted. The
deleted paragraph still shows the value the counter holds at its position, without
advancing it; the next live paragraph shows the same value. That is the famous
"duplicate numbers next to a struck paragraph" Word renders in All Markup view, and it
is why a lawyer reading a Word redline sees final-document numbering. Inserted pilcrows
(w:ins/w:moveTo) count normally — they are part of the final document.
A second trap lives in the styling of the number glyph. When the paragraph before a list item carries an inserted pilcrow, that can mean two very different things:
- A split: the ins-marked mark was inserted into pre-existing content (Enter pressed mid-paragraph with track changes on). The FOLLOWING paragraph occupies the newly created list position — its number is the "new" one.
- A wholly inserted paragraph (comparer output for a new or moved-in paragraph): own ins pilcrow AND all content inserted. The insertion is self-contained; rejecting it leaves the following paragraph untouched, so its number must NOT be styled as an insertion (or attributed to the revision's author).
FormattingAssembler's marker-styling heuristic treated both alike, painting the
unchanged paragraph after every comparer-inserted paragraph as if its number were newly
inserted.
A comparison has one additional fact that an arbitrary tracked-changes document does not:
the resolved marker on both the original and revised paragraph. DocxDiff now preserves a
changed original marker in native w:numPr/w:numberingChange[@w:original] metadata. The HTML
tracked-changes renderer consumes it as a deleted old marker followed by an inserted current
marker, so a cascade such as (a) → (b) is visible instead of silently displaying only the
final (b). For a wholly deleted or moved-from list paragraph, the same metadata keeps the
source-side marker visible rather than showing a counter value recomputed in the merged redline.
Relevant Code
ListItemRetriever.InitializeListItemRetrieverForStory— the counting loop;ParagraphMarkIsDeletedparagraphs get theirLevelNumbersannotation (they still render a struck number) but restore all forward-carried state (previous, start-override consumption, continuation tracking).FormattingAssembler.NormalizeListItemsTransform— the previous-paragraph-ins heuristic now requires the predecessor to carry pre-existing content (IsWhollyInsertedParagraph), and rendersw:numberingChangeas an old/new marker pair.IrMarkupRenderer.StampResolvedNumberingChange— compares the IR reader's already-resolved left/right markers and emitsw:numberingChangewhen they differ.
The behavior is unconditional (no setting): the default HTML render path accepts revisions before numbering runs, so only tracked-changes renders (and live tracked-changes editing sessions) can observe it, and as-if-accepted is Word's reading of the format.
Footnotes
Footnote Numbering Uses Raw XML IDs Instead of Sequential Display Numbers
Status: Fixed (December 2024)
Discovered: 2024-12-23
Test File: Model-COI-10-24-2024.docx (NVCA model legal document)
The Problem
Docxodus was displaying footnote numbers using raw XML w:id attribute values instead of sequential display numbers. Per ECMA-376, the w:id is a reference identifier (linking footnoteReference to footnote definitions), not the display number. Display numbers should be calculated sequentially based on the order footnotes appear in the document.
Example:
- Document has 91 footnotes with XML IDs 2-92 (IDs 0, 1 are reserved for separator types)
- Word/LibreOffice display: 1, 2, 3, ..., 91 (sequential)
- Docxodus (before fix): 2, 3, 4, ..., 92 (raw XML IDs)
ECMA-376 Specification
The ECMA-376 specification clarifies how footnote numbering works:
-
w:idis a reference identifier, NOT the display number- The
w:idattribute on<w:footnoteReference>links to the footnote definition infootnotes.xml - IDs 0 and 1 are reserved for
separatorandcontinuationSeparatortypes - Content footnotes typically start at ID 2
- The
-
Display number is determined by document order
- The first
<w:footnoteReference>in document flow displays as "1" - The second displays as "2", and so on
- This is independent of the
w:idvalue
- The first
-
w:customMarkFollowsattribute- When present, suppresses automatic numbering
- Used for custom footnote marks (symbols, letters, etc.)
The Fix
Implementation: Added FootnoteNumberingTracker class in WmlToHtmlConverter.cs.
How it works:
- Before conversion, scan the document for all
footnoteReferenceandendnoteReferenceelements in document order - Build a mapping from XML ID to sequential display number (1, 2, 3...)
- Store the mapping as an annotation on the root element
- Use the mapping when rendering footnote references (superscripts) and footnote list items
Code changes in Docxodus/WmlToHtmlConverter.cs:
- Added
FootnoteNumberingTrackerclass (lines 655-681) - Added
BuildFootnoteNumberingTracker()method to scan document and build mapping - Added
GetFootnoteNumberingTracker()helper method - Updated
ProcessFootnoteReference()to use display numbers instead of XML IDs - Updated
ProcessEndnoteReference()similarly - Updated
RenderFootnotesSection()to order footnotes by document order and use display numbers - Updated
RenderEndnotesSection()similarly - Updated
RenderPaginatedFootnoteRegistry()for pagination mode
Result: Footnotes now display with correct sequential numbers (1, 2, 3...) matching Word/LibreOffice behavior.
References
- ECMA-376 Part 1, Section 17.11.7 - footnoteReference
- ECMA-376 Part 1, Section 17.11.10 - footnotes part
continuationNotice Reserved Footnote Rides at a POSITIVE w:id (NVCA contract)
Status: Fixed (2026-06)
Discovered: 2026-06-23
Test: DocxDiffFootnoteRobustnessTests.ReservedContinuationNoticeAtPositiveId_DoesNotCollideWithRenumberedRealFootnote
The corner case
The reserved boilerplate footnotes are commonly assumed to occupy non-positive ids (separator = -1, continuationSeparator = 0), with content footnotes starting at id 2. But Word emits a third reserved note — continuationNotice — and the real NVCA model contract carries it at a positive id:
<w:footnote w:type="separator" w:id="-1">…</w:footnote>
<w:footnote w:type="continuationSeparator" w:id="0">…</w:footnote>
<w:footnote w:type="continuationNotice" w:id="1"><w:p/></w:footnote> <!-- positive id! -->
<w:footnote w:id="2">…first content footnote…</w:footnote>
Any code that (a) treats a typed note as reserved/kept-verbatim and (b) renumbers content notes from 1 will re-mint id 1 for the first content note → a duplicate w:id colliding with continuationNotice. In DocxDiff this corrupted every edit of the contract (even body/format-only edits that never touch a footnote), because the post-render renumber pass (IrMarkupRenderer.RenumberNoteIds) walks body references and re-sequences ids in reference order.
Renderer comparison
| Renders the duplicate? | |
|---|---|
| Word | N/A (Word never produces the collision; it keeps content ids ≥ 2 disjoint from reserved) |
| LibreOffice | Silently drops/repairs the colliding definition on load (loss) |
| Docxodus (before fix) | Emitted two <w:footnote w:id="1"> — schema-invalid (Sem_UniqueAttributeValue) |
The fix
RenumberNoteIds now starts the content-note counter above the highest positive reserved id (so {-1, 0}-only documents are unchanged, but a continuationNotice at 1 pushes content notes to start at 2). The renumbered range stays disjoint from the kept boilerplate ids. Relevant code: Docxodus/Ir/Diff/IrMarkupRenderer.cs (RenumberNoteIds, the int next = … seed).
LibreOffice Re-Associates Footnote References to Definitions POSITIONALLY (orphaned-definition fidelity)
Status: Documented behavior (not a Docxodus defect)
Discovered: 2026-06-23 (headless-LibreOffice footnote backstop, tools/diffharness/lo/lo_footnote_check.py)
The corner case
When a document contains an orphaned footnote definition (a w:footnote whose w:id is no longer named by any body w:footnoteReference — e.g. after a paragraph carrying the reference is deleted/rewritten, leaving the definition behind), LibreOffice on import does not resolve the surviving reference to its definition by w:id. It re-associates references to definitions positionally (the n-th reference → the n-th definition), so it displays the first definition's text for the surviving reference and drops the trailing one.
This means a body that references footnote id 2 ("See Section 1.2…") with an orphaned id 1 ("Include this provision…") still present renders in LibreOffice as "Include this provision…" — the orphaned definition's text. The OOXML is fully schema-valid (unique ids, the surviving reference resolves to exactly one definition by id); Word honors the id. It is purely a LibreOffice import behavior.
Why this is NOT a DocxDiff corruption
DocxDiff.Compare faithfully reproduces the right document's footnote structure on accept (and the left's on reject). The orphaned definition is a property of the user's edited (right) document itself — the fixture/edit removed only the body reference, not the definition. Loading the right document and the accept(Compare(left,right)) document in LibreOffice yields identical footnote rendering (same count, same text, same positional association), confirming accept ≡ right cross-renderer. The "wrong" text is LibreOffice's handling of that valid OOXML shape, applied equally to the target and to the diff's accept output. No loss, no repair, no divergence introduced by the engine.
Relevant code / verification
tools/diffharness/lo/lo_footnote_check.py— headless-LibreOffice load + footnote-count/text report (the independent validity backstop).DocxDiffScenarioTests.Scenario_PreservesFootnoteStructure— the in-process id↔reference↔text round-trip oracle (asserts at the OOXML id level, immune to LibreOffice's positional quirk).
OpenXmlValidator Does NOT Resolve Note-Body (note-in-note) References — a validation blind spot
Status: Documented gotcha Discovered: 2026-06-23 (non-body scope fidelity audit)
The corner case
A footnote/endnote definition body may itself contain a w:footnoteReference/w:endnoteReference (a note that cites another note — "note-in-note"). The SDK OpenXmlValidator validates references in the document body against the notes part, but does not resolve references that live inside a note definition body. So a dangling nested reference (one pointing to a note id that no longer exists after renumbering) produces zero schema errors — the validator simply does not check it.
This is a trap for any pipeline that uses "no new OpenXmlValidator errors" as its footnote-integrity oracle: it will pass a document whose note-in-note references dangle. In Docxodus this masked a real DocxDiff bug where RenumberNoteIds renumbered a body-referenced note's definition (e.g. id 5 → 2) but left a nested reference to it (inside another note's body) at the stale id 5.
How to actually catch it
Resolve every footnoteReference/endnoteReference in the document — body and inside every note definition body — against the note part's definition ids yourself; do not rely on the validator. See DocxDiffFootnoteRobustnessTests.AllUnresolvedFootnoteRefs (counts unresolved references across both scopes) and the fix in IrMarkupRenderer.RenumberNoteIds (records each definition's old→new id and remaps nested references).
Wrinkle (2026-06-24): it also FALSE-POSITIVES, and the value it names follows a renumber
The blind spot is worse than "ignores them": OpenXmlValidator (Office2019) emits a Sem_MissingReferenceElement for a note-in-note reference even when the target definition is present (a false positive — observed on TestFiles/DD/DD001-DenseBookmarkXrefFootnote.docx, whose footnote 2 cites footnote 5, where 5 exists). The error's Description embeds the reference value (…The reference value is '5'.). So when DocxDiff correctly compacts a gapped note id (5 → 4), the validator's false positive simply re-emits with the new value ('4'), at the same part/path.
This is a trap for a schema-error oracle that diffs validator output across input↔output keyed on the description: the input copy ('5') and output copy ('4') look like different errors, so the legitimate renumber is mis-counted as a NEW defect. DocxDiffBookmarkRealDocTests.SchemaErrors defends against this by keying on {Id}@{Part.Uri} + a value-normalized description ('\d+' → '#'); genuine new dangling references in a different part are still surfaced, and real note-in-note resolution is checked structurally by the UnresolvedNoteRefs oracle (which does not consult the validator at all).
Comments
Comment threading is keyed on w14:paraId, NOT the comment w:id (and a dedup clone must carry its own paraId)
Status: Documented behavior + design note (comment fidelity campaign)
Discovered: 2026-06-24 (DocxDiffCommentStructureTests, headless-LibreOffice comment oracle tools/diffharness/lo/lo_comment_check.py)
The corner case
A threaded comment reply is linked to its parent not by the comment's w:id, but by the w14:paraId of the comment-definition paragraph: commentsExtended.xml carries <w15:commentEx w15:paraId="…" w15:paraIdParent="…"> where both values are w14:paraIds of <w:comment>/<w:p> elements in comments.xml. Both Word and LibreOffice resolve "which comment is a reply to which" purely through this paraId graph. So renumbering a comment's w:id (as the DocxDiff dedup does for the del/ins copies of a rewritten commented paragraph — the comment analogue of the bookmark renumber-collision) does not by itself break threading.
The trap is in the reverse direction. When DocxDiff clones a comment definition to give the deleted (reject-side) copy a fresh w:id, a naive clone either (a) duplicates the original's w14:paraId — two comments now claim the same threading key — or (b) strips the paraId to avoid that duplicate, which silently severs the clone from commentsExtended so a reject-side threaded reply dangles (its paraIdParent no longer names a comment with that paraId). Both are wrong: (a) is ambiguous, (b) loses the reply→parent link on reject.
The fix
IrMarkupRenderer.NormalizeComments (phase B) gives each dedup clone a fresh w14:paraId (allocated above the max existing paraId) and clones the matching commentsExtended/commentsIds entry under the fresh paraId (CloneThreadingEntryForParaId), preserving paraIdParent. So the reject-side clone keeps its own threading link, exactly as the accept-side original keeps the unchanged one. Verified independently: lo_comment_check.py enumerates LibreOffice Annotation fields and asserts every reply's ParentName names a loaded comment — the dense fixture's Compare output reports 2 threaded replies (original + clone), both resolving.
OpenXmlValidator does NOT flag a duplicate w14:paraId (a second comment-threading blind spot)
Like the note-in-note blind spot above, the SDK OpenXmlValidator (Office2019) does not validate w14:paraId uniqueness across comment definitions — a document with two <w:comment>/<w:p> sharing one paraId is "schema-valid" to the validator but ambiguous to Word's/LibreOffice's threading resolver. So "no new validator errors" is not sufficient to prove comment-threading integrity; assert paraId/threading structurally (DocxDiffCommentStructureTests.AnchorProjection resolves each reply's parent through the paraId graph and checks accept ≡ right / reject ≡ left on the resolved-parent text).
v1 limitation: cross-document comment id / paraId collision (independent documents only)
The comment merge (MergeRightCommentDefinitions) and collapse assume a comment present in both sides carries the SAME w:id — true when the two inputs are two versions of ONE document (Word never reassigns a comment's id, so an edited doc's comment ids are stable). Two independent documents that each happened to assign w:id="0" to a DIFFERENT comment anchored on the same text, or a right-added comment whose w14:paraId GUID collides with a left comment's, are out of v1 scope: the cross-document case can leave accept showing the left comment's text (the right definition is not re-id'd and merged) or duplicate a w14:paraId. The output stays schema-valid and every reference resolves to exactly ONE comment (the (C) backstop guarantees that) — it is a content/threading-attribution gap, not a structural corruption, and it does not arise from the diff/review workflow the engine targets (before/after of one document). Re-id'ing right-sourced markers for a genuinely independent-document merge is a follow-on.
Unchanged comment ⇒ single BARE range (mirrors bookmarks)
A comment present in both sources whose anchored text is unedited collapses (phase A) to a single bare commentRangeStart/End/commentReference (no w:ins/w:del wrapper) so it survives both accept and reject — the same identity-aware collapse NormalizeBookmarks does. A right-added comment's bare markers (which landed in equal content) are instead wrapped in w:ins (phase A2) so the comment toggles with its side and does not leak into the reject (reject ≡ left); a left-deleted comment's are wrapped in w:del. LibreOffice drops a commentReference whose w:comment definition is missing (its own dangling-comment signal), so the oracle's clean load + refresh-stable comment count is the cross-renderer confirmation that every reference resolves.
Table/Cell Width as Percent-Suffixed String (w:tblW / w:tcW with w:type="pct")
Status: Fixed (2026-05) — Issue #210
Symptom
WmlToHtmlConverter.ConvertToHtml (convertDocxToHtml in the npm wrapper) threw
FormatException — Conversion failed: Format_InvalidStringWithValue, 100% —
for any document whose table or cell width was a percentage.
Minimal XML reproducer
<w:tbl>
<w:tblPr>
<!-- percent-suffixed string form -->
<w:tblW w:w="100%" w:type="pct"/>
</w:tblPr>
<w:tr>
<w:tc>
<w:tcPr><w:tcW w:w="50%" w:type="pct"/></w:tcPr>
<w:p><w:r><w:t>Item</w:t></w:r></w:p>
</w:tc>
</w:tr>
</w:tbl>
The corner case
The w:w attribute on w:tblW / w:tcW has schema type ST_TblWidth
(a union over ST_MeasurementOrPercent + ST_DecimalNumber). Under
w:type="pct" the value may be expressed two schema-valid ways:
| Form | Example | Meaning |
|---|---|---|
| Integer (fiftieths of a percent) | w:w="5000" | 5000 / 50 = 100% |
| Percent-suffixed string | w:w="100%" | a literal 100% |
Microsoft Word writes the integer-fiftieths form. The widely used docx
JavaScript library writes the percent-suffixed string form for
WidthType.PERCENTAGE — both are schema-valid, but Docxodus only handled the
integer form, casting the attribute straight to int. (int)"100%" throws.
Renderer comparison
| Width markup | Word | LibreOffice | Docxodus (before) | Docxodus (after) |
|---|---|---|---|---|
w:w="5000" w:type="pct" | 100% | 100% | width: 100% | width: 100% |
w:w="100%" w:type="pct" | 100% | 100% | throws | width: 100% |
w:w="9000" w:type="dxa" | 450pt | 450pt | width: 450pt | width: 450pt |
Relevant code
Docxodus/WmlToHtmlConverter.cs — ParseTblWidthValue(XAttribute, out bool isExplicitPercent)
centralizes the parse and is called from ProcessTable (table-level w:tblW)
and the cell-processing path (w:tcW). When the raw value ends with %,
isExplicitPercent is set and the number is treated as a literal percentage;
otherwise a pct value is divided by 50 (fiftieths -> percent) as before.
Non-numeric values return null and are skipped instead of throwing.
Tests
Docxodus.Tests/HtmlConverterTablePercentageWidthTests.cs
(HcTablePercentageWidthTests).
DocxDiff: zero-width markers that are NOT diff tokens (bookmarks, field plumbing, soft hyphens)
Symptom
Diffing two DOCX with DocxDiff and editing a paragraph that carries a bookmark, a REF/PAGEREF field,
or a w:noBreakHyphen/w:softHyphen/w:sym produced output where, after accept or reject:
- a
w:bookmarkStart/w:bookmarkEndwas dropped (orphaning the bookmark, dangling everyw:hyperlink @w:anchorandREF/PAGEREF/NOTEREF/HYPERLINK \lreference that targets it), - the same bookmark id was duplicated across the
w:delandw:inscopy (Sem_UniqueAttributeValue), - a whole
REFfield vanished when the text before it was edited, and - a body character was dropped next to a non-breaking/soft hyphen (the reject of a "Company‑Controlled Intellectual" run lost the "I").
The SDK OpenXmlValidator caught only the duplicate id; the dropped marker / dropped field / dropped char are
schema-valid (the validator does not resolve cross-references), so they require a STRUCTURAL round-trip oracle
(bookmark id↔name↔reference integrity + body-text reject ≡ left / accept ≡ right).
The corner case
The IR diff engine reconstructs an edited paragraph by slicing the SOURCE run-level XML at character offsets the token diff decided. A run-level element is one of three kinds with respect to that offset math:
| element | IR / tokenizer treats it as | source slicer must treat it as |
|---|---|---|
w:t text | N chars | N chars (splittable) |
w:bookmarkStart/End, w:fldChar, w:instrText | dropped / 0 chars, NOT a token | 0 chars but ALWAYS emitted |
w:noBreakHyphen/w:softHyphen/w:sym | 1 char of text (e.g. U+2011) | 1 char |
Two distinct bugs followed from violating that table:
- Boundary drop. Because a bookmark/field marker is not a diff token, the token-driven
boundary-ownership flags (
includeStart/EndZeroWidth) were blind to it, so a marker sitting exactly at an edit boundary was claimed by neither adjacent op and disappeared. Fix: flag these markersAlwaysKeepin the slicer (taken anywhere in[start,end]), then reconcile context in post-render passes (NormalizeBookmarks,NormalizeFields). - Off-by-one.
w:noBreakHyphen/w:softHyphen/w:symARE one character in the IR (the reader emits anIrTextRun), so the tokenizer counts them — but the slicer counted them as zero-width. Every such element shifted the slice by one and dropped an adjacent character. Fix: the slicer advances the char counter by one for them, matching the IR.
A bookmark/field present in BOTH documents is unchanged by the edit (only the surrounding text moved), so its
correct representation is a single bare (untracked) pair that survives both accept and reject — NOT a
tracked w:ins/w:del copy. NormalizeBookmarks/NormalizeFields collapse to that; a wholly inserted/deleted
bookmark or field keeps its revision context. Bookmarks nested in opaque content (m:oMath, w:drawing) are
deliberately left untouched — they are part of that element's canonical content hash, so renumbering them would
break reject ≡ left.
Word vs LibreOffice
No genuine Word-vs-LibreOffice divergence was found for bookmark/cross-reference handling: with the fixes,
both the bookmark structural round-trip and (per the lo_bookmark_check.py oracle design) LibreOffice's own
GetReference field resolution agree that every reference resolves. The one non-divergent quirk worth noting:
the strict ECMA-376 schema rejects <w:w w:val="0"> (character scale 0), which the real NVCA COI source carries
65× and which Word writes and tolerates — the diff merely relocates those runs, so it is a source quirk, not a
diff defect (it appears identically when validating the input).
Relevant code
Docxodus/Ir/Diff/IrMarkupRenderer.cs—SourceRunModel(AlwaysKeep/FieldPlumbingKeep, the 1-char hyphen/sym segment),NormalizeBookmarks,NormalizeFields,ExpandFieldForRevision.Docxodus/Ir/IrReader.cs—EmitRunChild(N7/N8:noBreakHyphen/softHyphen/sym→ 1-charIrTextRun).Docxodus/WmlComparer.cs—AddNumberingChildInSchemaOrder(numbering-merge child order).
Tests
Docxodus.Tests/DocxDiffBookmarkStructureTests.cs + DocxDiffBookmarkFixtures.cs (synthetic corpus),
DocxDiffBookmarkRealDocTests.cs (real NVCA COI/SPA), and the bkmk-struct column + lo/lo_bookmark_check.py
oracle in tools/diffharness.
DocxDiff: PreAcceptInputRevisions accept-all flattens prior authorship
Status: Documented (2026-06) — the revisionsInInput campaign.
The corner case
This is not a Word-vs-spec divergence but a lossy-by-design transformation worth pinning, because "just
accept-all both sides, then diff" looks innocent and is not. When an input is itself a redline (carries
un-accepted w:ins/w:del/w:moveFrom), DocxDiff's default already diffs the accepted view (rule N13 —
IrReader runs RevisionView.Accept before building the IR), so the produced body carries only the new
diff's revisions. But the output package is cloned on the LEFT input and only the body (+ changed notes) is
rebuilt, so pre-existing revision markup in carried-over parts (headers/footers, unchanged
footnotes/endnotes, styles, comments) is passed through verbatim. The opt-in
DocxDiffSettings.PreAcceptInputRevisions eliminates that by accepting BOTH whole inputs first (cleaning the
body, headers/footers, notes, and styles — the parts RevisionProcessor.AcceptRevisions processes; a tracked
change inside a comment definition or a glossary/building-blocks entry is NOT touched, since
RevisionProcessor.AcceptRevisions does not process the comments part or the GlossaryDocumentPart) — but
accept-all itself has two honest costs that a caller must understand before enabling it.
Minimal XML reproducer
An input whose header carries a prior reviewer's tracked insertion (identical on both diff sides, so a pure carry-over, not a diff):
<!-- left.docx and right.docx both contain this header part -->
<w:hdr>
<w:p>
<w:r><w:t xml:space="preserve">Header </w:t></w:r>
<w:ins w:id="99" w:author="OldReviewer" w:date="2020-01-01T00:00:00Z">
<w:r><w:t>CONFIDENTIAL</w:t></w:r>
</w:ins>
</w:p>
</w:hdr>
Behavior table
| Setting | Output header | accept(result) header | reject(result) header | Round-trip in header? |
|---|---|---|---|---|
default (PreAcceptInputRevisions = false) | <w:ins author="OldReviewer">CONFIDENTIAL</w:ins> (leaked verbatim) | Header CONFIDENTIAL | Header (leaked ins rejected → text dropped) | No — reject ≠ accept-view(left) |
PreAcceptInputRevisions = true | Header CONFIDENTIAL (plain, accepted) | Header CONFIDENTIAL | Header CONFIDENTIAL | Yes |
Word and LibreOffice behave the same on the outputs — there is no renderer divergence here. The divergence is between the default's leaked, non-round-tripping header and the flag's clean one.
Analysis — the two honest costs of accept-all
Even with the flag fixing the leak/round-trip, accept-all is opinionated and lossy and must not be enabled silently:
- It flattens pre-existing authorship and change boundaries. Accepting collapses each input's own tracked
changes into final text.
OldReviewer(and where their edit began/ended) is gone from the result; the output's authorship reflects only the new diff. You cannot recover "who edited what" afterward. - "Accept all" is itself a policy. Leaving a change in tracked form is how a reviewer defers or rejects it;
accept-all overrides that, materializing every insertion and dropping every deletion regardless of the prior
reviewer's intent. If the inputs' in-flight revisions must be preserved or re-adjudicated, resolve them by an
explicit policy first, then diff — do not reach for
PreAcceptInputRevisions.
The Word-parity alternative — PreserveInputRevisions and the one-sided Reject All
Word's own Compare does neither the default's leak nor the flag's flatten: it preserves the inputs'
pre-existing tracked revisions in the compare output verbatim (original author/date markup intact) while the
text diff is computed over the accepted view. Verified against Word-oracle outputs: an input with 176
revisions by another author keeps them in Word's compare result alongside the fresh compare revisions.
DocxDiffSettings.PreserveInputRevisions reproduces this (equal blocks + whole-block inserts, in the body
and in footnote/endnote bodies, in v1; it WINS
over PreAcceptInputRevisions when both are set, and the DocxCompare engine-selector path enables it).
The Word behavior worth pinning here: Reject All on such an output does NOT restore the left document.
Rejecting a preserved foreign w:del RESTORES its deleted text (text the left side never showed), and
rejecting a preserved foreign w:ins removes text the accepted view carried. Word's Compare output behaves
identically under Reject All — the one-sided round trip (accept ≡ right holds, reject ≠ left where
foreign markup exists) is inherent to preserving input revisions, not a Docxodus defect. Do not "fix" it.
Relevant code
Docxodus/DocxDiff.cs—DocxDiffSettings.PreAcceptInputRevisions+ thePreAccept(...)pre-pass wired into all seven entry points;IrMarkupRenderer.Renderclones the output on the LEFT package (the carry-over source);DocxDiffSettings.PreserveInputRevisions(the Word-parity opt-in, precedence over the pre-accept).Docxodus/Ir/IrReader.cs—ApplyRevisionView(rule N13:RevisionView.Acceptbefore IR build).Docxodus/Ir/Diff/IrMarkupRenderer.cs—BuildPreservedOriginalIndex/NormalizePreservedClone+ the preserve-awareEmitVerbatim/EmitWholeBlock/MarkWholeParagraph/MarkParagraphMark/MarkWholeTable.Docxodus/DocxDiffCompatibility.cs— therevisionsInInputcatalog entry (nowCovered).
Tests
Docxodus.Tests/Ir/Diff/RevisionsInInputDefaultTests.cs (pins the default: clean body + leaking carry-over +
the broken header round-trip), PreAcceptInputRevisionsTests.cs (the flag is the wrapper, no stale
authorship, every-scope round-trip, schema validity, multi-author redline-of-a-redline), and
DocxDiffPreserveInputRevisionsTests.cs (preservation of foreign ins/del in equal + inserted blocks, no
same-kind nesting, the fully-deleted-paragraph ride-along, the pinned reject caveat, precedence).
*PrChange inners are CT_*Base: rejecting a property change must NOT drop what lives outside it
Discovered: 2026-07-03, block-format-change family.
Word's block-level property-revision markers store the OLD properties in an inner element whose type is the …Base variant of the container — which excludes the sibling content that is not part of the tracked property change:
w:pPrChange's innerw:pPrisCT_PPrBase— no paragraph-markw:rPr, and no inlinew:sectPr.w:sectPrChange's innerw:sectPrisCT_SectPrBase— now:headerReference/w:footerReference.
The trap
A naive reject implementation replaces the whole container with the inner:
// WRONG — drops the references / inline sectPr that were never part of the change
if (element.Name == W.sectPr && element.Element(W.sectPrChange) != null)
return element.Element(W.sectPrChange).Element(W.sectPr);
Because the inner is reference-less, rejecting a w:sectPrChange deletes the section's headers and footers; rejecting a w:pPrChange on a section-final paragraph deletes the section break. This is a silent structural loss — the document still validates.
Word's behavior
Word rejects the tracked property change (restores the old page setup / paragraph properties) while keeping the references and the inline w:sectPr intact — they were never revised.
Reject a w:sectPrChange (section had a w:headerReference) | Header reference after reject |
|---|---|
| Word | preserved |
| Docxodus (before fix) | dropped |
| Docxodus (after fix) | preserved |
The fix
RevisionProcessor.RejectRevisionsForPartTransform rebuilds the container: keep the CURRENT out-of-scope children (references for sectPr; the mark w:rPr + inline w:sectPr for pPr), restore the inner's in-scope properties.
Relevant code
Docxodus/RevisionProcessor.cs— thew:sectPr/w:sectPrChangeandw:pPr/w:pPrChangereject branches.Docxodus/Ir/Diff/IrMarkupRenderer.cs—ApplySectPrChange/ApplyBlockFormatChangesproduce the markers with reference-less CT_*Base inners (so they round-trip only with the fix).
Tests
Docxodus.Tests/Ir/Diff/BlockFormatChangeTests.cs — RejectRevisions_preserves_inline_sectPr_when_rejecting_a_pPrChange and SectPrChange_reject_preserves_header_footer_references.
Document metadata: w:sectPr in tables is a section break; w:sectPr in a text box is NOT
Status: Fixed (issue #51)
Symptom
WmlToHtmlConverter.GetDocumentMetadata() reported the wrong number of sections
for documents whose section break lived inside a table cell — it only scanned the
body's direct children, so an in-cell w:sectPr was invisible and the section
count (and the per-section page dimensions / paragraph & table index ranges used
for lazy-loading pagination) were off.
The corner case
A section break is expressed as a w:sectPr in the w:pPr of the paragraph that
ends the section (or the trailing w:body/w:sectPr for the final section).
While Word's UI does not let you place one inside a table, the OOXML is free to
carry a w:sectPr on a paragraph nested in a table cell, and a faithful metadata
scan must find it:
<w:body>
<w:tbl>
<w:tr><w:tc>
<w:p><w:pPr>
<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr> <!-- ends section 1 -->
</w:pPr></w:p>
</w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:t>section 2</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="15840" w:h="12240"/></w:sectPr> <!-- final section -->
</w:body>
The subtlety is what to do with a w:sectPr inside a text box. A text box
(w:txbxContent, reached through w:r/w:pict/v:textbox or the DrawingML
wps:txbx) is a separate story: it is floating content that does not
paginate the main document. A w:sectPr there is meaningless to main-document
pagination, and counting it would invent a phantom section and corrupt the page
dimensions reported for the surrounding content. The same is true of any story
reached only through a run — it must be excluded.
| Placement | Counts as a main-document section? |
|---|---|
w:body/w:p/w:pPr/w:sectPr (body paragraph) | ✅ yes |
w:body/w:sectPr (trailing) | ✅ yes |
w:tbl/w:tr/w:tc/w:p/w:pPr/w:sectPr (table cell) | ✅ yes (this fix) |
…/w:txbxContent/w:p/w:pPr/w:sectPr (text box) | ❌ no — separate story |
| header / footer / footnote / endnote / comment part | ❌ no — separate part, not in the body tree |
The fix
CollectSectionData walks the body as a single main story in document order,
descending into tables (w:tbl → w:tr → w:tc) because a table cell's content is
part of that story, but treating every w:p as a leaf — only its direct
w:pPr/w:sectPr is inspected, never its runs. Because a text box is always nested
inside a run, this one rule excludes text-box section properties (and text-box
paragraphs) automatically, with no special-casing of w:txbxContent. Only
body-level tables count toward the table total (a nested table is part of its
containing cell's content), preserving the pre-fix counts for the common case.
Headers, footers, footnotes, endnotes and comments live in their own parts and are never in the body tree, so they are out of scope by construction.
Relevant code
Docxodus/WmlToHtmlConverter.cs — CollectSectionData (the recursive
WalkMainStory local function).
Tests
Docxodus.Tests/DocumentMetadataTests.cs —
DM022_GetDocumentMetadata_DetectsSectionBreakInsideTableCell (in-cell break is
counted, table attributed to the section it starts in) and
DM023_GetDocumentMetadata_IgnoresSectionPropertiesInsideTextBox (a text-box
w:sectPr does not create a section, and text-box paragraphs are not counted).
Tables: Word's compare backfills a hairline cell-margin/indent on fixed-width tables
Status: Fixed (DocxDiff — WordCompareTableNormalizer)
Symptom
A DocxDiff redline of two documents containing a fixed-width table rendered (in LibreOffice) with every cell's text shifted horizontally versus Microsoft Word's redline of the same pair — the whole table "ghosts" against the oracle, costing large amounts of pixel-diff energy even when the tracked-changes word stream is byte-identical to Word's.
The corner case
A fixed-width table (w:tblW w:type="dxa", cell widths summing to the table width) authored with no
explicit w:tblCellMar relies on the application's default cell margin. Word's and LibreOffice's defaults
disagree: LibreOffice insets cell text by ≈108 twips (its default), which on a fixed layout eats into the
declared column widths, while Word insets by a hairline.
Word's compare output does not leave the margin implicit — it materializes it. Across the Word-compare
corpus, every fixed-width table lacking cell margins came back with w:tblCellMar left/right and a
matching w:tblInd whose value equals the table's border width:
Source tblBorders w:sz | Point size | Materialized tblCellMar/tblInd (twips) |
|---|---|---|
4 | 0.5 pt | 10 |
(w:sz is in eighths of a point; 1 pt = 20 twips, so twips = sz × 2.5.) AUTO-width tables
(type="auto") are not normalized (Word leaves them bare), and a table that already declares
w:tblCellMar is left untouched.
| Renderer | Fixed-width table, no tblCellMar |
|---|---|
| Word (compare output) | inserts tblCellMar/tblInd = border width (hairline) → cells fill the fixed columns |
| LibreOffice (our un-normalized output) | applies its own ≈108-twip default → cell text shifts right, table ghosts |
| Docxodus (after fix) | inserts border-width inset like Word → renders where Word's does |
The fix
WordCompareTableNormalizer.NormalizeAll runs as a single-owner post-pass over the assembled body blocks
in IrMarkupRenderer.Render. For each w:tbl whose w:tblW is dxa, that has a derivable border width
and no declared w:tblCellMar, it inserts w:tblCellMar (left/right) and w:tblInd at the border-width
inset, in CT_TblPrBase schema order. This mirrors the docDefaults backfill (WordStockDocDefaults) — the
engine's job is to reproduce Word's compare output, so its tables must land where Word's do. The inset is
a table property, not tracked-changes markup, so the accept ≡ right / reject ≡ left contract (verified
at body-text level) is unaffected.
Known limitation: on a pathological "repaired" document whose oracle itself normalized only some of its tables, the rule over-fires on the un-normalized ones (no principled source condition distinguishes them). The affected document is a deep residual on other grounds; the net effect across the corpus is a strong improvement with no table rendered worse than the un-normalized default.
Relevant code
Docxodus/Ir/Diff/WordCompareTableNormalizer.cs— the normalization rule.Docxodus/Ir/Diff/IrMarkupRenderer.cs—Renderinvokes the post-pass after block assembly.
Tests
Docxodus.Tests/DocxDiffTableCellMarginBackfillTests.cs — border-width backfill, border-width tracking
(sz="8" → 20), auto-width untouched, existing-margin untouched, no-border untouched.
Tracked changes: author-color leak when input revisions are preserved
Status: Fixed (DocxDiff — DocxDiffSettings.NormalizeRevisionAuthors, opt-in)
Symptom
A DocxDiff redline of a document whose source (the revised side) already carried tracked changes — e.g. Google-Docs "suggestions" authored by Online User, or another reviewer's edits — rendered (in LibreOffice) with that preserved content in a DIFFERENT COLOR from the fresh compare markup, while Microsoft Word's oracle redline of the same pair was a single color. On a page dominated by such content this cost large amounts of pixel-diff energy even though the tracked-changes markup was structurally faithful.
The corner case
LibreOffice colors tracked changes by author: each distinct w:author gets its own color. With
PreserveInputRevisions on (as the Word-parity submission profile uses), the inputs' own revisions
ride through into the output under their original author, and the fresh compare revisions use the
engine's own author — so the output carries two authors and renders in two colors. Word's
compare output, for these documents, is single-author (its oracle carries one w:author), hence one
color.
Empirically across the corpus this is common but not universal — of 42 documents where our output leaked a second author, 29 had single-author oracles (Word collapsed to one) and 13 had genuinely multi-author oracles (Word kept more than one). Because there is no reliable structural signal that distinguishes "Word collapsed" from "Word kept" on the input alone, this is exposed as an opt-in setting, not default behavior.
| Renderer | Source with a preserved foreign-author revision |
|---|---|
| Word (compare output, these docs) | single w:author → one color |
Docxodus (default, PreserveInputRevisions on) | fresh author + preserved author → two colors |
Docxodus (NormalizeRevisionAuthors on) | all revision authors collapsed to one → one color |
The fix
IrMarkupRenderer.NormalizeRevisionAuthors runs as a byte→byte post-pass over the rendered document
when the flag is set: it stamps settings.AuthorForRevisions onto the w:author of every
tracked-revision element (w:ins/w:del/w:moveFrom/w:moveTo and the *Change markers) across the
wordprocessing story parts (document, headers, footers, footnotes, endnotes, comments-part revisions),
leaving w:comment authors untouched (a comment is not a tracked change) and non-wordprocessing parts
(charts, customXml, SmartArt) alone. Author is presentation metadata, so revision structure — and the
accept ≡ right / reject ≡ left contract — is unaffected.
This normalizes the render, not the markup semantics — it matches how an author-coloring renderer displays Word's single-author output, and it is a net-positive approximation (correct for the 29 single-oracle docs, a no-op-or-neutral change for the 13 multi-oracle docs in practice) enabled via an explicit setting. It deliberately does not touch Consolidate/N-way output, whose per-reviewer authors are intentional.
Relevant code
Docxodus/Ir/Diff/IrMarkupRenderer.cs—NormalizeRevisionAuthors/RevisionBearingParts.Docxodus/Ir/Diff/IrDiffSettings.cs,Docxodus/DocxDiff.cs— the flag + public mirror.
Tests
Docxodus.Tests/DocxDiffAuthorNormalizationTests.cs — collapse of a preserved foreign author to the
single author; the leak when the flag is off.
Headers/footers: a first/even part can outlive its w:titlePg / w:evenAndOddHeaders flag
The behavior
A w:headerReference/w:footerReference of type first or even is inert on its own. Word
renders the first-page stories only when the governing w:sectPr carries w:titlePg, and the
even-page stories only when the settings part carries w:evenAndOddHeaders.
The trap is what Word does when the user turns those options back off in the UI ("Different
first page" / "Different odd & even pages"): it removes only the flag. The header/footer parts and
their references stay in the package. A document can therefore carry a complete set of six stories
— default/first/even for both header and footer — with neither flag set, which is exactly the shape
of TestFiles/HC031-Complicated-Document.docx.
Minimal reproducer
<!-- word/document.xml — references present, no w:titlePg -->
<w:sectPr>
<w:headerReference w:type="first" r:id="rId15"/>
<w:headerReference w:type="default" r:id="rId12"/>
<!-- no <w:titlePg/> -->
</w:sectPr>
word/header3.xml (the first part) can hold arbitrary content and no renderer will show it.
Renderer comparison
| Renderer | Result |
|---|---|
| Word | First-page header ignored; the Default header renders on page 1. |
| LibreOffice | Identical — verified by converting to PDF; the first/even content never appears. |
Docxodus (WmlToHtmlConverter, pagination) | Same: selectHeader/selectFooter only pick the first/even story when the flags resolve. |
So all three agree. The hazard is not a rendering divergence — it is that writing content into such a story appears to succeed and silently produces an invisible result.
Why it bites a mutation API
DocxSession.SetHeaderText/SetFooterText set the flags as a side effect of writing content, so
authoring a story from scratch is fine. But a caller that edits an existing first/even story —
via ReplaceText/ApplyFormat on the story's paragraph anchor, which is what an anchor-addressed
editor does — never goes through that path, and the flag is never added. The saved file then
contains the user's text in the right part, rendered nowhere.
Relevant code
Docxodus/DocxSession.cs—EnsureHeaderFooterVisible(the section-level operation that sets the flags independently of a content write);SetHeaderFooterText(the create-time path).Docxodus/WordprocessingMLUtil.cs—EnsureEvenAndOddHeaders(inserts the settings child at its CT_Settings schema slot; see the settings-ordering note above).npm/src/editor-headerfooter.ts— the editor band calls the op wheneverfirst/evenis selected, because selecting that kind is the user asking for a different first/even page.
The second-order surprise (worth surfacing in a UI)
Turning either flag on means those pages stop inheriting the Default stories entirely, and
w:evenAndOddHeaders is document-global and governs footers as well as headers. A section with a
populated Default footer but an empty even footer therefore shows no footer at all on even
pages, and enabling w:titlePg with an empty first-page footer leaves page 1 without one. This is
spec-correct and reproduces identically in Word and LibreOffice; the editor's header/footer bands
show an inline note for both cases.
The render side of the same rule
The flag governs reading too, and our paginated renderer originally got it wrong in the
opposite direction: RenderPaginatedHeaderFooterRegistry gated the first stories on
w:titlePg but emitted the even stories whenever a w:type="even" reference existed, with no
check on w:evenAndOddHeaders.
| Renderer | Even-page footer, reference present, w:evenAndOddHeaders absent |
|---|---|
| Word | Default story |
| LibreOffice 25.8 | Default story |
| Docxodus (before) | Even story |
| Docxodus (after) | Default story |
Found by smoke-testing the NVCA model certificate of incorporation
(https://nvca.org/wp-content/uploads/2025/10/NVCA-Model-COI-10-1-2025.docx), a real filing
template that carries three w:type="even" footer references with the flag absent. Its leftover
even footer reads DRAFT and carries no PAGE field, so the paginated view showed DRAFT — and
therefore no page number at all — on every even page, where LibreOffice showed
Last Updated October 2025 and the roman-numeral page number.
The fix mirrors the existing hasTitlePage gate with a hasEvenAndOddHeaders one, so both stories
are governed by their own flag in the same place.
Tests
Docxodus.Tests/DocxSessionTests.cs — DS268 (flags set for pre-existing stories, idempotent,
lands in the section that carries the reference rather than merely the trailing sectPr);
Docxodus.Tests/PaginatedHeaderFooterGatingTests.cs — PHF001/PHF002 (even stories follow
w:evenAndOddHeaders) and PHF003 (first stories follow w:titlePg, pinned so the two rules
cannot drift apart); npm/tests/editor-headerfooter.spec.ts — the end-to-end assertion over the
saved package.
Endnotes: the default w:numFmt is lowerRoman, not decimal
The behavior
Footnote and endnote markers do not share a default numbering format. With no w:numFmt
declared anywhere — no w:footnotePr/w:endnotePr in the settings part, none in any w:sectPr —
Word and LibreOffice number footnotes 1, 2, 3… but endnotes i, ii, iii… (lowercase roman).
ECMA-376 pins this: w:numFmt's default is decimal in a footnote context (§17.11.17/§17.11.18)
but lowerRoman in an endnote context (§17.11.17/§17.11.19). A typical Word-authored package
carries a w:endnotePr in settings.xml that declares only the two reserved separator notes and
no w:numFmt at all, so the spec default is what actually renders.
Precedence when the format IS declared: a w:numFmt inside a section's w:sectPr/w:endnotePr
overrides the document-wide declaration in settings.xml for that section.
Minimal reproducer
<!-- word/settings.xml — Word's usual shape: endnotePr present, numFmt absent -->
<w:endnotePr>
<w:endnote w:id="-1"/>
<w:endnote w:id="0"/>
</w:endnotePr>
Cite one endnote from the body (TestFiles/WC/WC036-Endnote-With-Table-Before.docx is exactly
this shape).
Renderer comparison
| Renderer | Endnote marker | Footnote marker |
|---|---|---|
| Word | i | 1 |
| LibreOffice | i | 1 |
| Docxodus (before fix) | 1 | 1 |
| Docxodus (issue #414 fix) | i | 1 |
Relevant code
WmlToHtmlConverter.GetNoteNumberFormat resolves the effective token (sectPr-level notePr →
settings-part notePr → spec default), FormatNoteNumber renders the glyph via
ListItemTextGetter_Default.GetListItemText, and the footnotes/endnotes section <ol> carries
the matching CSS list-style-type (NoteListStyleType).
Layout: Word's line breaking and table sizing have no matching CSS default
The behavior
Two of Word's layout invariants are the OPPOSITE of what CSS does by default, so an HTML rendering of a DOCX gets them wrong unless the converter says otherwise.
-
A word wider than its column is broken, not overflowed. Word and LibreOffice put as much of an over-long word on the line as fits and break the rest. CSS's initial
overflow-wrap: normalnever breaks inside a word, so it runs past the margin instead. -
A table never exceeds the text column. Word's table layout — fixed or AutoFit — keeps the table inside the column, narrowing columns when it must. CSS's
table-layout: autodoes the reverse: a cell's widest unbreakable word is a min-content floor, and the table is GROWN until that floor is satisfied, container be damned. A table box is not shrinkable below it.
The two compound. Enlarging a run inside a fixed-width cell raises the cell's min-content, which widens the table, which overflows the page — the visible symptom being document text painted outside the sheet and clipped by the window.
Minimal reproducer
<w:tbl>
<w:tblPr>
<w:tblW w:w="9936" w:type="dxa"/> <!-- 496.8pt: a full-width US Letter table -->
</w:tblPr>
<w:tblGrid><w:gridCol w:w="9936"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r>
<w:rPr><w:sz w:val="132"/></w:rPr> <!-- 66pt -->
<w:t>Edit this document.</w:t>
</w:r></w:p></w:tc></w:tr>
</w:tbl>
Rendered into a 354pt-wide container (a phone):
| Renderer | Result |
|---|---|
| Word | Table at the text column width; "document." breaks or wraps inside it |
| LibreOffice | Same; the page is zoomed to fit the window, never reflowed narrower |
| Docxodus (before) | <table> grown to ~462pt by the cell's min-content; text clipped by the window |
| Docxodus (after) | Table at the column width, word wrapped, page zoomed to fit |
Analysis
CSS has no single property for "lay out like a word processor". The behaviors have to be assembled:
overflow-wrap: break-wordgives Word's line breaking, but deliberately does NOT change intrinsic sizing — which is why it alone does not stop the table from growing. (Chrome's UA stylesheet sets it on[contenteditable], which is why an editable paragraph appears to break correctly while the table around it still overflows.)overflow-wrap: anywhereDOES lower min-content, so it is the right rule for table cells.table-layout: fixed(with the column widths in acolgroup) is the analogue of Word'sw:tblLayoutfixed: authored widths become binding and content wraps inside them.max-width: 100%enforces "a table never exceeds the text column" for the AutoFit case.
None of it helps if the container is the wrong width to begin with: the text column must come
from w:sectPr, and a window narrower than the page must ZOOM, the way every word processor
does, rather than reflow.
Relevant code
Docxodus/WmlToHtmlConverter.cs — GenerateDocumentLayoutCss (always emitted, ahead of the
caller's GeneralCss so a consumer can still override), IsFixedLayoutTable/CreateColGroup
(ProcessTable), and CreateSectionDivs, which stamps section geometry in every render mode
rather than only under PaginationMode.Paginated. npm/src/page-geometry.ts reads that geometry;
npm/src/viewport.ts (DocumentViewport) applies the column width and the fit-to-width zoom.
Tests
Docxodus.Tests/HtmlConverterTests.cs — HC056 (layout CSS always emitted), HC057 (section
geometry outside Paginated mode), HC058/HC059 (fixed vs AutoFit table layout);
npm/tests/editor-page-geometry.spec.ts — the end-to-end assertion that a 66pt heading on a
390px viewport leaves nothing overflowing the sheet.
Package Output
Misleading Deflate Hints Cause Compression Loss
Status: Fixed (August 2026)
Issue: #331
Test: Docxodus.Tests/PackageCompressionTests.cs (PKG331–PKG334)
The problem
Word-authored OPC packages commonly set bits 1–2 of each ZIP entry's general-purpose flag to the "superfast" deflate hint, even when the existing compressed bytes have a high compression ratio. That hint is not evidence of how efficiently the current bytes were actually compressed.
.NET 10's ZipArchive update-mode constructor maps those bits back to a compression policy for a
future rewrite: normal → Optimal, maximum → SmallestSize, and both fast/superfast → Fastest.
System.IO.Packaging preserves unchanged entries efficiently, but an XML part opened for writing
inherits the source entry's policy. A normal Open XML save can therefore keep every unchanged
binary part intact while recompressing changed XML with Fastest.
On HC031-Complicated-Document.docx, a representative 25-part fixture, the pre-fix session save
showed the characteristic disproportion:
| Scope | Before save | Pre-fix output | Uncompressed change |
|---|---|---|---|
word/document.xml compressed bytes | 19,290 | 24,219 | +742 |
| Whole package bytes | 42,336 | 51,491 | about +800 total |
The package grew by 9 KB even though the actual XML grew by less than 1 KB. Recompressing the exact
same output payloads with an explicit policy produced 37,101 bytes at Optimal and 36,724 bytes at
SmallestSize, isolating compression selection as the cause.
Output policy
ZipPackageOutputNormalizer runs only after the owning Package/OpenXmlPackage has finished
writing an output. Byte-exact clone and no-op comparison paths return the cloned package directly,
so they neither change ZIP representation nor pay the finalization cost. For modified output, the
normalizer builds a fresh archive in one streaming pass and:
- copies every entry payload without parsing or reserializing it, preserving content types, relationship parts, signature parts, macros, and all other OPC semantics;
- preserves entries as stored when their completed source representation got no benefit from compression (the normal case for JPEG/PNG and similar media);
- uses
CompressionLevel.Optimalfor frequently written package markup, balancing size and save latency, andSmallestSizefor compressible binary assets such as embedded fonts; - preserves entry names, order, timestamps, comments, and existing external attributes; and
- assigns sane Unix permissions to zero-attribute entries, subsuming the issue #302 output pass so the archive is rewritten only once.
This is deliberately an output-boundary policy, not a byte-header patch or reflection workaround.
Changing ZIP flags on a live package would put its archive bookkeeping out of sync, while setting
OpenXmlPackage.CompressionOption controls only newly created parts and cannot repair existing
parts rewritten in update mode.
Performance tradeoff
Finalization performs one sequential inflate/copy/deflate pass and holds the source and destination
archives in memory. That costs more CPU than returning the update-mode ZIP directly, but the
latency-sensitive XML path uses Optimal, stored media avoids compression work, and the pass
replaces the previous separate Unix-metadata rewrite. The cost is bounded to final output
production; editing, projection, undo, and intermediate operations are unchanged. This policy
favors storage and transfer efficiency for batch-produced documents without paying maximum
compression cost on every XML save.
Paragraph Layout
w:lineRule="auto" is a multiple of the FONT's line box, not of font-size
Symptom
Every line of body text sits too close to the next, and the error compounds down the page. It is
invisible in a single line and obvious after twenty. Where the laid-out text height is a
rendered dimension — a spAutoFit DrawingML textbox, whose height is its content — the same error
surfaces as a visibly undersized box.
Minimal XML reproducer
<w:pPr>
<w:spacing w:line="259" w:lineRule="auto"/>
</w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Calibri"/><w:sz w:val="22"/></w:rPr><w:t>…</w:t></w:r>
w:line="259" with w:lineRule="auto" is Word's own default for documents created since Word
2013, so this is the common case, not an exotic one.
The corner case
ECMA-376 defines w:lineRule="auto" as specifying line spacing in 240ths of a line. The
ambiguity is what "a line" means, and it is not the font size: it is the font's own single-line
height — ascent + descent + line gap, from the font's metrics.
CSS has no equivalent base. Both line-height: 107.9% and line-height: 1.079 resolve against
font-size, i.e. the em square. For Calibri (and its metric-compatible substitute Carlito) the
font's natural line box is ≈1.22 em, so a percentage translation under-measures every line by
that ratio — about 19% — regardless of how faithfully the 259/240 arithmetic itself is done.
At 11pt (14.667px):
| Model | Line height |
|---|---|
line-height: 107.9% (percentage of em square) | 15.69px |
| 1.0792 × the font's line box (1.22 em ≈ 17.91px) | 19.33px |
| LibreOffice, measured | 19.33px |
Note that w:line="240" (exactly single) is the special case where doing nothing is right: CSS
line-height: normal already is the font's natural line box. The error only appears once the
multiplier differs from 1 — which is precisely Word's modern default.
Renderer comparison
| Renderer | 11pt Calibri, w:line="259" w:lineRule="auto" |
|---|---|
| Word | multiple of the font's single-line height |
| LibreOffice | 19.33px line advance (measured from PDF text extents at 96 DPI) |
| Docxodus (before) | 15.69px — line-height: 107.9% |
| Docxodus (after) | 19.33px — line-height: normal + calc(1lh * 1.079) |
The fix
CSS's lh unit is the missing base. The paragraph keeps line-height: normal, so 1lh on each
direct inline child resolves to the browser's native line box for that paragraph's font, and
calc(1lh * var(--docx-auto-line-spacing)) multiplies it. Applying it to the children rather than
the paragraph avoids a self-reference in the paragraph's own line-height. Nothing font-specific
is hard-coded, so the result follows whatever font actually resolves.
A child that already declares an explicit line-height is skipped — the compacted pieces of a
w:br carry line-height: 0 precisely so they cannot contribute a line box.
Relevant code
Docxodus/WmlToHtmlConverter.cs—CreateStyleFromSpacing(derives the multiplier),ApplyAutomaticLineSpacingToInlineContent(applies it),DefineParagraphStyle(enables it).
Tests
Docxodus.Tests/HtmlConversionOpsTests.cs—HCO073_PointSuffixedAutoLineSpacing_NormalizesToTwipsBeforeDerivingCssnpm/tests/drawing-autofit-height.spec.ts— the auto-fit textbox height that this drivesnpm/tests/toc-line-geometry.spec.ts— TOC entry line boxes
History
PR #372 introduced the native-line-box model but enabled it only for empty paragraph marks, which is what pagination parity needed at the time; populated paragraphs kept the percentage fallback. Issues #396 (DrawingML textbox auto-fit height) and #397 (TOC line height) were both traced to that remaining fallback.
An accumulated line-spacing error can resemble a top-margin deviation
Symptom
In DB012-Lists-With-Different-Numberings.docx, later list lines once appeared about 28px lower
in Docxodus than in LibreOffice. Because the section declares a 1701-twip top margin and the first
content is a list, the shift was initially attributed to different top-margin import behavior.
Relevant XML
<w:sectPr>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1701" w:right="1134" w:bottom="1701" w:left="1134"
w:header="708" w:footer="708" w:gutter="0"/>
</w:sectPr>
At 96 DPI, 1701 twips is 113.4px. Glyph ink begins a few pixels below that content edge because the ink bounds measure the font's painted pixels, not the top of its line box.
Renderer comparison
The fixture was rendered through Microsoft Graph's Word DOCX-to-PDF conversion and rasterized under the benchmark's 96-DPI contract. The current Docxodus and LibreOffice artifacts use the same page size and font-substitution contract.
| Renderer | Page (px) | First ink row | Last ink row |
|---|---|---|---|
| Microsoft Graph Word conversion | 794 × 1123 | 117 | 365 |
| LibreOffice 25.8.7.3 | 794 × 1123 | 117 | 365 |
| Docxodus | 794 × 1123 | 118 | 365 |
The one-row first-glyph difference is rasterization, not layout. Docxodus and LibreOffice have exact tolerant ink geometry (F1 1.00000), while Word independently confirms the same page-top position. There is no top-margin deviation to emulate or renderer fix to make.
Analysis
The apparent 28px displacement grew with each line. That is the signature of the automatic line
spacing defect described above, not a constant page-origin offset. Once w:lineRule="auto" was
measured against the font's native line box, the accumulated displacement disappeared without any
change to w:pgMar handling. The numbered-lists corpus case is therefore an environment
residual (substituted-font rasterization), not a reference-deviation.
Evidence and tests
npm/tests/visual-parity/word-reference.jsonrecords the Word page geometry, ink bounds, fixture hash, capture environment, and first-line measurement.npm/tests/visual-parity-word-reference.spec.tsvalidates that the corpus disposition cannot cite Word evidence unless the corresponding measurement is committed.npm/tests/visual-parity/ratchet.jsonrecords the current Docxodus/LibreOffice F1 of 1.00000.
Cached TOC field results suppress hyperlink presentation
Symptom
A cached table of contents rendered blue and underlined in Docxodus while both Word and LibreOffice rendered its entries black. An ordinary hyperlink using the same paragraph and character styles remained blue and underlined in Word.
Minimal XML reproducer
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText> TOC \o "1-3" \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:hyperlink w:anchor="_Toc425251205">
<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
<w:t>The first heading</w:t></w:r>
</w:hyperlink>
<!-- cached entries may span paragraphs -->
<w:r><w:fldChar w:fldCharType="end"/></w:r>
with, in styles.xml:
<w:style w:type="character" w:styleId="Hyperlink">
<w:name w:val="Hyperlink"/><w:basedOn w:val="DefaultParagraphFont"/>
<w:rPr><w:color w:val="0563C1" w:themeColor="hyperlink"/><w:u w:val="single"/></w:rPr>
</w:style>
The corner case
w:hyperlink alone does not imply an appearance. Here the run explicitly references the
Hyperlink character style, which declares blue and underline, but Word applies a higher-level
presentation rule to hyperlinks in the cached result of a complex TOC field. That field context,
not the TOC1 paragraph style or the anchor name, suppresses those two properties.
The Word-reference capture of HC022-Table-Of-Contents.docx was rasterized at 96 DPI. In the TOC
entry region (90,135)–(730,220), Word contains 0 blue pixels, while blue content elsewhere on
the same page rules out a global color or export artifact.
| Context | Word presentation |
|---|---|
Hyperlink inside the cached TOC result | Underlying TOC color; no underline |
Ordinary hyperlink with the same TOC1 + Hyperlink styles | #0563C1; underlined |
Analysis
The renderer already annotates every OOXML element with its enclosing complex-field stack before
HTML transformation. FieldRetriever now recognizes TOC and exposes whether a run belongs to its
cached result. Run styling then removes color and only the underline decoration for a
w:hyperlink in that context. Removing the properties instead of forcing black preserves an
intentional color supplied by the underlying TOC paragraph/run formatting.
Relevant code
Docxodus/FieldRetriever.cs— parsesTOCand identifies its cached result across paragraphs.Docxodus/WmlToHtmlConverter.cs— applies cached-field presentation after normal run-style resolution.
Tests
Docxodus.Tests/FieldRetrieverTests.cs— pins cross-paragraph result scope and proves it ends atw:fldCharType="end".npm/tests/toc-line-geometry.spec.ts— uses an actual cachedTOCfield plus an ordinary same-style hyperlink control; it pins both presentation contexts and unchanged line geometry.
A trap when reducing this to a generated document
A programmatically built package needs a DocumentSettingsPart for character-style resolution
to run at all. Without word/settings.xml, the converter still emits the style's CSS class on the
run but generates an empty rule for it, so w:rStyle silently loses every declared property and
the reduced case appears to pass without exercising suppression. This is the same requirement
CLAUDE.md notes for programmatic .NET test documents.
Theme Colors
w:color/w:fill are a CACHE; w:themeColor/w:themeFill are the authority
Symptom
None, in any file Word wrote — which is exactly what makes it worth documenting. Word rewrites the cached literal whenever it applies a theme, so the two always agree and a renderer that reads the wrong one is indistinguishable from a correct one. The divergence only surfaces in a document whose theme was replaced without the caches being rewritten (a template swap, a programmatic edit, or a producer other than Word).
Minimal XML reproducer
A table style declaring the same accent colour twice — once as a fill, once as a border — with a deliberately stale cache on both:
<w:tblBorders>
<w:top w:val="single" w:sz="4" w:color="0000FF" w:themeColor="accent5" w:themeTint="99"/>
<!-- … -->
</w:tblBorders>
<w:tblStylePr w:type="firstRow">
<w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="FF0000" w:themeFill="accent5"/></w:tcPr>
</w:tblStylePr>
with accent5 = 4472C4 in theme1.xml. A conforming consumer paints the header #4472C4 and the
border #8EAADB (accent5 at tint 0x99), ignoring both stale literals.
The corner case
ECMA-376 treats the theme reference as authoritative and the w:color/w:fill attribute as the
last computed value, retained so a consumer that cannot resolve themes still has something to draw.
Word's tint formula is value × tint + 255 × (1 − tint), floored — 4472C4 at tint 0x99 (153/255)
gives 8EAADB, and at tint 0x33 (51/255) gives D9E2F3, which is exactly what a real file's cache
contains.
Docxodus resolved this correctly for run colour and for shading, but border colour read the literal, so one table style could derive the same accent colour from two different sources. Fixed; the two paths now agree.
Renderer comparison
Measured on HC029-Table-Merged-Cells.docx, whose colours come entirely from the
Grid Table 4 Accent 5 style:
| Renderer | Header fill | Band fill | Border |
|---|---|---|---|
| Word | accent5 | accent5 tint 33 | accent5 tint 99 |
| LibreOffice | #4472C4 | #D9E2F3 | #8EAADB |
| Docxodus | #4472C4 | #D9E2F3 | #8EAADB |
All three agree, because that file's cache is in sync — which is why the tracked benchmark case could not decide the question and a generated one had to.
A second requirement the reduced case exposed
Word stamps every w:tr/w:tc with a w:cnfStyle listing the conditional formats that apply to
it. Docxodus applies table-style conditional formatting (w:tblStylePr for firstRow, band1Horz,
…) from those hints rather than deriving band membership from w:tblLook and the row index, so a
hand-authored table without them renders with no header or band shading at all. Real files
always carry them; a generated regression must emit them too.
Relevant code
Docxodus/WmlToHtmlConverter.cs—ResolveThemeColor/ApplyTintShade, used byCreateStyleFromShd, run colour, and (now)GenerateBorderStyle.
Tests
npm/tests/table-style-color.spec.ts— a generated table whose cached literals disagree with the theme, asserting the theme wins for header fill, band fill, and border colour independently.
Contributing
When adding new corner cases to this document:
- Provide a minimal reproducer: Include the relevant XML snippets and a description of how to reproduce
- Document all renderers: Test in Word, LibreOffice, and Docxodus
- Reference the spec: Link to relevant ECMA-376 sections
- Identify the code: Point to the specific Docxodus files/functions involved
- Propose a fix: If possible, outline how the issue might be resolved