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

  1. Numbering and Lists
  2. Footnotes
  3. Package Output
  4. Paragraph Layout
  5. Theme Colors
  6. Contributing

Numbering and Lists

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

RendererItem 4 OutputNotes
Microsoft Word4.Period at end, not middle
LibreOffice Writer4.Matches Word
LibreOffice HTML export4Uses <ol start="4">
Docxodus (current)3.4Incorrect - 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:

  1. Detecting that item 4 at ilvl=1 is an "orphan" (no proper parent-child nesting)
  2. Falling back to level 0's format %1. but using the level 1 counter (4)
  3. 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:

  1. For ilvl=1, looping from level 0 to level 1
  2. For level 0: inheriting the counter from the previous paragraph (3)
  3. For level 1: using the start value (4)
  4. Result: levelNumbers = [3, 4]
  5. Format %1.%2 produces: "3" + "." + "4" = "3.4"

Word appears to use different logic where:

  • The %1 token 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

  1. Orphan nesting detection: Word may detect that para 4 at ilvl=1 doesn't have a proper parent-child relationship with para 3 at ilvl=0 (they're effectively siblings in a flat list that happens to use different levels).

  2. Level entry tracking: Word may only include %N tokens in the output when level N has been "entered" as part of the current nesting chain, not just referenced from previous items.

  3. Start value heuristics: When a level's start value (4) suggests continuation of an overall sequence, Word may apply special formatting rules.

Relevant Code

  • Docxodus/ListItemRetriever.cs:
    • FormatListItem() (lines 1100-1144): Processes lvlText format tokens
    • Level number calculation (lines 980-1079): Builds levelNumbers array
// 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:

  1. It's the first paragraph at this level in the current sequence, AND
  2. The level's start value 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:

  1. Docxodus/ListItemRetriever.cs:

    • Added ContinuationInfo annotation class to track continuation state per paragraph
    • Added GetEffectiveLevel() helper method that returns 0 for continuation patterns
    • In InitializeListItemRetriever, after calculating levelNumbers:
      // 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
  2. Docxodus/FormattingAssembler.cs:

    • NormalizeListItemsTransform: Uses GetEffectiveLevel() to get list item level's rPr
    • ParaStyleParaPropsStack: Uses GetEffectiveLevel() to yield correct level's pPr and rPr
    • AnnotateParagraph: Uses GetEffectiveLevel() 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

  1. Standard multi-level list with proper nesting (1., 1.1, 1.2, 2., 2.1)
  2. "Orphan" nesting like the NVCA example (1., 2., 3., then jump to level 1)
  3. Legal numbering (isLgl) vs non-legal numbering behavior
  4. Various start values 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

RendererAlphaBravo (deleted)Charlie
Microsoft Word (All Markup)1.2. (struck)2.
LibreOffice Writer (Show Changes)1.2. (struck)2.
Docxodus before fix1.2. (struck)3.
Docxodus after fix1.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; ParagraphMarkIsDeleted paragraphs get their LevelNumbers annotation (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 renders w:numberingChange as an old/new marker pair.
  • IrMarkupRenderer.StampResolvedNumberingChange — compares the IR reader's already-resolved left/right markers and emits w:numberingChange when 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:

  1. w:id is a reference identifier, NOT the display number

    • The w:id attribute on <w:footnoteReference> links to the footnote definition in footnotes.xml
    • IDs 0 and 1 are reserved for separator and continuationSeparator types
    • Content footnotes typically start at ID 2
  2. 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:id value
  3. w:customMarkFollows attribute

    • 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:

  1. Before conversion, scan the document for all footnoteReference and endnoteReference elements in document order
  2. Build a mapping from XML ID to sequential display number (1, 2, 3...)
  3. Store the mapping as an annotation on the root element
  4. Use the mapping when rendering footnote references (superscripts) and footnote list items

Code changes in Docxodus/WmlToHtmlConverter.cs:

  • Added FootnoteNumberingTracker class (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


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?
WordN/A (Word never produces the collision; it keeps content ids ≥ 2 disjoint from reserved)
LibreOfficeSilently 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 FormatExceptionConversion 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:

FormExampleMeaning
Integer (fiftieths of a percent)w:w="5000"5000 / 50 = 100%
Percent-suffixed stringw: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 markupWordLibreOfficeDocxodus (before)Docxodus (after)
w:w="5000" w:type="pct"100%100%width: 100%width: 100%
w:w="100%" w:type="pct"100%100%throwswidth: 100%
w:w="9000" w:type="dxa"450pt450ptwidth: 450ptwidth: 450pt

Relevant code

Docxodus/WmlToHtmlConverter.csParseTblWidthValue(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:bookmarkEnd was dropped (orphaning the bookmark, dangling every w:hyperlink @w:anchor and REF/PAGEREF/NOTEREF/HYPERLINK \l reference that targets it),
  • the same bookmark id was duplicated across the w:del and w:ins copy (Sem_UniqueAttributeValue),
  • a whole REF field 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:

elementIR / tokenizer treats it assource slicer must treat it as
w:t textN charsN chars (splittable)
w:bookmarkStart/End, w:fldChar, w:instrTextdropped / 0 chars, NOT a token0 chars but ALWAYS emitted
w:noBreakHyphen/w:softHyphen/w:sym1 char of text (e.g. U+2011)1 char

Two distinct bugs followed from violating that table:

  1. 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 markers AlwaysKeep in the slicer (taken anywhere in [start,end]), then reconcile context in post-render passes (NormalizeBookmarks, NormalizeFields).
  2. Off-by-one. w:noBreakHyphen/w:softHyphen/w:sym ARE one character in the IR (the reader emits an IrTextRun), 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.csSourceRunModel (AlwaysKeep/FieldPlumbingKeep, the 1-char hyphen/sym segment), NormalizeBookmarks, NormalizeFields, ExpandFieldForRevision.
  • Docxodus/Ir/IrReader.csEmitRunChild (N7/N8: noBreakHyphen/softHyphen/sym → 1-char IrTextRun).
  • Docxodus/WmlComparer.csAddNumberingChildInSchemaOrder (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

SettingOutput headeraccept(result) headerreject(result) headerRound-trip in header?
default (PreAcceptInputRevisions = false)<w:ins author="OldReviewer">CONFIDENTIAL</w:ins> (leaked verbatim)Header CONFIDENTIALHeader (leaked ins rejected → text dropped)Noreject ≠ accept-view(left)
PreAcceptInputRevisions = trueHeader CONFIDENTIAL (plain, accepted)Header CONFIDENTIALHeader CONFIDENTIALYes

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:

  1. 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.
  2. "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.csDocxDiffSettings.PreAcceptInputRevisions + the PreAccept(...) pre-pass wired into all seven entry points; IrMarkupRenderer.Render clones 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.csApplyRevisionView (rule N13: RevisionView.Accept before IR build).
  • Docxodus/Ir/Diff/IrMarkupRenderer.csBuildPreservedOriginalIndex / NormalizePreservedClone + the preserve-aware EmitVerbatim/EmitWholeBlock/MarkWholeParagraph/MarkParagraphMark/MarkWholeTable.
  • Docxodus/DocxDiffCompatibility.cs — the revisionsInInput catalog entry (now Covered).

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 inner w:pPr is CT_PPrBase — no paragraph-mark w:rPr, and no inline w:sectPr.
  • w:sectPrChange's inner w:sectPr is CT_SectPrBaseno w: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
Wordpreserved
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 — the w:sectPr/w:sectPrChange and w:pPr/w:pPrChange reject branches.
  • Docxodus/Ir/Diff/IrMarkupRenderer.csApplySectPrChange/ApplyBlockFormatChanges produce the markers with reference-less CT_*Base inners (so they round-trip only with the fix).

Tests

Docxodus.Tests/Ir/Diff/BlockFormatChangeTests.csRejectRevisions_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.

PlacementCounts 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.csCollectSectionData (the recursive WalkMainStory local function).

Tests

Docxodus.Tests/DocumentMetadataTests.csDM022_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:szPoint sizeMaterialized tblCellMar/tblInd (twips)
40.5 pt10

(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.

RendererFixed-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.csRender invokes 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.

RendererSource 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.csNormalizeRevisionAuthors / 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

RendererResult
WordFirst-page header ignored; the Default header renders on page 1.
LibreOfficeIdentical — 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.csEnsureHeaderFooterVisible (the section-level operation that sets the flags independently of a content write); SetHeaderFooterText (the create-time path).
  • Docxodus/WordprocessingMLUtil.csEnsureEvenAndOddHeaders (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 whenever first/even is 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.

RendererEven-page footer, reference present, w:evenAndOddHeaders absent
WordDefault story
LibreOffice 25.8Default 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.csDS268 (flags set for pre-existing stories, idempotent, lands in the section that carries the reference rather than merely the trailing sectPr); Docxodus.Tests/PaginatedHeaderFooterGatingTests.csPHF001/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

RendererEndnote markerFootnote marker
Wordi1
LibreOfficei1
Docxodus (before fix)11
Docxodus (issue #414 fix)i1

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.

  1. 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: normal never breaks inside a word, so it runs past the margin instead.

  2. 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: auto does 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):

RendererResult
WordTable at the text column width; "document." breaks or wraps inside it
LibreOfficeSame; 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-word gives 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: anywhere DOES lower min-content, so it is the right rule for table cells.
  • table-layout: fixed (with the column widths in a colgroup) is the analogue of Word's w:tblLayout fixed: 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.csGenerateDocumentLayoutCss (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.csHC056 (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:

ScopeBefore savePre-fix outputUncompressed change
word/document.xml compressed bytes19,29024,219+742
Whole package bytes42,33651,491about +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:

  1. copies every entry payload without parsing or reserializing it, preserving content types, relationship parts, signature parts, macros, and all other OPC semantics;
  2. preserves entries as stored when their completed source representation got no benefit from compression (the normal case for JPEG/PNG and similar media);
  3. uses CompressionLevel.Optimal for frequently written package markup, balancing size and save latency, and SmallestSize for compressible binary assets such as embedded fonts;
  4. preserves entry names, order, timestamps, comments, and existing external attributes; and
  5. 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):

ModelLine 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, measured19.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

Renderer11pt Calibri, w:line="259" w:lineRule="auto"
Wordmultiple of the font's single-line height
LibreOffice19.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.csCreateStyleFromSpacing (derives the multiplier), ApplyAutomaticLineSpacingToInlineContent (applies it), DefineParagraphStyle (enables it).

Tests

  • Docxodus.Tests/HtmlConversionOpsTests.csHCO073_PointSuffixedAutoLineSpacing_NormalizesToTwipsBeforeDerivingCss
  • npm/tests/drawing-autofit-height.spec.ts — the auto-fit textbox height that this drives
  • npm/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.

RendererPage (px)First ink rowLast ink row
Microsoft Graph Word conversion794 × 1123117365
LibreOffice 25.8.7.3794 × 1123117365
Docxodus794 × 1123118365

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.json records the Word page geometry, ink bounds, fixture hash, capture environment, and first-line measurement.
  • npm/tests/visual-parity-word-reference.spec.ts validates that the corpus disposition cannot cite Word evidence unless the corresponding measurement is committed.
  • npm/tests/visual-parity/ratchet.json records the current Docxodus/LibreOffice F1 of 1.00000.

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.

ContextWord presentation
Hyperlink inside the cached TOC resultUnderlying 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 — parses TOC and 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 at w:fldCharType="end".
  • npm/tests/toc-line-geometry.spec.ts — uses an actual cached TOC field 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:

RendererHeader fillBand fillBorder
Wordaccent5accent5 tint 33accent5 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.csResolveThemeColor / ApplyTintShade, used by CreateStyleFromShd, 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:

  1. Provide a minimal reproducer: Include the relevant XML snippets and a description of how to reproduce
  2. Document all renderers: Test in Word, LibreOffice, and Docxodus
  3. Reference the spec: Link to relevant ECMA-376 sections
  4. Identify the code: Point to the specific Docxodus files/functions involved
  5. Propose a fix: If possible, outline how the issue might be resolved