Skip to content

fix(docx): hold a ruled table's first and last rows less the rule and a half they carry - #829

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-editorial-proposal-cells
Oct 3, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-editorial-proposal-cells

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Why

holdRowHeight holds a table's row at the height the page gives it. It writes trHeight (at least) as that height less the most any cell takes around its content: its top and bottom margins and one horizontal rule (verticalMargins).

Word, like LibreOffice, reads trHeight as the cells' content. It splits a rule between two rows half to each row: the lower row's rule, where the two differ. The rule above the table and the rule below it go whole to their row. clearOfTheRules already takes the rules out of the padding that way. So the first row and the last carry a rule and a half, and written less one rule they were held half a rule taller than their content needs.

Measured in Word, on a hand-made table of four rows ruled at 0.75pt (7pt padding, exact 10.8pt lines, margins as clearOfTheRules writes them):

Row height First row Middle rows Last row
Not written 24.38pt 24.84 / 24.96pt 24.48pt
Written as the export did 24.84pt (+0.46) 24.84pt (unchanged) 24.84pt (+0.36)

In EditorialProposal, taking trHeight off the timeline table's last row moved its bottom rule up 0.24pt.

EditorialProposal. Its timeline and investment tables each stood that much taller in Word. Its second page ran 2.5pt low by the acceptance block, with 11 lines more than 2pt off.

What changed

  • outerRulesBeyondOne. It counts what a table's first or last row holds of its rules beyond the one rule verticalMargins counts:

    • first row: half the next row's rule;
    • last row: half its own rule below the table;
    • a table of one row: half of each of its own two rules.

    The first row is held after the rest of the table is written, since its share of the rule under it is the next row's.

  • holdRowAtLeast(row, points, beyond). holdRowHeight passes that in, and it comes off the written height too.

    • Every row of a table ruled alike is then held at its content's height. So is a header ruled heavier than its rows.
    • What the margins leave, the rules take down to a twip at least. Written, the height still cuts a blank line to the row (fitBlankLinesToTheRow).
    • Rows of layout tables, panels and stacks written as columns keep calling the old form.
  • The half rules are rounded as a sum. A 0.75pt rule is 15 twips; rounded one half at a time, a one-row table lost a twip.

  • Javadoc. holdRowHeight and fitBlankLinesToTheRow said Word reads a row's written height as the row's whole. It reads it as the content, as LibreOffice does.

  • Baselines. word-windows, libreoffice-windows and their -lines files are rewritten; every document with a ruled table moves.

    • libreoffice-linux and its -lines file are taken from this PR's first CI run (docx-fidelity-37125838538). On Linux, LibreOffice moves only three documents:
      • CobaltRota: median 0.54 → 0.23pt;
      • ObsidianInvoice: 20 → 25 lines past 2pt, none of them more than 0.5pt further;
      • MerchantInvoice: p90 1.72 → 1.91pt.
    • The header-row change made after that run moves MerchantInvoice again, so the next CI run re-measures it.
  • Preview. assets/readme/examples/word-export-companion.docx is re-rendered. Its table's first and last rows are written 8 twips shorter, and nothing else in it changes.

  • Docs. CHANGELOG.md, and the Tables row of docs/recipes/docx-export.md.

Verification

  • Template corpus, re-measured in full in both editors on Windows.

    Word 16.0.20430 LibreOffice (Windows)
    Lines past 2pt 22 → 11 614 → 614
    Mean |drift|, per line 0.430 → 0.333pt 1.783 → 1.708pt
    Lines better by > 0.05pt 1170 1086
    Lines worse by > 0.05pt 95 164
    Lines worse by > 0.5pt 4 48

    No line is lost, and page counts are unchanged.

    Per document, median in Word:

    Document Median Past 2pt
    EditorialProposal 0.50 → 0.41pt (p90 1.81 → 0.97pt) 11 → 0
    ClassicInvoice 0.48 → 0.22pt
    ClassicInvoice, long 0.51 → 0.27pt
    ModernInvoice 0.31 → 0.09pt
    PaymentsInvoice 0.51 → 0.15pt
    WorkspaceInvoice 0.44 → 0.12pt
    MeteredInvoice 0.37 → 0.14pt
    MerchantInvoice 0.30 → 0.14pt
    CobaltRota 0.71 → 0.32pt
    NorthlineProposal 0.43 → 0.27pt
    EditorialBlue 0.34 → 0.14pt
  • Lines that move further from the page. In each case a table that now stands where the page sets it stops cancelling an offset of its own next to it.

    • MeteredInvoice.
      • Its items table goes from +0.5 to +0.9pt down to +0.1 to +0.2pt.
      • The payment details beside the totals go from +0.4 to −0.6pt.
      • Two lines of the closing note go from +0.15 to −0.8pt, past the gate's 0.5pt.
    • ObsidianInvoice: median 0.26 → 0.32pt.
      • Its items table's header row already stands 1.7pt high, and the parties block above it 0.2 to 0.3pt high. The table's body rows now stand with them, at −0.4 to −0.8pt where they were −0.1 to −0.3pt.
      • The 23 lines from the subtotal down come closer: mean |drift| 0.42 → 0.29pt.
      • Two lines of the payment note are the exception, going from −0.19 and −0.22 to −0.91 and −0.94pt, past the gate's 0.5pt.
    • ModernProposal: median 0.35 → 0.48pt. The page above its timeline table already stands 0.4 to 0.8pt high. The lines from the table down now stand 0.4 to 0.5pt high, where they ranged from 0.3pt high to 0.7pt low.
    • LibreOffice on Windows: 48 lines go more than 0.5pt further.
      • EditorialProposal: 34, on its second page, which a page break of LibreOffice's own already puts 44pt off. It moves 0.5pt on average, 1.2pt at most.
      • MeteredInvoice: 8.
      • ModernProposal: 3.
      • ObsidianInvoice: 2.
      • WorkspaceInvoice: 1.
  • New test class, DocxRuledRowHeightTest, four tests.

    • In a table of three rows ruled at 1.5pt, every row is held at exactly its line's height, 259 twips.
    • A one-row table ruled at 1.5pt, and one ruled at 0.75pt, is held at its line's height too.
    • A 1.5pt header over 0.5pt rows is held at its line's height, giving up half its rows' rule, not half its own.
    • A one-row table no taller than its two 1pt rules is still held, at a twip.
  • Each test fails when its mechanism is taken out:

    • without beyond, the first and last rows are written at 274 and a one-row table at 289;
    • with the halves rounded one at a time, the 0.75pt one-row table is written at 260;
    • counting the header's own rule fails the header test;
    • without the floor, the rule row is not held.
  • DocxRuleRowTest.aLineWithLettersKeepsItsHeight now expects a rule and a half between a last row's written height and its line, where it expected one. The line itself is unchanged.

  • Examples module: ./mvnw -B -ntp test -f examples/pom.xml runs 93 tests, CommittedAssetDriftTest among them: BUILD SUCCESS.

  • 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 845 tests, qa 1814.

Known limits

  • Rows between others ruled differently are counted as before.
    • Such a row is held less the heavier of its own rules. Word actually gives it half its own top rule and half the next row's.
    • So a 1.5pt row between 0.5pt rows is held 0.5pt short of its content.
    • That matters only where the written height binds, in a cell whose content Word sets shorter than the page.
  • Only a table's own rows count the outer rules. A row written as a layout table, a panel or a stack written as columns is held less one rule, as before.
  • The offsets the tables were cancelling are not found yet: ObsidianInvoice's header row, MeteredInvoice's payment details and closing note, and ModernProposal's blocks above its timeline.

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

@DemchaAV
DemchaAV merged commit ceda8d2 into 2.5-dev Oct 3, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-editorial-proposal-cells branch October 3, 2026 15:13
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