Skip to content

fix(docx): grow lines with their text where they are as tall as their faces' own - #955

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-least-line-heights
Oct 25, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-least-line-heights

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Why

A DOCX export wrote every paragraph's lines at an exact height (w:lineRule="exact"), which Word keeps whatever the text holds. A reader who makes the body larger in Word, the Normal style two points up, gets letters taller than their lines, cut off at the top on screen. Word's PDF export draws them whole, so only the screen and the edit protocol's restyle scenario showed it: 1617 of the 1832 paragraphs the corpus's restyle grows stood on lines shorter than 1.1 times their letters.

Measured in Word 16 and LibreOffice 24.2 (CI's build), on every face the templates use at 10 and 20pt, line heights 1.0 to 2.0 times the size:

  • An exact line is as tall as written, its baseline 0.8 of the way down.
  • A least line (w:lineRule="atLeast") is as tall as written or as its runs' own line, whichever is taller. It keeps the spare above its text: its baseline stands the line's deepest run's descent above its foot.
  • A run's own line is its face's, from the face's tables: typographic metrics where OS/2 sets USE_TYPO_METRICS, else the Windows metrics with what hhea's line gap asks beyond them. A line of several runs reaches as high as the tallest and as deep as the deepest, the mark among them. Both editors agree to 0.15pt, in body paragraphs, in top, middle and bottom aligned cells, and in chains of paragraphs with space above.
  • A run's w:position does not move text in a least line: Word keeps the text on the line's foot, LibreOffice moves it and grows the line. A run holding only an anchored shape does not set the line's height.

The page's line is its face's ascent and descent, and its descent is the editors' for every face the templates use. So a least line whose gap the export takes from the space above, as it already does, stands its text where the page does without the raises an exact line needs.

What changed

  • DocxNaturalLine reads a face's own line from its head, hhea and OS/2 tables. It gives none for a face whose hhea ascender and descender are not the metrics so chosen: the page measures descent by hhea, so only then does a least line stand its text where the page does, and an editor reading hhea for the face sets it the same. Every face the templates use passes; Alegreya Sans and IBM Plex Serif, bundled, do not. The table lookup is shared with DocxFontEmbedding's licence check (tableOffset), which now also refuses a table shorter than the reader needs.
  • DocxFontTable.write returns the line of each face it ships (ShippedLines). A standard PDF name, or a face that may not travel, has none: the reader's machine substitutes it.
  • DocxGrownLines reads a written paragraph:
    • reachOf: its runs' and mark's reach, a run with no face or size of its own read in the Normal style. A run holding only an anchored shape is passed over. A picture in the line, a raise, a script, a right-to-left run, a face not shipped or a marker Word draws gives none.
    • mayGrow: the line written is at least its runs' own, to the arithmetic's error, and every run is the same depth. A line rounded down to the twip below its own grows back in the editor on every line, so it stays exact.
    • lineOf: how tall Word stands the line, and its baseline: 0.8 of an exact line, the runs' descent above a least line's foot.
  • DocxSemanticBackend.letTheLinesGrow writes a paragraph's or a list item's line as a least height when it is about to be seated (shiftToThePagesBaseline, seatTheItem), on these conditions:
    • it owns its Word paragraph;
    • it is seated on its baseline;
    • it may grow;
    • Word would then set it under a quarter point off the page's baseline. A least line's text cannot be moved by a position, and a quarter point is the most a position, counting in half points, leaves an exact line off. The threshold does not compare the exact line's own seat: a least line a quarter to half a point off, which the space above could still take, stays exact.
  • Readers of where Word stands a line's text now read it from lineOf: belowThePagesBaseline, which drives the raises and lowerings, and DocxDrawingAnchors.Seat.paragraphTop, which places a shape beside the text.
  • takeFromTheLineAbove shortens a least line above a pulled paragraph only once it knows something is taken. It then writes the line exact and moves its text by where the least line stood it (raiseHoldingItExact) and the four fifths of the pull, rounded to the half point once. Its room under the letters is measured from where the least line stands them, the page's seat, unrounded. In the corpus this moves one lockup: CobaltRota's Spectral wordmark gives a quarter point less of its subtitle's 6.46pt pull, and the subtitle's line the rest. Word stands the wordmark 0.24pt nearer the page, LibreOffice 24.2 0.20pt further (0.38pt off), and the subtitle 0.05pt nearer.
  • nameTheExactLines names, once for the document, how many paragraphs of text are still exact, in the body, its tables, headers and footers (line height, no path).
  • Docs: the recipe's line-height row and its restyle section, the capability matrix's paragraph row, and the CHANGELOG.

What stays exact

  • A line tighter than its faces' own: Poppins' and Gothic A1's, whose own line holds a line gap the page's does not; one rounded to the twip below its own.
  • A line in a face the document does not ship: the default Helvetica, which the reader's machine substitutes. Also a face whose hhea is not its line's metrics.
  • A line holding a picture, a Word list's marker, a raised or seated run, a script, a right-to-left run, or runs of different depths.
  • A table's text cells, page zones, text boxes, stacked lines and line pairs: they are written on paths that do not seat a line this way.

Verification

  • Probes (scratch, not committed), each in Word and LibreOffice 24.2: single, exact and least lines in 16 faces; mixed faces in one line; raised runs in least lines; least lines in aligned cells; chains of paragraphs with space above; a run holding an anchored shape.
  • Tests:
    • DocxNaturalLineTest (4): each face's line against Word's measured height and descent; no line for Alegreya Sans and IBM Plex Serif, whose hhea is not their line's metrics; none for a file cut short or an OS/2 table shorter than read.
    • DocxGrownLinesTest (8): the reach of mixed runs and of the mark in Normal, the bold and italic faces (shipped or not), a link's and a field's runs, bold off, an anchored shape passed over, what is not read, mayGrow, lineOf.
    • DocxGrownLinesExportTest (8):
      • least lines in the body and in a row of columns' cells, the gap taken off the space above, no position;
      • the quarter-point threshold: two lines whose gap is shared out grow at 0.8pt of gap, 0.2pt off, and stay exact at 1.2pt, 0.3pt off;
      • exact for Poppins' tight lines, runs of two depths, Alegreya Sans, a seated paragraph and a Word list's item;
      • least for an item whose marker is its text.
    • DocxBaselineSeatTest: Spectral at 25pt grows and stands where the page does with no position; at 30pt its line rounds short of its own and stays exact.
    • DocxSpaceAboveTest: a shortened least line is made exact, its text moved by both moves rounded once (7 half points where rounding each would give 6); one with no room under its letters keeps its least height.
    • DocxDrawingAnchorsTest: a shape beside a least line is placed from the line's seat (lineOf, injected).
    • DocxExportReportTest: the note, once, with no path; a footer's line counted; a Lato paragraph says nothing.
  • Seating tests rewritten:
    • Those whose lines are now least were moved to faces that stay exact, so they still exercise the exact seat: Ubuntu, set high in Word, and Poppins, set low; one Lato line Word nearly matched, to Gothic A1.
    • Notes asserted empty now read past the line height note (DocxExports.besidesTheExactLines).
    • Expectations that changed with the behaviour:
      • DocxLineGapAboveTheRowTest: two lines a 0.6pt gap apart are now least, their middle a quarter of the gap low, and the row above is lifted that 0.15pt, where the exact lines' middle stood that much high and nothing moved;
      • DocxComposedListTest: a markdown paragraph and a list's item in Lato are now least;
      • DocxSpaceAboveTest: the Lato lockup's pull is 3.4pt, all of which its wordmark's room under the letters gives.
  • Fail-closed: 27 sabotages, each run alone, all caught. They break:
    • growing at all, and the one depth;
    • the mark, a raise, a Word list, an anchored shape, the italic face;
    • the quarter-point threshold, at half a point and at a tenth, and seating off the baseline;
    • the least seat in lineOf, and in the anchor;
    • making a line exact: its move, rounding its two moves apart, doing it with nothing taken; and shortening a least line above;
    • the note, its text filter and its headers and footers;
    • the font tables' line gap, the Windows metrics, hhea against either, the table length, and the arithmetic's error, both ways.
  • Corpus: 1164 of its 3856 paragraphs of text are written at a least height. The report gains 61 line height notes, and the 1384 notes it had are unchanged. The measurements below are of an export that differs from the final one only in CobaltRota's lockup, measured on its own above; Alegreya Sans and IBM Plex Serif, which no longer grow, are in no corpus document.
  • Word edit protocol, restyle-body-through-normal: 1138 of the 1832 paragraphs the restyle grows stand on lines shorter than 1.1 times their letters, from 1617. 10 documents pass, from 2.
  • Word fidelity gate (scripts/docx-visual/word-fidelity.ps1):
    • BUILD SUCCESS: no line further from the page than its baseline holds. Pages are unchanged.
    • The documents' 90th percentiles average 0.265pt, from 0.281. The median average is 0.123pt, from 0.119.
    • The step from a line to the next differs from the page's by 0.120pt on average, from 0.130, and by more than 0.2pt for 238 of 2766 steps, from 335.
  • LibreOffice 24.2 (CI's build, in a container), base and change through the same container:
    • 490 of 3936 lines stand nearer the page and 160 further.
    • The step differs by 0.173pt on average, from 0.187, p90 0.24 from 0.28, and by more than 0.2pt for 275 steps, from 378.
    • The largest move away is 0.50pt, on two lines of the Panel CV. The gate's slack is 0.5.
  • Gate:
    • ./mvnw -B -ntp clean verify -pl <core, render-pdf, render-docx, render-pptx, templates, testing, qa, coverage> -am → BUILD SUCCESS: core 823, render-pdf 339, render-docx 2167, testing 5, render-pptx 142, templates 127, qa 1830.
    • The JDK 17 knowledge checks pass, with the API surfaces unchanged, and core javadoc has 0 warnings.
    • ./mvnw -f examples/pom.xml test → BUILD SUCCESS, 93 tests.

Lane: shared-engine (render-docx). No public API change. The file's line rules and seats change, and the report gains one note per document with exact lines.

… faces' own

- Every paragraph's lines were written at an exact height, which Word
  keeps whatever the text holds: the Normal style two points up cut
  letters off at the top. A paragraph's or a list item's line at least
  as tall as its runs' own line, by the faces' own tables (DocxNaturalLine,
  read for each shipped face by DocxFontTable), is now written as a least
  height, where its runs share one depth, hold no raise, script or picture
  in the line, the paragraph is seated on its baseline and Word then
  stands its text within a quarter point of the page's.
- Word and LibreOffice stand a least line's baseline its runs' descent
  above its foot, the spare above the letters (measured, to 0.15pt):
  DocxGrownLines.lineOf gives that seat to the baseline seating and to
  the anchors of shapes beside the text. A least line shortened for a
  pulled paragraph below is written exact first, its text where it stood.
- The report names, once for the document, how many paragraphs of text
  are still exact (line height), headers and footers included.
@DemchaAV
DemchaAV marked this pull request as draft October 24, 2026 23:19
- DocxNaturalLine gives no line for a face whose hhea ascender and
  descender are not the typographic or Windows metrics its line is set
  on. The page measures descent by hhea, so a least line in such a face
  stood its text off the page's baseline: Alegreya Sans and IBM Plex
  Serif, bundled, now stay exact.
- A least line made exact above a pulled paragraph moves its text by
  where the least line stood it and the pull's lowering together,
  rounded to the half point once (raiseHoldingItExact, in points).
- Tests: the quarter-point threshold on both sides, no line for the two
  faces, the shipped bold face's reach, the single rounding; the
  CHANGELOG and recipe name right-to-left runs and scripts as exact.
@DemchaAV
DemchaAV marked this pull request as ready for review October 24, 2026 23:53
@DemchaAV
DemchaAV merged commit e2ae156 into 2.5-dev Oct 25, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-least-line-heights branch October 25, 2026 00:10
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