Skip to content

fix(docx): read a table's cells, headers and list runs; check page references - #906

Merged
DemchaAV merged 1 commit into
2.5-devfrom
fix/docx-composed-cell-passes
Oct 15, 2026
Merged

DemchaAV merged 1 commit into
2.5-devfrom
fix/docx-composed-cell-passes

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Why

Before writing the body, the DOCX export read the document's tree three times:

  • to pick the faces it ships;
  • to find the outline levels that need a HeadingN style;
  • to find the anchors that get a bookmark, which decided whether a page reference is a live PAGEREF field.

All three walked node.children(). A table keeps a cell built from a node (DocumentTableCell.node(...)) in its rows, not in its children, so every such cell was invisible to these passes.

Faces. A face set only in a composed cell was named on its runs and not shipped, so a reader without it installed saw a substitute. In the corpus:

  • rota-cobalt ships no Spectral at all;
  • cv-slate_orange lacks Fira Sans Condensed Bold;
  • cv-violet_grid lacks Lato Bold.

The font table also skipped page zones and header/footer bands of text (their parts are not written from the tree), a page reference's and a page number's style, and the runs of a list's items and markers.

Headings. A heading in a composed cell named a HeadingN style that styles.xml did not define. Measured in Word, the paragraph reads as Normal at outline level 10 (body text), outside the Navigation Pane.

Page references. A page reference to an anchor in a composed cell was written as a number that an edit does not update. Its note said the anchor had no bookmark, although the cell writes one. A tree walk cannot say which anchors get a bookmark anyway. A section holding only shapes the export draws writes no paragraph to put one round, yet the walk counted its anchor, so its field would read "Error! Bookmark not defined." the first time Word updated it. A stack holding only drawing inside a composed cell was read as an overlay; the writer writes it as a stack of one layer, rule and bookmark included.

What changed

  • DocxWrittenTree.under(node) returns a node's children plus, for a table, each composed cell's content, row by row. collectOutlineLevels and DocxFontTable.collectFonts now walk it. weighTextStyles, which picks Normal, already read composed cells and is unchanged.

  • Font table. DocxFontTable.write takes every section's page-zone content and band styles. The zone and band writers build these through the same helpers (zoneContentOf, bandStyleOf), so the font table reads exactly what is written. collectFonts also reads:

    • PageReferenceNode and PageFieldNode styles;
    • a list's marker runs;
    • the runs of every item and item marker, nested ones included.

    A list's runs are read after its own style, so a document with nothing new to ship keeps its font-table order.

  • Page references. bookmarkedAnchorsIn is gone. Every page reference is written as a PAGEREF field and recorded. Once every section is written, unfieldTheReferencesWithoutABookmark checks each field against the bookmarks the body actually wrote (openAnchor, bookmarkAround), by name and by the anchor each was written for, since two anchors can be minted one name. A field whose bookmark is missing loses its field marks and code but keeps its text and style. Its note goes where the reference's note stands, or would stand, and says the reference is text. DocxExportReport.Builder gains set and insert to do this.

    • A layer-stack column's anchor note now says that a link to it names a missing bookmark and that a page reference to it is written as the number it reads. Before, it said the reference named the missing bookmark too, which no longer happens.
  • Docs. The recipe now says where shipped faces come from and when a reference is checked. The CHANGELOG entry is under 2.5.0 Public API.

Verification

  • New tests:
    • DocxFontTableTest: faces from a composed cell and from a nested table's cell; from a footer paragraph, a header page number and a footer band; from a page reference, a plain list's marker runs, and a nested list's item runs and depth-1 marker.
    • DocxHeadingRoleTest: a heading in a nested composed cell has its Heading2 style defined.
    • DocxPageReferenceTest:
      • references to a paragraph's anchor, a section's anchor, and a rule's anchor in a stack of one layer, all in composed cells, are PAGEREF fields with no "no bookmark" note;
      • two references to an anchored section in a cell that holds only a shape are written as the text 1, with no field marks;
      • both notes say the reference is text: one note is new, the other replaces the margin note the reference already had.
      • a reference to an anchor that exists nowhere keeps no field, although another anchor's bookmark was minted the same name (a. and a_2 both become a_2).
  • Sabotages. 20 sabotages each undo one part of the fix. Every one fails a test.
  • Template corpus (62 DOCX) against 2.5-dev:
    • 59 documents are byte-identical; the other 3 differ only in their font table and font parts (the faces listed above). Report notes stay 1244 → 1244.
    • LibreOffice 24.2 (the CI build) now sets those three in the shipped faces, Spectral, Fira Sans Condensed Bold and Lato Bold, instead of DejaVu Sans and a synthesized bold. Every line stands within 0.05pt of where it stood.
  • Full build. ./mvnw -B -ntp clean verify → BUILD SUCCESS (render-docx 1625, qa 1820).

Lane: shared-engine (render-docx semantic DOCX backend), no public API change.

…ferences

The faces a document ships, the heading styles it defines and the
anchors a page reference was written as a field for were read from the
document's tree before the body, walking a node's children. A table
holds a cell built from a node in the cell: a face set only there was
named on its runs and not shipped, a heading there named a HeadingN
style the styles part did not define (Word set it as Normal, at no
outline level), and a page reference to an anchor there was written as
a number with a note saying the bookmark was missing, while the cell
wrote it.

DocxWrittenTree.under gives a node's children and a table's composed
cells' content, and the font and outline passes walk it. The font
table also reads the page zones and the header and footer bands of
every section, a page reference's and a page number's style, and the
runs of a list's items and markers.

A page reference no longer asks the tree whether its anchor gets a
bookmark: it is written as a PAGEREF field, and once the body is
written each field whose bookmark the file does not hold for its anchor
(a section holding only shapes the export draws writes no paragraph to
put one round) is taken back to the number it reads, its note saying
so, where Word would show "Error! Bookmark not defined." on its first
update.
@DemchaAV
DemchaAV merged commit 6c05f70 into 2.5-dev Oct 15, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-composed-cell-passes branch October 15, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant