Skip to content

fix(docx): keep the space above a paragraph or spacer the layout moves to a new page - #825

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

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-executive

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Why

When a page break moves a block down, the layout keeps three kinds of space above it on the new page:

  • the block's own top edge;
  • the edges of the containers opening with it;
  • the gap before it, but only when that gap did not fit at the foot of the page above (CompilerState.advanceSpace moves to a new page before spending it).

Word drops a paragraph's space above at the top of a page, and keeps its line's height. #821 handled tables and rows. Body paragraphs and spacers still lost the space:

  • ModernProfessional. The second page opens with a heading its section pads 8pt down. In Word the heading and everything under it stood 7.7pt high.
  • Executive. The second page opens with the 3pt spacer between two entries, 3pt below the margin, because the 3pt gap before it did not fit on the first page. In Word the page stood 3.2pt high.

What changed

  • holdAParagraphsTopEdgeOnItsPage. A body paragraph the layout starts on a new page gets an empty line before it, kept with it (keepNext, a 1pt mark).
    • The line's height is what the layout leaves above the paragraph's text on that page. That is the space above its box (spaceAboveOnItsPage) plus its own padding. So it includes a gap the layout carried there, and it does not include edges the layout spent on the page above.
      • A heading kept with what follows can be hoisted to the next page out of a section begun on this one. It leaves the section's padding behind, and gets no line.
    • The rest of the space owed stays as the line's space above. At the foot of the page above, the line and that space take what the paragraph's space above did, so Word breaks the page where it did. When the gap was written on the page above instead, ClassicSerif's second page's first line fit on its first page in Word.
    • Without a canvas, the line holds the paragraph's own edge and the carried container edges. The same applies to a block placed over more than one page, whose placement pairs its first piece's place with the whole height.
    • Some state stays on the page above. Text hanging below a band, a pull out of the block before (a negative bottom margin) and a card's border below it are not carried over, because the layout starts the new page from its top. Before this, a pull of 5pt cut the line holding an 8pt padding to 3pt. The table path drops them the same way.
  • holdTheGapAboveInTheLine. A body SpacerNode the layout starts on a new page moves the space the layout leaves above it there out of its hairline paragraph's space above and into its exact height. That is the gap the layout carried there, and the edges of any containers opening with it (the Executive case).
  • takeFromTheLineHoldingItsEdge. applyLineGap takes one line gap out of a paragraph's space above. For a paragraph whose edge a line holds, it now takes that gap from the line, then from the line's space above. Without this the gap was shared out over the lines: MinimalUnderlined's page-2 description set its two lines 0.7pt closer than the page.
  • holdInALineKeptWithTheNext. The table path (fix(docx): keep a table's top edge on the page the layout moves it to #821) and the paragraph path now build the holding line with this one helper.
  • Baselines. word-windows, libreoffice-windows and their -lines files are rewritten.
    • Executive and ModernProfessional: fixed (see the table below).
    • MinimalUnderlined and BoxedSections, second page. The opening paragraph moves from −0.70 to −0.10pt (BoxedSections: −0.66 to −0.06). The lines below it carry the documents' existing +0.4pt offset per entry, which the −0.7 at the top used to cancel, so they end up up to 0.6pt lower.
      • In MinimalUnderlined, "ADDITIONAL INFORMATION" goes from −0.03 to +0.57, past the gate's 0.5pt. In Word, mean |dy| on that page goes from 0.35 to 0.27pt.
      • In BoxedSections, mean |dy| on page 2 gets worse in Word (0.28 → 0.33pt) and better in LibreOffice (0.34 → 0.29pt).
    • CenteredHeadline: its second page moves 0.12pt lower in Word, 0.05pt in LibreOffice.
    • Linux LibreOffice: libreoffice-linux and its -lines file are taken from this PR's CI run (docx-fidelity-37107445748, on the code before the review fixes; those leave both Windows corpora unchanged).
      • The same documents move as on Windows, by the same amounts.
      • EditorialBlue's page 2 also moves, 2.0pt lower on all 9 lines (−1.8 → +0.2pt). Its lines past 2pt go 2 → 0, p90 1.78 → 0.45pt.
      • No Linux line moves more than 0.36pt further from the page, and no line is lost. Lines past 2pt fall from 750 to 728.
  • Docs. CHANGELOG.md, and the Paragraphs, Spacers and Tables rows of docs/recipes/docx-export.md.

Verification

  • Template corpus, re-measured in full in both editors. Rows that change:

    Document Editor Past 2pt Median p90
    Executive Word 15 → 0 0.25 → 0.16pt 3.05 → 0.38pt
    Executive LibreOffice (Windows) 15 → 0 0.24 → 0.18pt 3.10 → 0.30pt
    ModernProfessional Word 5 → 0 0.20 → 0.20pt 0.77 → 0.56pt
    ModernProfessional LibreOffice (Windows) 5 → 0 0.12 → 0.12pt 0.59 → 0.44pt
    MinimalUnderlined Word 0 → 0 0.62 → 0.57pt 0.89 → 0.89pt
    MinimalUnderlined LibreOffice (Windows) 9 → 9 0.69 → 0.68pt 13.40 → 13.40pt
    BoxedSections Word 0 → 0 0.30 → 0.31pt 0.51 → 0.52pt
    BoxedSections LibreOffice (Windows) 0 → 0 0.30 → 0.30pt 0.59 → 0.46pt
    CenteredHeadline Word 0 → 0 0.20 → 0.24pt 0.82 → 0.82pt
    EditorialBlue LibreOffice (Linux) 2 → 0 0.11 → 0.10pt 1.78 → 0.45pt

    Across the corpus, Word's lines past 2pt fall from 48 to 28, and LibreOffice's (Windows) from 644 to 624. Page counts are unchanged everywhere.

  • New test class, DocxSpaceAboveOnANewPageTest, eleven tests. Spacers:

    • A spacer whose gap carries to a new page holds the gap in its line.
    • A spacer whose gap stays on the page above is written as before.

    Paragraphs:

    • A paragraph its section pads down holds the padding in a line kept with it. The gap stays above the line, and the paragraph has nothing above it.
    • With the gap carried as well, the line holds the gap and the padding.
    • A paragraph with no edge of its own holds a carried gap.
    • A heading hoisted out of a section begun on the page above gets no line, and its padding is left for Word to drop. The test first checks that the layout begins the section on page 1 and the heading on page 2.
    • A pull out of the block on the page above leaves the holding line whole (160 twips, not 60).
    • A paragraph in a table cell gets no line.
    • The same paragraph on the page of the block before it is written as before.

    Line gap:

    • A wrapped paragraph's line gap comes off the line holding its edge (40 → 12 twips).
    • What the line cannot give of the gap comes off the line's space above, and the lines are then as tall as when the whole gap comes off its space above on one page.
  • Each mechanism fails the tests when taken out or replaced:

    • the paragraph line;
    • the line's height measured from the layout: replaced by edge + container edges, 3 tests fail, including the hoisted heading;
    • the cell guard;
    • dropping the pull from the page above;
    • the take from the line;
    • the take from its space above;
    • the spacer hold;
    • the layout measure forced large.
  • DocxSpaceOnANewPageTest (fix(docx): keep a table's top edge on the page the layout moves it to #821, table path): its 4 tests stay green.

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

Known limits

  • A table or row moved to a new page still writes the gap before it below the paragraph on the page above (fix(docx): keep a table's top edge on the page the layout moves it to #821), and does not hold a gap the layout carried onto its page. A paragraph's gap stays above its line on its own page. Bringing the table path to the paragraph's way moves table baselines, so it is left to its own change.
  • Per-page margin overrides. The space above is measured from the section's canvas margin. The DOCX export writes one margin per section and does not read per-page margin overrides, so on a page with an override it is off by the difference, as the rest of that page is.
  • Paragraphs in a table cell, an overlay or a list are left to their container.
  • The share of a paragraph's line gap that comes off the holding line's space above moves nothing in Word, which drops that space at the top of a page. The first line stands that much low (0.55pt in the test case). This keeps the paragraph's lines as tall as on one page.

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

@DemchaAV
DemchaAV merged commit aac24aa into 2.5-dev Oct 3, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-executive branch October 3, 2026 09:00
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