Skip to content

fix(docx): write a list composed in a table cell on the lines its table laid out - #884

Merged
DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-composed-cell-list-lines
Oct 12, 2026
Merged

DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-composed-cell-list-lines

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Why

A table lays a composed cell's content out under its own path, so a list in DocumentTableCell.node(...) has no fragments at a path of its own. DocxLayoutMetrics found none for it. Its items were written with no w:lineRule at all, and Word and LibreOffice set each at the face's own line, taller than the page's. A paragraph composed in a cell already had a way out: matchComposedText pairs it with its table's fragments by text. A list had none.

SlateOrange's certifications are a bullet list beside the education column. They stood 0.26, 0.48, 0.84 and 1.18pt low in Word, and 0.15, 0.45, 0.75 and 1.05pt low in LibreOffice: 0.3pt lower with every item.

What changed

  • matchComposedText pairs lists as well as paragraphs. It walks a table's composed paragraphs and lists in layout order, over TableLines: the table's lines, each line's text key computed once, and a queue per text of the lines not taken yet.

    • Each paragraph takes the first waiting line of its text, as before. Its markdown pass still runs after the walk.
    • Each list takes itemFragments: the run that holds its items in turn, opening at a waiting line of its first item's text.
    • An item's line must equal the line the page writes for it (DocxMarkdown.items): the marker and indent set in the text, then the item. endsWith would let a one-item list take another paragraph's line that merely ends with its text.
    • In a hangingIndent list, the line just before an item's is its marker's, set in a column of its own: level with the item's text, only a drawing for a drawn marker, and only for a visible marker. A marker that draws nothing lays out no line, so a line level before such an item's is another node's: a paragraph's in the cell before.
    • The lines a list takes leave the queues, so a paragraph of the same text later in the table keeps its own.
    • Before a list is matched, the paragraphs put aside for the markdown pass are matched by their markdown. A paragraph the page reads as **0.00** sets its line as 0.00, and a list item of that text would otherwise take the line first. Tables with no composed list match their paragraphs exactly as before.
  • The match fails closed. No run, and the list is written as before, in these cases:

    • a list holding an item of runs;
    • an item the page reads as markdown, its marks dropped;
    • a hangingIndent list with a blank item, which the page draws as a marker alone, as in the flow;
    • an item run onto the next page.
  • The list readers go through textFragmentsOf. These are itemLines, markerToText and markerWidth, next to lineHeight and lineGap, which already did. A matched composed list then takes from its lines what a list in the flow takes:

    • the page's line height;
    • its lineSpacing between a wrapped item's lines;
    • a hangingIndent list's marker column;
    • each item's seat on the page's baseline.

    With no place of its own, a Word list whose marker the page sets in its text keeps its stated column (prefixColumn needs a placement).

  • seatTheItem raises an item into the gap above it in a row Word keeps whole (inARowKeptWhole()), as a composed paragraph has been since fix(docx): raise a cell's first line into its padding, and a row's out of the space above it #882. A composed list has no placement, so listOnOnePage is false for it. A list set over two pages in a panel still keeps its space above: its row is neither cantSplit nor placed whole.

Corpus

Only cv-slate_orange.docx changes. The other 61 DOCX files are byte-identical with this PR's base, and the report's 969 notes are unchanged.

The four certifications, in pt below the page, measured against the committed baselines:

before after
Word 16.0 0.26 / 0.48 / 0.84 / 1.18 0.26 / 0.00 / 0.12 / 0.10
LibreOffice, Windows 0.15 / 0.45 / 0.75 / 1.05 0.10 / −0.10 / −0.10 / −0.10
LibreOffice 24.2 (CI) 0.15 / 0.45 / 0.75 / 1.05 0.10 / −0.10 / −0.10 / −0.10

The first item opens its cell, with no space above it to rise into. The other three rise 0.2pt into their gaps. Matched lines alone, without the raise, left all four 0.22–0.26pt low in Word and 0.10pt low in LibreOffice.

The Word and LibreOffice-on-Windows baselines are rewritten from this measurement; only cv-slate_orange rows change. The Linux LibreOffice baseline is rewritten from this PR's CI artifact, in a commit of its own. Against the run before it, only cv-slate_orange moved, 3 lines nearer and none further, and 52 of 62 documents still pass.

Verification

  • ./mvnw -B -ntp clean verify → BUILD SUCCESS: render-docx 1435 (+12), qa 1820.
  • DocxComposedListTest (new):
    • a list in a cell is written at the line height a list in the flow gets;
    • a wrapped item in a cell takes the list's lineSpacing into its line, off its space above;
    • a list and a paragraph of the same text in one table each keep their own lines, in either order;
    • a hanging list's marker column in a cell is the flow's, with no stated-column note;
    • a markdown paragraph level with a markerless hanging list's item keeps its own line;
    • a list does not take a paragraph's line that ends with its item;
    • a list whose items no run of its table's lines holds takes none of them;
    • a markdown paragraph before a list of its text keeps its own line, and the list takes its own;
    • a hanging list whose marker is drawn, and a tree of items, flat and hanging, are written on their lines in a cell;
    • a hanging list with a blank item, and a list holding an item of runs, are left as before, with a layout behind them.
  • DocxListItemSeatTest: an item in a composed cell rises into the gap above it and owes it below; the first item takes its position.
  • DocxListReportTest: a matched composed list's lineSpacing is no longer named; an unmatched one still is.
  • Undone one at a time, 17 of 19 parts of the match fail at least one of these tests. Two are not caught: the checks that a marker's line is level with its item's text and that the marker is visible. Both keep another node's line out of a list's run. Today such a line changes nothing written, since a line level with an item's has the item's height, and the item's own line is the one written.

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

…le laid out

A table lays a composed cell's content out under its own path, so a list
in DocumentTableCell.node(...) had no lines to read: its items were
written with no line height, and Word set each at the face's own line.
SlateOrange's certifications stood 0.3pt lower with every item, the
fourth 1.18pt low in Word and 1.05pt in LibreOffice.

matchComposedText now walks a table's composed paragraphs and lists in
layout order. A list takes the run of its table's lines that holds its
items in turn, opening at a line no node before it took: each the line
the page writes for the item, its marker and indent in the text, and in
a hangingIndent list the item's own marker, level, before it. A list
holding an item of runs, one the page reads as markdown, or a hanging
list with a blank item is left as before. itemLines, markerToText and
markerWidth read through textFragmentsOf, so a matched list takes what a
list in the flow takes from its lines. An item in a row Word keeps whole
rises into the gap above it, as a paragraph composed there does.

The certifications now stand 0.26pt low at most in Word and within
0.1pt in LibreOffice; the other 61 corpus documents are byte-identical,
and the Word and LibreOffice-on-Windows baselines change only there.
…hange's CI run

- Taken from the DOCX Fidelity artifact of this change's CI run. Against
  the run before it, only cv-slate_orange moved: its certifications, 0.45
  to 1.05pt low, now stand 0.10pt low to 0.10pt high; 52 of 62 documents
  still pass.
- Without it, the gate's half point a line would let those lines slide
  back to where they stood.
…ake a list's marker only where it is visible

- A list of one item could open its run at the line of a paragraph the
  page reads as markdown, of the same text, waiting for the markdown
  pass after the walk: **0.00** beside a list of 0.00 lost its exact
  line, and the list took the paragraph's height. The paragraphs left
  before a list are now matched by their markdown before it is; a table
  with no composed list matches its paragraphs as before.
- A hanging list's marker line is taken only for a visible marker: one
  that draws nothing lays out no line, and the line level before its
  item is another node's. The check of the marker's letters goes: the
  page sets a visible marker's line just before its item's text.
- TableLines.take polls the head of its queue where it can, so pairing
  a table's paragraphs stays linear.
- Tests: a markdown paragraph before a list of its text, a drawn marker,
  a tree of items flat and hanging; the test of a list matched to none
  no longer asserts a paragraph that could not collide with it.
- The CHANGELOG, recipe and capability matrix say which composed lists
  are not matched, an item run onto the next page among them.
@DemchaAV
DemchaAV merged commit e41c08d into 2.5-dev Oct 12, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-composed-cell-list-lines branch October 12, 2026 14:02
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