Skip to content

feat(api): pageMargins(List<PageMarginRule>) — per-page-range margin overrides#244

Merged
DemchaAV merged 2 commits into
developfrom
feat/per-page-margin
Jun 26, 2026
Merged

feat(api): pageMargins(List<PageMarginRule>) — per-page-range margin overrides#244
DemchaAV merged 2 commits into
developfrom
feat/per-page-margin

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A document had one margin for every page, so a full-bleed cover and a book-margin body could not live in the same file — the cover had to be a separate document or a hand-built full-bleed hack.

What changed

  • DocumentSession.pageMargins(List<PageMarginRule>) overrides the page margin for ranges of pages — both horizontally and vertically. PageMarginRule.page(1, DocumentInsets.zero()) for a full-bleed cover, PageMarginRule.from(2, …) for the body. Pages are 1-based; rules apply in list order, last-covering-rule wins (mirrors pageBackgrounds).
  • New public types: PageMarginRule (record, factories page() / range() / from()), plus the engine-side PageGeometry / PageMarginOverride resolvers.

How it works

The engine measures content before it knows which page the content lands on, so per-page width is a circular dependency. It's resolved as a fixed point — the same recompile-until-stable loop the page-reference/TOC resolver already uses: compile, record each node's start page, recompile feeding those back, until the assignment settles (capped, unconverged returns the last layout). Each top-level block — and each direct child of the page-flow column — is measured at the content width of the page it begins on. A single block that overflows a margin boundary keeps its start-page column for its whole length (documented; the model is per-page-range, not mid-block).

Lane: canonical API + shared-engine (LayoutCompiler / CompilerState / pagination). A nullable PageGeometry resolver returns the base canvas for every page when no rules are set; the no-rules path compiles once and the per-page branches are guarded, so a document that sets no margins does no extra work.

Verification

  • ./mvnw test -pl .green (1601), 0 changed visual baselines → a document with no rules is laid out byte-for-byte as before. ./mvnw -P japicmp verify — binary-compatible (new types + default interface methods + a delegating ctor, all additive). Javadoc gate clean.
  • Perf A/B vs develop (deterministic compile-time probe, default no-rules path, interleaved batched runs): no measurable regression — overall min 1.046 ms (branch) vs 1.067 ms (develop), batch-medians overlap on both. Confirms the structural analysis: one compile, inlined null-checks, no extra allocation.
  • PageMarginTest (7) asserts horizontal (placementX 10→70) and vertical (top edge 285→260) per-page geometry off the placed graph — via an explicit page break and via natural overflow (the fixed point) — plus last-rule-wins, empty-rules, the documented block-spanning-a-boundary limitation, and per-page margins coexisting with page references (the unified loop).
  • A cold review confirmed byte-identity, the fixed-point convergence/stability (5-pass cap, stable paths), the depth==1 page-column gate, and japicmp; it drove the boundary-limitation and page-reference-coexistence tests.
  • Runnable PerPageMarginExample + committed preview: a full-bleed cover (page 1, zero margin) and a book-margin body (pages 2+) in one session.

This is the last of the book-template engine-gap items.

DemchaAV added 2 commits June 26, 2026 12:06
…overrides

A document had one margin for every page, so a full-bleed cover and a book-margin
body could not live in the same file — the cover had to be a separate document or a
hand-built full-bleed hack.

DocumentSession.pageMargins(List<PageMarginRule>) overrides the page margin for ranges
of pages (1-based, last-covering-rule wins), changing the content box both horizontally
and vertically: PageMarginRule.page(1, DocumentInsets.zero()) for a full-bleed cover,
PageMarginRule.from(2, ...) for the body. Because the engine measures content before it
knows the page, per-page width is a fixed point: each top-level block — and each direct
child of the page-flow column — is measured at the content width of the page it begins
on, resolved by the same recompile-until-stable loop the page-reference resolver already
uses. A nullable PageGeometry resolver returns the base canvas for every page when no
rules are set, and the no-rules path compiles once and allocates nothing extra, so a
document that sets no margins is laid out byte-for-byte as before (full suite, zero
changed baselines).

Tests: PageMarginTest asserts horizontal (placementX) and vertical (top edge) per-page
geometry off the placed graph, via an explicit page break and via natural overflow (the
fixed point), plus last-rule-wins, empty-rules, the documented block-spanning-a-boundary
limitation, and per-page margins coexisting with page references. Example:
PerPageMarginExample (full-bleed cover + book-margin body in one session).
PageMarginRule rejected negative insets but let NaN/Infinity through (the
non-negative check is false for both), so a non-finite inset surfaced only later as
an opaque engine error. Reject non-finite insets at the type with the same
bleed()-pointing message. The page()/range() factories now also reject
Integer.MAX_VALUE — which their +1 would overflow into a confusing message — and
point at from() for an open-ended range.

Tests cover the non-finite/overflow rejection, null clearing back to the
document-wide margin, a middle-only page range leaving its neighbours at the base
margin, per-page margins resolving section-locally under a multi-section document,
and every top-level block being placed consistently with the page it lands on. The
pageMargins() Javadoc now states the per-page model's mid-block limitation on the
vertical axis too, and the limitation is recorded in the 2.0 breaking-changes ledger
(the API stays Stable).
@DemchaAV DemchaAV merged commit 970e62b into develop Jun 26, 2026
11 checks passed
@DemchaAV DemchaAV deleted the feat/per-page-margin branch June 26, 2026 14:41
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