Skip to content

fix(docx): wrap a list without hangingIndent where the page does, its marker a space ahead - #839

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-horizontal-offsets-5
Oct 4, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-horizontal-offsets-5

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Why

A list without hangingIndent is laid out with its marker inside the item's text: the first line is the marker, a space and the text, and every wrapped line starts after the spaces that cover the marker and that space (ParagraphWrapping's continuation prefix). The DOCX export makes it a Word list whose levels are a stated 9pt column with a tab after the marker, so Word wrapped the lines at 9pt whatever the page did:

  • ModernInvoice's and ClassicInvoice's notes (and their long variants): wrapped lines 2.6pt left of the page's;
  • MidnightNavy's bullets 3.7pt right, SlateOrange's 2.6pt right.

What changed

  • numberingFor / prefixColumn. The top level of a Word list the layout placed, without hangingIndent, that does not nest, with a visible marker of text, takes as its indent and hanging the width of the spaces covering the marker and its space, in the list's style (spacesCovering, the same count the layout takes).
    • Its marker is followed by a space, not a tab (w:suff="space"), so the first line reads marker, space, text as on the page. The lite schema has no type for w:suff; followTheMarkerWithASpace writes it through an XmlCursor, before w:lvlText as the schema orders it.
    • The level names the list's face and size, which Word draws the marker and its space in.
    • A list built as a tree of items (even leaves) is laid out flattened, its markers in its text, and keeps the stated column; so does a list that nests and one the layout did not place.
  • measureTheItemAtWordsSize. In that column an item has exactly the page's measure, and Word sets a size the page gives to the tenth at the half point. An item that wraps gets a right indent that grows or shrinks its measure in the same proportion, less a point, as measureAtWordsSize does a paragraph's.
    • The floor is its widest line as Word sets it, plus a point, so a line the page fills is never narrowed past its last word.
    • An item of one line, or one whose lines the layout does not report, is only widened.
    • The item's own w:ind is its right side alone: Word and LibreOffice take each side of w:ind on its own, so the item keeps its level's column and follows the level when an editor changes it (measured: writing the column on the item as well moves no line in either editor).
    • The per-item lines come from DocxLayoutMetrics.itemLines, which replaces itemLineCounts.
  • Docs. docs/recipes/docx-export.md (what a list becomes), capability matrix (list row), CHANGELOG.

Verification

  • Word corpus. scripts/docx-visual/word-fidelity.ps1 -Update (Word 16.0.20430) → BUILD SUCCESS.
    • Lines past 2pt across: 72 → 49; nothing moved down (down column unchanged in every row), no line lost.
    • The invoice notes' wrapped lines −2.60/−2.68 → within 0.04pt (19 lines); MidnightNavy +3.70 → +0.10; SlateOrange +2.64 → 0.00.
    • TerracottaRail (+0.30 → +0.06) and NavySidebar (−0.18 → +0.06) nearer.
    • Four lines more are found in MidnightNavy (84 of 86) and OrangeOps (106 of 108), their items now breaking as the page does.
  • LibreOffice (Windows). 77 → 54 past 2pt across, the same lines, nothing moved down; MidnightNavy finds four lines more.
  • LibreOffice (Linux). The baseline is this PR's CI artifact: 358 → 335 past 2pt across, nothing moved down; four lines more found in MidnightNavy and OrangeOps.
  • The measure correction. In the page's column alone, TerracottaRail's 8.6pt items, set at 8.5, took a word onto a line the page breaks it from, and the column under them rose 11.5pt in Word; with it, nothing moves down.
  • Tests:
    • DocxListNumberingTest, three new:
      • a list without the flag takes the prefix column (twice as wide at twice the size), hung as far as it starts, its marker a space ahead, the level in the list's size;
      • a nesting list, and a list of leaf items, keep the stated 180 twips and a tab;
      • an item at 8.6pt is narrowed, but not past its widest line and a point (5.5pt, where the proportion alone gives 7.0); at 8.4pt it is widened; an item of one line is never narrowed.
    • Seven existing tests pinned the stated 180 twips for a list without the flag: those that test an inset plus the level's column now read the column from the file (DocxListLevels); two assert the prefix column (not 180, as wide as it hangs) and the space.
  • Sabotage. Each rule fails its test:
    • no prefix column;
    • no w:suff;
    • no measure correction;
    • no floor (7.0 against the 6.0 bound);
    • the nesting guard as nests() only (the leaf tree reads 234 twips);
    • the column written on the item as well as the right side (the item no longer follows its level).
  • render-docx. 887 tests green.
  • Full reactor gate. ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am → BUILD SUCCESS (qa 1820 tests).
  • Examples. CommittedAssetDriftTest → 3/3 green after regenerating assets/readme/examples/word-export-companion.docx, whose list now carries the column and the space (only word/numbering.xml and the timestamp differ).

Notes

  • Word's own width for the space. The first line's text follows the marker and a space as Word sets them in the level's style. That is the page's "• " to the half point, not the column. The corpus puts every first line's marker within 0.1pt; the text after it is not measured.
  • An item that runs onto the next page. Its list's items are then not matched to the layout's, so none is narrowed, only widened.
  • No test ties the column to the layout's own prefix width. The tests check its shape; the corpus measures its place (the invoice notes' wrapped lines within 0.04pt in Word).

Lane: shared-engine (render-docx).

… marker a space ahead

The page sets such an item's first line as the marker, a space and the
text, and every wrapped line after the spaces covering the marker; the
Word list's levels were a stated 9pt column with a tab. The top level of a
placed list that does not nest now takes the width of those spaces in the
list's style as its indent and hanging, w:suff="space", the level in the
list's face and size. An item that wraps is measured at Word's half-point
size, less a point and never short of its widest line, so it breaks at the
page's words.

Lines more than 2pt off across fall from 72 to 49 in Word and from 77 to
54 in LibreOffice; nothing moves down.
…ps its level's column

Word and LibreOffice take each side of w:ind on its own: an item that
wraps now carries only its right indent and follows its level when an
editor changes it; measured, no line moves in either editor. The tests
pin the prefix column against the stated 9pt and the item's own column;
the Linux LibreOffice baseline is this branch's CI measure (358 to 335
past 2pt across).
@DemchaAV
DemchaAV merged commit 56a61f0 into 2.5-dev Oct 4, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-horizontal-offsets-5 branch October 4, 2026 10:52
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