Skip to content

fix(docx): keep a bulletOffset paragraph's wrapped lines the page's distance apart - #819

Merged
DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-line-pitch
Oct 2, 2026
Merged

DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-line-pitch

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Why

Word sets every line of a paragraph one exact height apart. The page sets each line its own height, and the export wrote the tallest.

A bulletOffset paragraph's prefix is laid out on the page as a span of spaces in the paragraph's style, and that span has always counted towards its line. In EditorialProposal's and NorthlineProposal's bullets that style is taller than the runs, so the wrapped line is 12.95pt against the first line's 12.0.

On the page the two baselines are 13.05pt apart. The export wrote 12.95 plus the gap, so Word set them 13.8pt apart. Every item then stood half a point to a point lower than the one above it.

  • In EditorialProposal's terms, "full payment is received." ended 4pt low, and the right-hand column of deliverables was a point low under its first item.
  • NorthlineProposal's list ran from about 1pt to 4.2pt low.

What changed

  • DocxLayoutMetrics.lineHeight
    • Only a paragraph of more than one line, on one page, is written otherwise. Only there can a shortfall be owed, so a paragraph split across pages keeps the tallest line.
    • The prefix no longer counts. In a paragraph with a bulletOffset and an indentStrategy, a leading blank span (the prefix the export writes as an indent) is left out of the height of each line the strategy prefixes (writtenTextHeight): the first line for FIRST_LINE and ALL_LINES, the wrapped lines for FROM_SECOND_LINE and ALL_LINES. A paragraph of one line keeps its laid-out height: where it is set by its rise or its pictures (riseIntoItsLine, holdPicturesInTheLine), nothing would make up a shorter one.
    • The page's distance between lines counts. Where the page's mean distance between baselines, less the gap, is more than the tallest written line, it is the height instead. The distance is above.base + gap + line.height − line.base, over pairs of lines whose height is their text's, so lines a picture makes taller are left to makeRoomForPictures. It is never written below the tallest line, which would clip it, nor above the lines' mean height, which would leave the paragraph taller than the page with nothing to owe back.
    • Ordinary paragraphs are written as before. For lines of one height the distance is the line plus the gap. Lines of mixed heights reach past the tallest only when one line's depth below its baseline and the next line's height above it add up to more. No paragraph in the corpus outside the two prefixed lists does.
  • oweWhatTheLinesFallShort. A paragraph written at a height other than its tallest laid-out line can come out shorter than on the page, by what its first line's top and last line's foot hold. That shortfall is owed below it (linesHeight, lineIsNotTheTallest). A paragraph at its tallest line comes out no shorter, so nothing is owed there.
  • Baselines. word-windows, libreoffice-windows and libreoffice-linux are rewritten for the two documents this moves. The Linux one is taken from this PR's CI run (docx-fidelity-37024889880), where NorthlineProposal goes from 5 lines past 2pt to none.
  • Docs. CHANGELOG.md, the Line height row of docs/recipes/docx-export.md, and the lineHeight Javadoc.

Verification

  • Template corpus, re-measured in full. Only EditorialProposal and NorthlineProposal change, in both editors.

    Editor Document Past 2pt Median p90
    Word 16.0.20430 EditorialProposal 21 → 17 0.50 → 0.47pt 2.16 → 2.07pt
    Word NorthlineProposal 6 → 0 0.36pt 1.75 → 1.60pt
    LibreOffice (Windows) NorthlineProposal 6 → 0 0.38pt 1.62 → 1.47pt
    LibreOffice (Windows) EditorialProposal 72 → 72 43.12 → 43.29pt 45.15 → 45.18pt
  • EditorialProposal's second page in Word.

    • The terms list stands flat, 1.0 to 1.5pt low. That is what its heading above already is (1.66, from the timeline table). Before, the list ran from 1.06 down to 4.06.
    • The right-hand deliverables under the first are 0.04 to 0.12pt off, level with the left column. Before, they were 1.04 to 1.12. The first item's first line goes from 0.38 to 0.62pt (see Known limits).
  • EditorialProposal in LibreOffice. LibreOffice already sets this page about 45pt high, for a cause of its own.

    • The right-hand deliverables move from −44.40 to −45.40. They now stand level with the left column's −45.38, where before they were a point under it, as in Word.
    • The terms list goes flat, from −44.80…−41.86 to about −44.2 ± 0.4. That moves the median from 43.12 to 43.29.
    • The gate reads the deliverables as further from the page, so the baselines are rewritten.
  • New test class, DocxLinePitchTest, four tests.

    • Lines of different heights. A prefixed bullet's line is written at the page's distance between its baselines, within the twip that rounding the line and the gap apart allows.
    • What is owed. The paragraph and what it owes are as tall in Word as on the page, within 0.06pt.
    • Lines of one height are written at the tallest line and the gap, with nothing owed.
    • A paragraph of one line with a first-line prefix keeps its laid-out height.
  • Each rule fails its test when taken out.

    • Without the distance rule, the line is 257 twips where the page's distance is 261.
    • Without the one-line exception, the single line comes out shorter than laid out.
    • Without the owing, the paragraph is 0.57pt short.
  • 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 gives BUILD SUCCESS. render-docx runs 808 tests, qa 1814.

Known limits

  • A paragraph with no room above its first line for the gap (one opening a cell or a column) shares its gaps out over all its lines, as before. Its lines stand a little closer than on the page: the first deliverable's wrapped line is 0.45pt high in Word.
  • A paragraph of three or more lines whose distances differ is written at their mean. Its first and last baselines match the page, and the lines between can be a few tenths off.
  • A multi-line paragraph with a prefix and an inline picture reaching past its written line can come out taller than the page: the picture's room is made after the shortfall is owed. No document in the corpus has one.
  • What a paragraph falls short of is not owed in every place. It is not owed for a list item or for text set over the flow. It is dropped at the end of a band or a layer column. Neither path changes a document in the corpus.

Lane: shared-engine (render-docx). No public API change.

@DemchaAV
DemchaAV merged commit 70b0009 into 2.5-dev Oct 2, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-line-pitch branch October 2, 2026 15:54
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