Skip to content

fix(docx): seat text off its baseline as the page does and hang text below its band - #787

Merged
DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-text-vertical-align
Sep 30, 2026
Merged

DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-text-vertical-align

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Why

The DOCX export wrote every paragraph on its baseline. A paragraph with TextVerticalAlign.TOP, CENTER or BOTTOM is seated by its cap band on the page, and Word set it lower or higher. LumaStudioInvoice shows both problems this PR fixes:

  • Seating. Its title sets "INVOICE" against the top of a line far taller than its capitals. In Word the title stood 20pt low, with its rule through the letters. The lockup's "L" stood on the "&Co." set under it.
  • Band overhang. A band whose lowest written block runs past the band's foot gave that overhang no room. DocxLayerColumns.band clamped the space below to zero. Luma's title line is 13pt deeper than its title block, so the invoice's details and everything under them stood 13pt lower, and the notes landed on a second page.

What changed

  • ParagraphSeating (render-pdf, @Internal) holds the PDF backend's seating correction. The correction moves the baseline so the cap top meets the line's top, the cap band sits on its middle, or the baseline sits at its foot. PdfParagraphFragmentRenderHandler and PptxParagraphFragmentRenderHandler call it instead of their private copies, and the DOCX backend calls the same code, so the three cannot drift apart. (PptxGeometryAssertions keeps its own copy as an independent oracle.)
  • seatShift / seatInTheLine compute that correction for a seated paragraph. One shift covers the Word paragraph; the page seats each line by its own, which differs only for lines in different sizes. They use the first line holding text and a lazily built PdfFontLibraryFactory.measurementLibrary over the export's font families, which is the library the layout measured with. The result is written as w:position on the paragraph's own runs:
    • A line pair or a page zone writes another paragraph's runs into the same Word paragraph, and those runs keep their place.
    • Runs inside an internal link are included, although POI's getRuns() does not list them.
    • A picture's own raise is added to rather than replaced, because the page moves a picture with its line's seated baseline.
  • DocxLayerColumns.band keeps a negative below. writeOverlayBand turns it into hangingBelow, as writeLinePair already did for a title-and-dates line, so the next block takes the overhang out of the gap under the band. The band sets the hang rather than adding to it: its below is measured from its lowest text, a nested band's included, so an inner band's overhang is not taken twice.
  • DocxLayoutMetrics.lines returns every line a node laid out.

Verification

  • 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, gave BUILD SUCCESS (1791 + 127 tests). After install, the examples are 93 green and the knowledge checks green. extract-api --check is current: knowledge/api/excluded.json regenerated for the new @Internal class, and no surface changed.

  • DocxVerticalSeatTest has 6 tests. Each was checked by switching its guard off and watching it fail, with the build's outcome printed.

    • A 30pt Spectral word is raised for TOP, lowered for BOTTOM, halfway between the two for CENTER, and left alone for DEFAULT.
    • A title block hanging below itself inside a taller stack leaves the same gap as one tall enough: the overhang is taken once.
    • A title line deeper than its block takes the overhang from the gap under the block. The gap follows the block's height 1:1, and when the overhang is deeper than the margin nothing is left.
    • In one line, a seated value moves while its label on the baseline stays.
    • A seated paragraph's internal-link runs move with it.
  • Exact lines: "Élan Hold gypsy" set at 36pt TOP, CENTER and BOTTOM was converted by Word and showed no accent or descender clipped.

  • Template corpus (62 documents), converted to PDF by Word. Only LumaStudioInvoice, its long variant and NorthlineProposal change.

    Document Pages Median drift p90 drift
    LumaStudioInvoice 2 → 1, as on the page 14.5pt → 1.2 15.4 → 2.1
    LumaStudioInvoice (long) unchanged 8.5pt → 1.9 15.3 → 5.8
    NorthlineProposal unchanged unchanged unchanged — its eight centred section headings are raised 0.5pt

    In LumaStudioInvoice, "INVOICE" stands over its rule and the lockup's "L" over its "&Co.".

  • Known limits: a page zone's paragraph (a header or footer) is written on its baseline, having no laid-out lines to seat by; a seated picture's line keeps the room of its unseated reach. Display text set in Spectral stands about 6pt low in Word, on its baseline too (DEFAULT measured the same). Word sets a line's foot by its own reading of the font's metrics. That is a per-font difference for a later change.

  • docs/architecture/backend-capability-matrix.md (paragraph row, DOCX) and docs/architecture/package-map.md record the seating and why render-docx uses render-pdf for it.

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

@DemchaAV
DemchaAV merged commit e1cc2fa into 2.5-dev Sep 30, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-text-vertical-align branch September 30, 2026 09:24
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