Skip to content

fix(docx): stand a table's text cells on the page's baselines, through their row's margins - #876

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-table-text-seat
Oct 11, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-table-text-seat

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Why

Three things set table text and what follows paragraphs and tables off the page in Word and
LibreOffice alike, each measured in isolation:

  • A table's text cells were not seated. The page sets a cell's line its face's ascent below
    the line's top; Word stands an exact line's baseline four fifths of the way down it, whatever
    the face. Paragraphs are seated (the raise into the space above); a text cell's lines were not,
    and stood as far off as the two seats are apart. A probe of single cells anchored at the top, the
    middle and the bottom, one face per table: 8pt Poppins 0.58pt low in Word and 0.55pt in
    LibreOffice, Helvetica 0.24 and 0.16pt, Lato 0.24 and 0.25pt high — the same at every anchor, and
    nothing else in the cell moved. Across the DOCX fidelity corpus the lines opening a vertically
    centred cell with no space above them stood a median 0.20pt low in Word (904 lines), against
    0.01pt for body paragraphs, which are seated.
  • A paragraph written at its tallest line did not owe what its rounding takes. Its lines are
    written to the twip: ModernInvoice's address blocks, six lines of 11.475pt written at 11.45,
    stood everything under them 0.15pt high. This had been left alone because owing it set
    ModernInvoice's table a step lower — the table's text, unseated, already stood that much low.
  • A cell's rule came off its padding at the authored width. Word draws a rule in eighths of a
    point: a 0.7pt hairline is drawn 0.75pt wide.

What changed

In DocxSemanticBackend (and DocxLayoutMetrics):

  • moveTheRowsText: a row of text cells is moved by its margins. Word and LibreOffice give
    every cell of a row its largest top and bottom margins (evenTheRowsMargins), so the row's top
    margin is made smaller and its bottom one as much larger — or the other way, for a face Word sets
    high — and every cell's text moves with its room, whatever its anchor, while the row keeps its
    height (holdRowHeight reads the margins' sum, which does not change). The probe, patched this
    way: within 0.04pt of the page in Word and 0.09pt in LibreOffice, raised or lowered.
    • How far each cell's lines stand off is belowThePage: four fifths of the exact line the cell's
      first paragraph is written at, against the page's seat — the measured line height less the
      face's descent, as PdfTableRowFragmentRenderHandler draws it — read from the layout's
      measured cell (DocxLayoutMetrics.measuredCell, the index cellLineHeight reads).
    • The row moves by the least any of its cells calls for, so no cell passes the page's baseline,
      and no further than the margin holds; not at all where its cells call for opposite ways or its
      least call is under a tenth of a point (LEAST_ROW_TEXT_MOVE; Word's grid is 0.12pt). A blank
      cell does not hold the row back.
    • Only a row of text cells the layout measured, in a face the measured fonts hold: a composed
      cell's paragraphs are seated by their own writers where they can be, and the margins would
      move them again (measured: a composed Poppins paragraph, already moved by its position, stood
      0.5pt high). Not a row in a vertical merge, whose cells' margins are evened apart. A row's
      entries in textCellRaises are taken out when it is decided.
    • What the margins leave of a text cell's call — in a row they do not move, or move by less — is
      moved by its text's w:position where that is half a point or more, as a paragraph's is.
      LibreOffice moves raised text further than it is raised, by up to half as much again; from
      half a point up that overshoot is less than what is left, so less is left as it is.
    • Space above a centred cell's text would not do: LibreOffice sets a vertically centred cell's
      content as if its first paragraph had none (measured: 3pt above it stood the line 1.5pt high).
  • settleTheLinesWithThePage owes what rounding takes from a paragraph written at its tallest
    line, as it owes what one written at another height falls short; lineIsNotTheTallest is gone,
    and with it the two flags of DocxLayoutMetrics.lineHeight it alone set false. Where a
    paragraph's pictures then make its line taller than written (makeRoomForPictures), what was
    owed is taken back (takeBackTheRoundingOwedBy): Word grows those lines past the shortfall.
  • clearOfTheRules takes each rule off the padding as wide as Word draws it (ruleTwips: the
    w:sz eighths, counted to the twip as borderTwips counts a row's rules). A cell's rule is
    written as a paragraph's rule is (ruleEighths(double), the two to ninety-six eighths a line
    border's width runs over): a 0.05pt hairline is sz=2 where it was sz=1, which Word drew at two
    eighths anyway; no rule in the corpus is under two eighths or over twelve points.

Docs: the recipe's Tables and Line height rows, the backend capability matrix's paragraph and table
rows, CHANGELOG.md (v2.5.0; two earlier entries of the same version no longer say rounding is not
owed or that a table text cell's lines are not moved).

Verification

  • ./mvnw -B -ntp clean verify → BUILD SUCCESS: render-docx 1350 run, 0 failures, 1 skipped
    (+18), qa 1820, examples 93, core 818.
  • New DocxCellSeatTest (14): a row set low is raised, each cell's room moved and as tall, at the
    top, the middle and the bottom; a row set high lowered; the least of three cells — the 7pt one's,
    the 14pt one's call pinned to the paragraph seat's 6 twips; a cell of two lines a paragraph each,
    the space between them kept; each row decided on its own; opposite ways left, the cell calling for
    half a point and more moved by its position; what the margins leave of half a point and more moved
    by its position; a blank cell in a face Word sets high does not hold the row back; a composed
    cell (its text cell moved by its position), a vertical merge and an export without layout left; a
    raise no more than the top margin holds, a lowering no more than the bottom one; under a tenth left
    (Gothic A1 14pt, rounding to a twip). The calls are derived from the faces' ascents in ems and the
    written lines.
  • DocxCellPaddingTest: a 0.7pt rule comes off at 0.75 (sz=6), a 0.1pt one is written and taken
    off at a quarter point (sz=2), a 13pt one at twelve (sz=96); the padding tests read the default
    face's row move (DocxExports.DEFAULT_LINE_RAISE, the same 6 twips as a paragraph of the default
    text). DocxLineSurplusTest: a paragraph of one size owes what its rounding takes, and owes nothing
    where a picture centred on its text grows its lines. DocxTablePaintTest: a 0.05pt hairline is
    written at two eighths.
  • Twenty sabotages, each caught by its own test: no move, the most instead of the least, the last
    cell's, a composed cell moved, a merge moved, no clamp, the clamp's sides swapped, no floor, a
    blank cell blocking, opposite ways moved, raise only, top margin only, the seat's sign, the owed
    rounding dropped, the authored rule width, an eighth for a hairline, no cap at twelve points, the
    owed rounding kept under a picture, no position for what is left, the position read off the whole
    call.
  • DOCX fidelity corpus against 2.5-dev, Word (word-windows*.tsv): 12 documents nearer, the sum
    of the medians 1.45pt nearer — invoice-consulting_long 0.74 → 0.35pt, invoice-classic_long
    0.26 → 0.06pt, invoice-subscription 0.56 → 0.43pt, invoice-modern_long 0.22 → 0.10pt; one
    further (below). LibreOffice on Windows (libreoffice-windows*.tsv): 12 nearer, 1.38pt —
    invoice-consulting_long 0.69 → 0.22pt, invoice-classic_long 0.22 → 0.05pt; two further.
    LibreOffice on Linux (CI's docx-fidelity job): its baseline is rewritten from that job's artifact;
    on the first head, 9 nearer and 5 further — invoice-merchant, invoice-metered and
    proposal-modern by 0.05pt each, documents Linux's face substitutions already set 0.7 to 10pt
    off. The report is unchanged: the same 969 notes.

Notes for review

  • invoice-modern moves 0.08 → 0.12pt in Word and 0.07 → 0.13pt in LibreOffice. Its table's text,
    set low in its cells, had stood where the page sets it only because its rows stood as much high;
    and the notes under the table stand 0.1pt lower, where Word leaves more space after the totals
    table than the page does. Both are their own change.
  • cv-editorial_blue's skills table now stands with the column round it, which LibreOffice sets
    0.1 to 0.3pt high: its LibreOffice median 0.09 → 0.14pt; Word nearer.
  • A rule between two rows is still halved, each half rounded to the twip with its margin, so an odd
    rule comes off a twip short: NorthlineProposal's and EditorialProposal's investment tables,
    ruled at 0.7pt (15 twips, 7 and 7 taken), stand 0.04pt lower with every row in both editors. A
    probe of ten-row tables ruled at three to nine eighths puts the width both editors set a row's rule
    at as its eighths in twips rounded down (12.5 → 12, 17.5 → 17); counted that way, CobaltRota's
    composed rota rows, which Word already sets a little taller than the page, stand further, so the
    rule's share and those rows are their own change.
  • Rows holding a composed cell — CobaltRota, LumaStudio, ConsultingInvoice's line items — keep
    their margins; their text cells' calls of half a point and more are moved by position, and a
    composed cell's first line, which has no space above it to be raised into, is not seated.

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

…h their row's margins

- A row of text cells is moved by its top and bottom margins together
  (moveTheRowsText): the page sets a cell's line its face's ascent below the
  line's top, Word four fifths of the way down the exact line, and both
  editors give every cell of a row its largest margins, so every cell's text
  moves with its room at any anchor while the row keeps its height. By the
  least its cells call for, no further than the margin holds; not a row
  holding a composed cell, one the layout did not measure, one in a vertical
  merge or whose cells call for opposite ways, nor under a tenth of a point.
- A paragraph written at its tallest line owes what its rounding to the twip
  takes (settleTheLinesWithThePage), as one written at another height does.
- A cell's rules come off its padding as wide as Word draws them, in eighths
  of a point (writtenRuleWidth), and are written from Word's thinnest.
- Docs: the recipe's Tables and Line height rows, the backend capability
  matrix, the CHANGELOG.
- Fidelity baselines rewritten (Word, LibreOffice on Windows); the committed
  preview word-export-companion.docx re-rendered: its cells' margins only.
- Tests: DocxCellSeatTest; DocxCellPaddingTest, DocxLineSurplusTest and
  DocxTablePaintTest read the new margins, rounding and rule widths.
…ts position, and owe no rounding a picture outgrows

- What the margins leave of a text cell's call — in a row they do not
  move, or move by less — is moved by its text's position where that is
  half a point or more, as a paragraph's is; under it, LibreOffice's
  overshoot would be more than what is left.
- A paragraph whose pictures make its line taller than written
  (makeRoomForPictures) takes back the rounding it owed below it.
- A cell's seat is read off the exact line its first paragraph is written
  at; a row's entries are taken out when it is decided.
- A cell's rule is written from two to ninety-six eighths of a point, the
  range of a line border's width (ruleEighths(double)), and counted to the
  twip through one helper with borderTwips.
- Docs: the CHANGELOG entry names the position, the exclusions and the
  floor; the recipe and the capability matrix say a rule between two rows
  comes off a twip short where it is odd.
- Fidelity baselines rewritten (Word, LibreOffice on Windows).
- Tests: DocxCellSeatTest (the least of three cells, the position after
  the margins, the lowering clamp, a cell of two lines, rows decided on
  their own, no layout), DocxCellPaddingTest (a rule under two eighths and
  over twelve points), DocxLineSurplusTest (a picture outgrowing the owed
  rounding).
@DemchaAV
DemchaAV merged commit 8fa0340 into 2.5-dev Oct 11, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-table-text-seat branch October 11, 2026 10:36
DemchaAV added a commit that referenced this pull request Oct 11, 2026
…le text cell seat (#877)

The DOCX Fidelity job's measurement of the export that stands a table's
text cells on the page's baselines (#876), taken from that job's
artifact: 9 documents nearer, 5 further, the sum of the medians 1.03pt
nearer. invoice-consulting_long 0.69 -> 0.22pt, invoice-consulting
0.65 -> 0.18pt, invoice-classic_long 0.17 -> 0.08pt; invoice-merchant,
invoice-metered, proposal-modern, cv-editorial_blue and invoice-modern
0.05 to 0.06pt further.
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