Skip to content

fix(docx): write top and bottom margins that change from page to page as Word sections - #951

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-page-margin-sections
Oct 23, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-page-margin-sections

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Why

DocumentSession.pageMargins(...) sets margins per page range, and the layout honours them. Nothing passed them to a semantic backend, so the DOCX export wrote the canvas's margins on every page and reported nothing.

Measured in Word and LibreOffice on Windows, on a 400×600 page whose bottom margin grows from 36pt to 136pt on page 2:

  • With one set of margins, page 2 ran 7 of its 33 lines past its foot.
  • A table there ran on to row 47 where the layout stops at row 42.
  • A rule for page 1 alone was lost as well.

Four invoice presets (Merchant, Obsidian, Payments, Workspace) set another bottom margin from page 2.

A Word section holds one set of margins, and a section's first page starts a page of its own. I measured where to start one:

  • A next-page section break exactly where the layout starts the page: every page stood where the layout sets it, line for line, in both editors. This held on pages of paragraphs, after an explicit page break, after a table, between two tables, and under a card's held margin.
  • A continuous break on page 1: works in Word but not in LibreOffice.
  • A table split at the row where the page starts, or a paragraph split at that line: also line for line. Those splits are not in this change.

What changed

  • Core: LayoutGraph
    • It carries the PageGeometry the layout used (pageGeometry, @since 2.5.0). With no per-page margins, that is the canvas's margins. LayoutGraph is in the internal document.layout package.
    • LayoutCompiler hands the geometry on.
    • The three passes that add fragments call LayoutGraph.withFragments, which keeps it. Before, each rebuilt the graph field by field.
    • PageGeometry gets equals, hashCode and toString. The graph's record equality and the parallel-session stress test, which compares toString, rely on them.
    • No resolution logic is duplicated: the DOCX export asks the resolver the compiler used.
  • render-docx: DocxPageMarginSections (new, package-private)
    • Planning. plan runs once the section's body is written. For each page whose top and bottom margins differ from those Word sets it with, it finds where the page starts among the body's elements. writeNode records which node wrote each element and on which pages, but only when the layout's margins vary.

    • Writing. Where the page starts between blocks, apply ends a section there. The section's end goes on one of these:

      • an explicit page break, whose break the section's own replaces (kept, it would open a blank page);
      • the hairline between two tables;
      • otherwise, the paragraph before it, or a hairline put before it.

      The paragraph that ends a section is no longer kept with the next one.

    • Later sections drop titlePg and the page count's restart. A top or bottom margin held negative for a zone stays held. Under a header or footer, a narrower one is held too.

    • Report. Each written section is named (page margin): its page break is fixed when text above it is edited.

  • Side margins stay the document's. The body is written against the document's width (a rule's length, a panel's room), and the page sets its header and footer there. Written into a section, other side margins would set a full-width rule short by the difference, so a run of pages whose side margins differ is named instead.
  • Named rather than written:
    • a page that starts inside a block (a table's rows, a paragraph's lines, a panel);
    • a page after one whose content reaches its foot under a table, where the hairline ending the section would open a page of its own;
    • a page of a section whose header or footer counts its own pages (SECTIONPAGES) in a document of several, since Word would count each section apart.
  • DocxSemanticBackend
    • The first section takes page 1's top and bottom margins and the document's sides.
    • Two reckonings use the page's margins: the room for the closing line under a last table (roomBelowTheFlow, now reading DocxLayoutMetrics.lowestContentOn), and the space above a block that opens a page (spaceAboveOnItsPage).
  • Ledger (DocxNodeFieldLedgerTest): every part of SemanticExportContext and LayoutGraph has a fate, so the next session input that reaches an export cannot go unnoticed.
  • Docs: a recipe section, a capability-matrix row and CHANGELOG entries.

The four invoice presets start their second page inside their line items, so their later pages are named for now. Splitting a body table at that row would write Merchant's, Payments' and Workspace's. Obsidian's line items sit in a painted panel, which waits on the panel being written in pieces where the layout breaks it.

Verification

  • DocxPageMarginSectionsTest (20 new tests):
    • a section starts between paragraphs, its end on page 1's last line, and the body takes page 2's margins;
    • a page break turns into the section's break, and no page break is left;
    • a page starting inside a table is named on that table;
    • a one-page document takes its page's top and bottom margins, keeps the document's sides, and names them;
    • the later section has no title page;
    • three sets of margins give two section ends, the middle one with the middle margins;
    • after a page named inside a table, the next clean page starts the section;
    • a multi-section document whose zone or text footer counts its pages is not split, and is named;
    • a later part of one section in a document of several counts its pages on;
    • the separator between two tables ends the section and is no longer kept with the next;
    • a page whose content reaches its foot is named, both after a panel and before a table's separator;
    • the line holding a card's margin stays on page 2;
    • side margins are named and not written, alone and alongside other top and bottom margins;
    • the closing-line room under a written section is reckoned against its bottom margin;
    • a narrower later top margin under a header is held;
    • a picture opening a page with a smaller top margin keeps its 20pt;
    • the held rule.
  • PageMarginTest (qa): the layout graph carries each page's margins through the background, zone and timeline-rail passes, and carries the canvas's when no rule is set.
  • PageGeometryTest (core): value equality.
  • Fail-closed: 23 sabotages, each run alone, all caught. They break:
    • the geometry from the compiler, and from the passes;
    • page 1's margins and the document's sides in the first section;
    • the spanning check, the page break, the title page, and the margins in force;
    • the element ownership and the ledger entry;
    • the held rule, under a header and without one, and its wiring into a later section;
    • the page-count case, from a zone and from a text footer;
    • the closing-line room and the space above;
    • the separator, its room, the held line, and the carrier's keep;
    • the side-margin note.
  • Word and LibreOffice on Windows, from the export itself:
    • The paragraph, page-break, two-table and card-after-a-table probes set every page where the layout does, each page's last line within 1.2pt.
    • The table and spanning-paragraph probes are named.
  • Corpus: 62 of 62 DOCX are byte for byte the same, and the 1384 notes are unchanged. None of its documents has margins that change from page to page.
  • 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 2109, 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 (core document.layout and render-docx). No public API change: document.layout is internal and excluded from japicmp and the knowledge surfaces.

- DocumentSession.pageMargins reached the layout only: the DOCX wrote
  the canvas's margins on every page and said nothing. A page with a
  larger bottom margin ran 7 lines of 33 further in Word and
  LibreOffice, past its foot.
- LayoutGraph carries the PageGeometry each page was laid out against.
  The compiler hands it on, and the passes that add fragments keep it
  through LayoutGraph.withFragments. PageGeometry is a value, as the
  graph's equality and its toString need.
- The export writes the first page's margins as the section's. A later
  page whose margins differ from those Word sets it with starts a Word
  section with them where it starts between blocks of the body
  (DocxPageMarginSections). An explicit page break there becomes the
  section's break, a hairline between two tables carries its end, and
  the title page and the count from 1 stay with the first section.
  Measured in Word and LibreOffice: every page where the layout sets it.
- The room for the closing line and the space above a block opening a
  page are reckoned against that page's margins.
- Named instead: a page starting inside a block, a page after one whose
  content reaches its foot under a table, a page of a section whose
  header or footer counts its own pages in a document of several, and
  side margins other than the document's, from which the page sets its
  header and footer.
- The field ledger gives the export context and the layout graph's
  parts a fate.
@DemchaAV
DemchaAV marked this pull request as draft October 23, 2026 16:42
…ffer

- The body is written against the document's width: a rule's length, a
  panel's room. Written into a Word section, other side margins set a
  full-width rule there short, by as much as they differ. Sections now
  carry a page's top and bottom margins only; side margins stay the
  document's, and a run of pages whose side margins differ is named.
  The page sets its header and footer from the document's side margins
  too, so the note on a header standing off goes.
- The paragraph ending a section is not kept with the next one, which
  stands on the page the section's break opens.
- A later section's narrower top or bottom margin is held negative only
  where the section has a header or footer reaching over it.
- Measured in Word and LibreOffice: between two tables and under a
  card's held margin after a table, every page where the layout sets it.
- Tests: the closing line's room under a written section, the held
  margin end to end, side margins named and not written, the carrier's
  keep.
@DemchaAV DemchaAV changed the title fix(docx): write margins that change from page to page as Word sections fix(docx): write top and bottom margins that change from page to page as Word sections Oct 23, 2026
@DemchaAV
DemchaAV marked this pull request as ready for review October 23, 2026 17:12
@DemchaAV
DemchaAV merged commit e885351 into 2.5-dev Oct 23, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-page-margin-sections branch October 23, 2026 17:22
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