Repository navigation
fix(docx): write top and bottom margins that change from page to page as Word sections - #951
Merged
Merged
Conversation
- 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
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
marked this pull request as ready for review
October 23, 2026 17:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
What changed
LayoutGraphPageGeometrythe layout used (pageGeometry,@since 2.5.0). With no per-page margins, that is the canvas's margins.LayoutGraphis in the internaldocument.layoutpackage.LayoutCompilerhands the geometry on.LayoutGraph.withFragments, which keeps it. Before, each rebuilt the graph field by field.PageGeometrygetsequals,hashCodeandtoString. The graph's record equality and the parallel-session stress test, which comparestoString, rely on them.DocxPageMarginSections(new, package-private)Planning.
planruns 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.writeNoderecords which node wrote each element and on which pages, but only when the layout's margins vary.Writing. Where the page starts between blocks,
applyends a section there. The section's end goes on one of these:The paragraph that ends a section is no longer kept with the next one.
Later sections drop
titlePgand 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.SECTIONPAGES) in a document of several, since Word would count each section apart.DocxSemanticBackendroomBelowTheFlow, now readingDocxLayoutMetrics.lowestContentOn), and the space above a block that opens a page (spaceAboveOnItsPage).DocxNodeFieldLedgerTest): every part ofSemanticExportContextandLayoutGraphhas a fate, so the next session input that reaches an export cannot go unnoticed.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):heldrule.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../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../mvnw -f examples/pom.xml test→ BUILD SUCCESS, 93 tests.Lane: shared-engine (core
document.layoutand render-docx). No public API change:document.layoutis internal and excluded from japicmp and the knowledge surfaces.