From 3787213cf8752065b6ffbbc956ee14d33114e165 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 26 Jun 2026 12:06:39 +0100
Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20pageMargins(List)=20=E2=80=94=20per-page-range=20margin=20overrides?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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) 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).
---
CHANGELOG.md | 8 +
assets/readme/examples/per-page-margin.pdf | Bin 0 -> 1466 bytes
examples/README.md | 18 ++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/layout/PerPageMarginExample.java | 96 +++++++++
.../compose/document/api/DocumentSession.java | 88 +++++++-
.../compose/document/api/PageMarginRule.java | 106 +++++++++
.../document/layout/CompilerState.java | 47 +++-
.../layout/DocumentLayoutPassContext.java | 41 ++++
.../document/layout/FragmentContext.java | 12 ++
.../document/layout/LayoutCompiler.java | 89 +++++---
.../compose/document/layout/PageGeometry.java | 79 +++++++
.../document/layout/PageMarginOverride.java | 45 ++++
.../document/layout/PrepareContext.java | 25 +++
.../document/api/DocumentSessionTest.java | 6 +
.../compose/document/api/PageMarginTest.java | 202 ++++++++++++++++++
16 files changed, 827 insertions(+), 37 deletions(-)
create mode 100644 assets/readme/examples/per-page-margin.pdf
create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
create mode 100644 src/main/java/com/demcha/compose/document/api/PageMarginRule.java
create mode 100644 src/main/java/com/demcha/compose/document/layout/PageGeometry.java
create mode 100644 src/main/java/com/demcha/compose/document/layout/PageMarginOverride.java
create mode 100644 src/test/java/com/demcha/compose/document/api/PageMarginTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae4222209..792827601 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,14 @@ PDF `GoTo` actions. External links are unchanged.
### Public API
+- **`DocumentSession.pageMargins(List)`** (`@since 1.9.0`). Overrides
+ the page margin for ranges of pages, so one document can mix a full-bleed cover
+ (`PageMarginRule.page(1, DocumentInsets.zero())`) with book margins on the body
+ (`PageMarginRule.from(2, …)`) — both horizontally and vertically. Pages are 1-based;
+ rules apply in list order, last-covering-rule wins. Each top-level block is laid out
+ at the content width of the page it begins on. A document that sets no rules is laid
+ out exactly as before.
+
- **`chrome().viewerPreferences(...)` + `DocumentViewerPreferences` /
`DocumentPageMode` / `DocumentPageLayout`** (`@since 1.9.0`). Controls how a PDF
reader presents the document on open — the page mode (`USE_OUTLINES` opens the
diff --git a/assets/readme/examples/per-page-margin.pdf b/assets/readme/examples/per-page-margin.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..f0cef8f46b80215bbd204f249dc59f9d8635a317
GIT binary patch
literal 1466
zcmY!laBZ^4=fsl4ocwey{jk)c;>`R!
z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y
zE~&}+DXAb`#U(|liMhO76?0`#VyWF~gD>O`gGo?fd@fbQT
zIL{xIIy+(E7R6fk3Q&`T$~%0
zC6w91*|;`z$;4IH9BeOrc`hyU_SVX|r;QfL$IVZ)RJMINt?U(hcC3;nFUau_m%zMX
zf$0qsQwt)zksExl@3w-#-rw5KW=sv!NnkDu{W>ABm)G}<#p9*Z*erx&
zralen?(**N-S+F&ZRbM__uXq6eyQHRa5AHJw;LkOw=1?b6;CK~EBd%_(trQdyb{BeI?JYpe_FHQouJfzKdHBUTyNNpm^pn)
zXlJyl=LvrpZl*Y6onDT1&UcB#_Fh}Jmgc+4KLi(U7kndhLREX6QFy&U#gEj(_j-9=
z-E(DP-_8H+f_+doYgmY-u>2BZU#p+)FWBn49vpiSWwkKSZ%bs-=|5@bv!4Hb-D>W1
z#6`JHQqn-}!`&_MW(G%dAD!QIA=$CD#Gsc|_W>#FyfA}+^H~4;-xTocv1&?>Em%dNphHoq9CwCZFbY1QZ
zmh(EDcoE%A5Me{Jr3e*D2D
zA??$D1Q^#uV{0#{{)lVp{Gp)qFQ0YgGRC_leM0aWG$ap`-yC`8*hIT<^d
z7#JHFm>F1@ni?7ySvoqqx|y07I$1cGyIL3+niv{6TN)Z08n~Le7+X34g`Az7OdVZZ
z%^e+qI_(rFHzF1mrx8J^XGd( band
[📄 View PDF](../assets/readme/examples/content-bleed.pdf) ·
[📜 Full source](src/main/java/com/demcha/examples/features/layout/BleedExample.java)
+### Per-page margin
+
+`pageMargins(List.of(...))` overrides the page margin for ranges of pages, so a
+single document can mix a full-bleed cover with a book-margin body. Each rule
+addresses pages by 1-based number; the content is laid out at the width of the page
+it begins on. Page 1 below uses a zero margin (the band spans the sheet); pages 2+
+use wide book margins (the body sits in a narrow column).
+
+```java
+document.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover
+ PageMarginRule.from(2, DocumentInsets.symmetric(36, 86)))); // book body
+```
+
+[📄 View PDF](../assets/readme/examples/per-page-margin.pdf) ·
+[📜 Full source](src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java)
+
### Row columns & TOC
`RowBuilder.columns(...)` sizes each column as fixed points, intrinsic content
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index 6d00df999..1ce12cf2c 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -9,6 +9,7 @@
import com.demcha.examples.features.chrome.PdfChromeExample;
import com.demcha.examples.features.chrome.ViewerPreferencesExample;
import com.demcha.examples.features.layout.BleedExample;
+import com.demcha.examples.features.layout.PerPageMarginExample;
import com.demcha.examples.features.layout.RowColumnsExample;
import com.demcha.examples.features.layout.RowFlexExample;
import com.demcha.examples.features.layout.RowVerticalAlignExample;
@@ -160,6 +161,7 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + SvgIconGalleryExample.generate());
System.out.println("Generated: " + BlockAlignExample.generate());
System.out.println("Generated: " + BleedExample.generate());
+ System.out.println("Generated: " + PerPageMarginExample.generate());
System.out.println("Generated: " + RowColumnsExample.generate());
System.out.println("Generated: " + RowVerticalAlignExample.generate());
System.out.println("Generated: " + RowFlexExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java b/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
new file mode 100644
index 000000000..b2ba9aecd
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
@@ -0,0 +1,96 @@
+package com.demcha.examples.features.layout;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.PageMarginRule;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Runnable showcase for v1.9 {@code pageMargins(...)}: different page margins for
+ * different page ranges in a single document. Page 1 is a full-bleed cover
+ * ({@link DocumentInsets#zero()} margin), so the same {@code fillColor} section
+ * spans the whole sheet; pages 2+ use wide book margins, so the body sits in a
+ * narrow centred column. The content is measured at the width of the page it begins
+ * on — no manual geometry, no separate documents.
+ *
+ * {@code
+ * document.pageMargins(List.of(
+ * PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover
+ * PageMarginRule.from(2, DocumentInsets.symmetric(40, 86)))); // book body
+ * }
+ *
+ * @author Artem Demchyshyn
+ */
+public final class PerPageMarginExample {
+
+ private static final DocumentColor INK = DocumentColor.rgb(22, 27, 38);
+ private static final DocumentColor PAPER = DocumentColor.rgb(244, 241, 234);
+ private static final DocumentColor MUTED = DocumentColor.rgb(96, 102, 112);
+ private static final DocumentColor WHITE = DocumentColor.rgb(255, 255, 255);
+
+ private static final String BODY =
+ "The cover above bleeds to all four edges because page one has a zero "
+ + "margin. This paragraph, on page two, flows inside the wide book margins "
+ + "set for the rest of the document — a narrower measure that is easier to "
+ + "read across a full page. Both pages live in one session: the engine lays "
+ + "each block out at the width of the page it begins on.";
+
+ private PerPageMarginExample() {
+ }
+
+ /**
+ * Renders a two-page document: a full-bleed cover and a book-margin body.
+ *
+ * @return path to the generated PDF
+ * @throws Exception if rendering or file IO fails
+ */
+ public static Path generate() throws Exception {
+ Path pdfFile = ExampleOutputPaths.prepare("features/layout", "per-page-margin.pdf");
+
+ try (DocumentSession document = GraphCompose.document(pdfFile)
+ .pageSize(360, 300)
+ .margin(DocumentInsets.of(28))
+ .create()) {
+ document.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover
+ PageMarginRule.from(2, DocumentInsets.symmetric(36, 86)))); // book body
+
+ document.pageFlow(page -> {
+ // Page 1 — zero margin, so this full-width filled band spans the sheet.
+ page.addRow(r -> r
+ .fillColor(INK)
+ .weights(1.0)
+ .padding(DocumentInsets.symmetric(104, 28))
+ .addParagraph(p -> p.text("Field Manual")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(26).withColor(WHITE))));
+
+ page.addPageBreak(b -> { });
+
+ // Pages 2+ — wide book margins, so the same band is now a narrow column.
+ page.addParagraph(p -> p.text("Chapter One")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(15).withColor(INK)));
+ page.addRow(r -> r
+ .fillColor(PAPER)
+ .weights(1.0)
+ .padding(DocumentInsets.of(14))
+ .margin(DocumentInsets.top(8))
+ .addParagraph(p -> p.text(BODY)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED))));
+ });
+
+ document.buildPdf();
+ }
+
+ return pdfFile;
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index 2df6151ac..d8e098d9e 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -88,6 +88,7 @@ public final class DocumentSession implements AutoCloseable {
private boolean markdown;
private DocumentDebugOptions debug = DocumentDebugOptions.none();
private List pageBackgrounds = List.of();
+ private List pageMargins = List.of();
private PdfMeasurementResources measurementResources;
private boolean closed;
@@ -451,6 +452,35 @@ public DocumentSession pageBackgrounds(List fills) {
return this;
}
+ /**
+ * Overrides the page margin for ranges of pages, replacing the document-wide
+ * {@link #margin(DocumentInsets)} on the pages each rule covers. Use this for a
+ * document whose pages are not all the same shape — a full-bleed cover with
+ * {@link DocumentInsets#zero()} insets followed by book margins on the body, a
+ * wide title page, and so on.
+ *
+ * Each {@link PageMarginRule} addresses pages by 1-based number; rules apply
+ * in list order and the last rule covering a page wins. Pass {@code null} or an
+ * empty list to clear (the default — every page uses the document-wide margin).
+ *
+ * Because content is measured before it is paginated, each top-level block is
+ * laid out at the content width of the page it begins on. A margin that changes
+ * the content width therefore takes effect where content breaks onto a covered
+ * page — the model is different margins for different page ranges, not a margin
+ * that changes mid-block. To extend a single node past the page edge instead,
+ * use {@code bleed(...)} (see {@link com.demcha.compose.document.style.DocumentBleed}).
+ *
+ * @param rules ordered list of per-page margin overrides, or {@code null}/empty to clear
+ * @return this session
+ * @throws IllegalStateException if this session has already been closed
+ */
+ public DocumentSession pageMargins(List rules) {
+ ensureOpen();
+ this.pageMargins = rules == null ? List.of() : List.copyOf(rules);
+ invalidate();
+ return this;
+ }
+
/**
* Returns a fluent facade for chrome configuration (metadata,
* watermark, protection, header, footer). The facade is a thin
@@ -802,29 +832,67 @@ public LayoutGraph layoutGraph() {
* byte-identical to before.
*/
private LayoutGraph computeLayout() {
- LayoutGraph graph = compilePass(Map.of());
- if (!containsPageReference(roots)) {
+ PageGeometry geometry = buildPageGeometry();
+ boolean hasMargins = geometry != null;
+ boolean hasPageReference = containsPageReference(roots);
+
+ LayoutGraph graph = compilePass(Map.of(), geometry, Map.of());
+ if (!hasMargins && !hasPageReference) {
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}
- Map resolved = resolvedPageNumbers(graph);
+ // Two coupled fixed points share one loop: page references resolve their
+ // numbers, and per-page margins resolve which page each top-level block
+ // begins on (its content width). A pass feeds back both; the layout is
+ // settled once neither changes. Capped so a document that never converges
+ // still returns its last (best) layout.
+ Map resolved = hasPageReference ? resolvedPageNumbers(graph) : Map.of();
+ Map startPages = hasMargins ? nodeStartPages(graph) : Map.of();
for (int pass = 0; pass < MAX_PAGE_REFERENCE_PASSES; pass++) {
- graph = compilePass(resolved);
- Map rendered = resolvedPageNumbers(graph);
- if (rendered.equals(resolved)) {
+ graph = compilePass(resolved, geometry, startPages);
+ Map renderedPages = hasPageReference ? resolvedPageNumbers(graph) : Map.of();
+ Map renderedStarts = hasMargins ? nodeStartPages(graph) : Map.of();
+ if (renderedPages.equals(resolved) && renderedStarts.equals(startPages)) {
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}
- resolved = rendered;
+ resolved = renderedPages;
+ startPages = renderedStarts;
}
- LIFECYCLE_LOG.debug("document.layout.pageReference.unconverged sessionId={} passes={}", sessionId, MAX_PAGE_REFERENCE_PASSES);
+ LIFECYCLE_LOG.debug("document.layout.unconverged sessionId={} passes={}", sessionId, MAX_PAGE_REFERENCE_PASSES);
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}
- private LayoutGraph compilePass(Map resolvedPages) {
+ private LayoutGraph compilePass(Map resolvedPages,
+ PageGeometry geometry,
+ Map nodeStartPages) {
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas,
- measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown, resolvedPages);
+ measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown,
+ resolvedPages, geometry, nodeStartPages);
return compiler.compile(documentGraph(), context, context);
}
+ private PageGeometry buildPageGeometry() {
+ if (pageMargins.isEmpty()) {
+ return null;
+ }
+ List overrides = new ArrayList<>(pageMargins.size());
+ for (PageMarginRule rule : pageMargins) {
+ int fromIndex = rule.fromPage() - 1;
+ int toIndexExclusive = rule.toPageExclusive() == Integer.MAX_VALUE
+ ? Integer.MAX_VALUE
+ : rule.toPageExclusive() - 1;
+ overrides.add(new PageMarginOverride(fromIndex, toIndexExclusive, toEngineMargin(rule.insets())));
+ }
+ return PageGeometry.of(canvas, overrides);
+ }
+
+ private static Map nodeStartPages(LayoutGraph graph) {
+ Map starts = new HashMap<>();
+ for (PlacedNode node : graph.nodes()) {
+ starts.put(node.path(), node.startPage());
+ }
+ return starts;
+ }
+
private static boolean containsPageReference(List nodes) {
for (DocumentNode node : nodes) {
if (node instanceof PageReferenceNode || containsPageReference(node.children())) {
diff --git a/src/main/java/com/demcha/compose/document/api/PageMarginRule.java b/src/main/java/com/demcha/compose/document/api/PageMarginRule.java
new file mode 100644
index 000000000..8106d5fdf
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/api/PageMarginRule.java
@@ -0,0 +1,106 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.document.style.DocumentInsets;
+
+import java.util.Objects;
+
+/**
+ * A per-page-range margin override: the page margin to use for a contiguous run
+ * of pages, replacing the document-wide {@link DocumentSession#margin(DocumentInsets)}
+ * for the pages it covers. Supplied to {@link DocumentSession#pageMargins(java.util.List)}.
+ *
+ * Pages are 1-based (page 1 is the first page). The range is half-open —
+ * {@code [fromPage, toPageExclusive)} — so {@code of(1, 2, …)} covers page 1
+ * only. Use the factory methods for the common cases:
+ *
+ * - {@link #page(int, DocumentInsets)} — a single page.
+ * - {@link #range(int, int, DocumentInsets)} — an inclusive page range.
+ * - {@link #from(int, DocumentInsets)} — a page to the end of the document.
+ *
+ *
+ * Rules are applied in list order and the last rule that covers a page
+ * wins, mirroring {@link DocumentSession#pageBackgrounds(java.util.List)}. A page no
+ * rule covers keeps the document-wide margin.
+ *
+ * Each block of content is laid out at the width of the page it begins on, so a
+ * margin that changes the content width takes effect where content breaks onto a
+ * page the rule covers — the intended use is different margins for different page
+ * ranges (e.g. a full-bleed cover with {@code zero()} insets followed by book
+ * margins on the body). To extend a single node past the page edge instead, use
+ * {@code bleed(...)} (see {@link com.demcha.compose.document.style.DocumentBleed}).
+ *
+ * @param fromPage first page the rule covers, 1-based and inclusive
+ * @param toPageExclusive one past the last page the rule covers (exclusive);
+ * {@link Integer#MAX_VALUE} runs to the end of the document
+ * @param insets the page margin to use for the covered pages
+ * @author Artem Demchyshyn
+ * @since 1.9.0
+ */
+public record PageMarginRule(int fromPage, int toPageExclusive, DocumentInsets insets) {
+
+ /**
+ * Validates the page range and that the insets are non-negative.
+ */
+ public PageMarginRule {
+ Objects.requireNonNull(insets, "insets");
+ if (fromPage < 1) {
+ throw new IllegalArgumentException("fromPage must be >= 1 (pages are 1-based) but was " + fromPage);
+ }
+ if (toPageExclusive <= fromPage) {
+ throw new IllegalArgumentException(
+ "toPageExclusive (" + toPageExclusive + ") must be greater than fromPage (" + fromPage + ")");
+ }
+ if (insets.top() < 0 || insets.right() < 0 || insets.bottom() < 0 || insets.left() < 0) {
+ throw new IllegalArgumentException(
+ "page margin must be non-negative; use bleed() to extend a node past the page edge "
+ + "(see DocumentBleed). Insets were " + insets);
+ }
+ }
+
+ /**
+ * A rule covering a single page.
+ *
+ * @param page the 1-based page number
+ * @param insets the page margin to use on that page
+ * @return a rule covering exactly {@code page}
+ */
+ public static PageMarginRule page(int page, DocumentInsets insets) {
+ return new PageMarginRule(page, page + 1, insets);
+ }
+
+ /**
+ * A rule covering an inclusive page range.
+ *
+ * @param fromPage first page (1-based, inclusive)
+ * @param toPage last page (1-based, inclusive)
+ * @param insets the page margin to use across the range
+ * @return a rule covering {@code fromPage..toPage}
+ */
+ public static PageMarginRule range(int fromPage, int toPage, DocumentInsets insets) {
+ if (toPage < fromPage) {
+ throw new IllegalArgumentException("toPage (" + toPage + ") must be >= fromPage (" + fromPage + ")");
+ }
+ return new PageMarginRule(fromPage, toPage + 1, insets);
+ }
+
+ /**
+ * A rule covering a page through to the end of the document.
+ *
+ * @param fromPage first page (1-based, inclusive)
+ * @param insets the page margin to use from {@code fromPage} onward
+ * @return a rule covering {@code fromPage} to the last page
+ */
+ public static PageMarginRule from(int fromPage, DocumentInsets insets) {
+ return new PageMarginRule(fromPage, Integer.MAX_VALUE, insets);
+ }
+
+ /**
+ * Whether this rule covers the given page.
+ *
+ * @param pageNumber a 1-based page number
+ * @return {@code true} when {@code fromPage <= pageNumber < toPageExclusive}
+ */
+ public boolean coversPage(int pageNumber) {
+ return pageNumber >= fromPage && pageNumber < toPageExclusive;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/layout/CompilerState.java b/src/main/java/com/demcha/compose/document/layout/CompilerState.java
index 33799f0c2..b38a743bf 100644
--- a/src/main/java/com/demcha/compose/document/layout/CompilerState.java
+++ b/src/main/java/com/demcha/compose/document/layout/CompilerState.java
@@ -1,5 +1,7 @@
package com.demcha.compose.document.layout;
+import com.demcha.compose.engine.components.style.Margin;
+
/**
* Mutable bookkeeping for the page-flow path of {@link LayoutCompiler}: the
* canvas the document is being placed on, the active page index, the height
@@ -9,23 +11,64 @@
* can hold and mutate the same state object that {@code LayoutCompiler} reads
* from. Stays package-private — callers outside {@code document.layout} should
* use {@link PlacementContext} for placement-strategy access.
+ *
+ * An optional {@link PageGeometry} lets the active margin / inner width /
+ * inner height vary by page index. When it is {@code null} every accessor falls
+ * back to the single canvas geometry, so a document with no per-page margins is
+ * laid out exactly as before.
*/
final class CompilerState {
final LayoutCanvas canvas;
+ private final PageGeometry geometry;
int pageIndex;
double usedHeight;
int maxTouchedPage = -1;
CompilerState(LayoutCanvas canvas) {
+ this(canvas, null);
+ }
+
+ CompilerState(LayoutCanvas canvas, PageGeometry geometry) {
this.canvas = canvas;
+ this.geometry = geometry;
+ }
+
+ /** Whether per-page geometry is active (a document with per-page margins). */
+ boolean hasPageGeometry() {
+ return geometry != null;
+ }
+
+ /** The margin of the page currently being placed on. */
+ Margin activeMargin() {
+ return geometry == null ? canvas.margin() : geometry.marginForPage(pageIndex);
+ }
+
+ /** The content height of the page currently being placed on. */
+ double activeInnerHeight() {
+ return geometry == null ? canvas.innerHeight() : geometry.innerHeightForPage(pageIndex);
+ }
+
+ /** The content height of a specific page (for fresh-page capacity checks). */
+ double innerHeightForPage(int page) {
+ return geometry == null ? canvas.innerHeight() : geometry.innerHeightForPage(page);
+ }
+
+ /** The content width of a specific page (for the per-page region width). */
+ double innerWidthForPage(int page) {
+ return geometry == null ? canvas.innerWidth() : geometry.innerWidthForPage(page);
+ }
+
+ /** The left margin of a specific page (for the per-page region x-origin). */
+ double marginLeftForPage(int page) {
+ return geometry == null ? canvas.margin().left() : geometry.marginForPage(page).left();
}
double remainingHeight() {
- return Math.max(0.0, canvas.innerHeight() - usedHeight);
+ return Math.max(0.0, activeInnerHeight() - usedHeight);
}
double pageTop() {
- return canvas.height() - canvas.margin().top();
+ return canvas.height() - activeMargin().top();
}
void newPage() {
diff --git a/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java b/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java
index 039511972..f190cbdc1 100644
--- a/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java
+++ b/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java
@@ -27,6 +27,8 @@ public final class DocumentLayoutPassContext implements PrepareContext, Fragment
private final TextMeasurementSystem textMeasurementSystem;
private final boolean markdown;
private final Map resolvedPages;
+ private final PageGeometry pageGeometry;
+ private final Map nodeStartPages;
private final Map> preparedNodes = new HashMap<>();
/**
@@ -64,12 +66,40 @@ public DocumentLayoutPassContext(NodeRegistry registry,
TextMeasurementSystem textMeasurementSystem,
boolean markdown,
Map resolvedPages) {
+ this(registry, canvas, fontLibrary, textMeasurementSystem, markdown, resolvedPages, null, Map.of());
+ }
+
+ /**
+ * Creates a layout-pass context carrying per-page margin geometry and the
+ * previous pass's top-level block page assignments — the per-page-margin fixed
+ * point. A document with no per-page margins passes {@code null} geometry and an
+ * empty assignment map and is laid out exactly as before.
+ *
+ * @param registry semantic node registry used for preparation
+ * @param canvas active layout canvas (the document-wide fallback)
+ * @param fontLibrary document font library
+ * @param textMeasurementSystem text measurement service for this pass
+ * @param markdown whether paragraph markdown parsing is enabled
+ * @param resolvedPages anchor name to 1-based page number
+ * @param pageGeometry per-page geometry resolver, or {@code null}
+ * @param nodeStartPages node path to its 0-based start page
+ */
+ public DocumentLayoutPassContext(NodeRegistry registry,
+ LayoutCanvas canvas,
+ FontLibrary fontLibrary,
+ TextMeasurementSystem textMeasurementSystem,
+ boolean markdown,
+ Map resolvedPages,
+ PageGeometry pageGeometry,
+ Map nodeStartPages) {
this.registry = Objects.requireNonNull(registry, "registry");
this.canvas = Objects.requireNonNull(canvas, "canvas");
this.fontLibrary = Objects.requireNonNull(fontLibrary, "fontLibrary");
this.textMeasurementSystem = Objects.requireNonNull(textMeasurementSystem, "textMeasurementSystem");
this.markdown = markdown;
this.resolvedPages = resolvedPages == null ? Map.of() : Map.copyOf(resolvedPages);
+ this.pageGeometry = pageGeometry;
+ this.nodeStartPages = nodeStartPages == null ? Map.of() : Map.copyOf(nodeStartPages);
}
@Override
@@ -78,6 +108,17 @@ public OptionalInt resolvedPage(String anchor) {
return page == null ? OptionalInt.empty() : OptionalInt.of(page);
}
+ @Override
+ public PageGeometry pageGeometry() {
+ return pageGeometry;
+ }
+
+ @Override
+ public int assignedStartPage(String path, int fallback) {
+ Integer assigned = path == null ? null : nodeStartPages.get(path);
+ return assigned == null ? fallback : assigned;
+ }
+
@Override
public PreparedNode prepare(E node, BoxConstraints constraints) {
PreparedNodeCacheKey cacheKey = new PreparedNodeCacheKey(node, normalizeWidth(constraints.availableWidth()));
diff --git a/src/main/java/com/demcha/compose/document/layout/FragmentContext.java b/src/main/java/com/demcha/compose/document/layout/FragmentContext.java
index 2a0270c7f..d15002b39 100644
--- a/src/main/java/com/demcha/compose/document/layout/FragmentContext.java
+++ b/src/main/java/com/demcha/compose/document/layout/FragmentContext.java
@@ -81,6 +81,18 @@ default List emitChildFragments(
default OptionalInt resolvedPage(String anchor) {
return OptionalInt.empty();
}
+
+ /**
+ * Per-page geometry resolver for documents with per-page margin overrides, or
+ * {@code null} when the document uses a single document-wide margin — in which
+ * case fragment placement uses {@link #canvas()} for every page, exactly as
+ * before.
+ *
+ * @return the page-geometry resolver, or {@code null}
+ */
+ default PageGeometry pageGeometry() {
+ return null;
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java
index 8f82fca80..9f63f7849 100644
--- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java
+++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java
@@ -178,19 +178,31 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F
LayoutCanvas canvas = prepareContext.canvas();
LAYOUT_LOG.debug("layout.compile.start roots={} canvas={}x{}", graph.roots().size(), Math.round(canvas.width()), Math.round(canvas.height()));
PAGINATION_LOG.debug("pagination.compile.start roots={} innerHeight={}", graph.roots().size(), Math.round(canvas.innerHeight()));
- CompilerState state = new CompilerState(canvas);
+ CompilerState state = new CompilerState(canvas, prepareContext.pageGeometry());
List nodes = new ArrayList<>();
List fragments = new ArrayList<>();
for (int index = 0; index < graph.roots().size(); index++) {
DocumentNode root = graph.roots().get(index);
+ // Each top-level block is measured at the content width of the page it
+ // begins on. The start page is carried over from the previous layout
+ // pass (the per-page-margin fixed point); on the first pass it falls back
+ // to the page the previous block ended on. With no per-page margins the
+ // path lookup is skipped and the width is the single canvas width, so the
+ // layout — and the work done — is byte-identical.
+ int startPage = state.pageIndex;
+ if (state.hasPageGeometry()) {
+ startPage = prepareContext.assignedStartPage(pathFor(root, null, index), state.pageIndex);
+ }
+ double regionWidth = state.innerWidthForPage(startPage);
+ double regionX = state.marginLeftForPage(startPage);
compileNode(
- prepareForRegionWidth(prepareContext, root, canvas.innerWidth()),
+ prepareForRegionWidth(prepareContext, root, regionWidth),
null,
index,
1,
- canvas.margin().left(),
- canvas.innerWidth(),
+ regionX,
+ regionWidth,
state,
prepareContext,
fragmentContext,
@@ -353,7 +365,7 @@ private void compileComposite(PreparedNode prepared,
// existing layouts are unchanged.
double outerHeight = naturalMeasure.height() + margin.vertical();
boolean keepWhole = node.keepTogether()
- && outerHeight <= state.canvas.innerHeight() + CAPACITY_TOLERANCE;
+ && outerHeight <= state.activeInnerHeight() + CAPACITY_TOLERANCE;
double startReservation = margin.top() + padding.top();
if (keepWhole && outerHeight > state.remainingHeight() + EPS && state.usedHeight > EPS) {
state.newPage();
@@ -374,16 +386,31 @@ private void compileComposite(PreparedNode prepared,
List children = definition.children(node);
double childRegionX = placementX + padding.left();
double childRegionWidth = Math.max(0.0, availableWidth - padding.horizontal());
+ // At the root level the page margin defines the document's content column, so
+ // each direct child is measured at the content width of the page it begins on
+ // (carried over from the previous pass via the fixed point). Nested composites
+ // keep their parent-allocated geometry. With no per-page margins both branches
+ // resolve to the same width, so the layout is byte-identical.
+ boolean pageColumn = depth == 1 && state.hasPageGeometry();
for (int index = 0; index < children.size(); index++) {
DocumentNode child = children.get(index);
+ double thisChildRegionX = childRegionX;
+ double thisChildRegionWidth = childRegionWidth;
+ if (pageColumn) {
+ int childStartPage = prepareContext.assignedStartPage(
+ pathFor(child, path, index), state.pageIndex);
+ double pageRegionWidth = state.innerWidthForPage(childStartPage);
+ thisChildRegionWidth = Math.max(0.0, (pageRegionWidth - margin.horizontal()) - padding.horizontal());
+ thisChildRegionX = state.marginLeftForPage(childStartPage) + margin.left() + padding.left();
+ }
compileNode(
- prepareForRegionWidth(prepareContext, child, childRegionWidth),
+ prepareForRegionWidth(prepareContext, child, thisChildRegionWidth),
path,
index,
depth + 1,
- childRegionX,
- childRegionWidth,
+ thisChildRegionX,
+ thisChildRegionWidth,
state,
prepareContext,
fragmentContext,
@@ -488,7 +515,7 @@ private void compileHorizontalRow(PreparedNode prepared,
MeasureResult naturalMeasure) {
DocumentNode node = prepared.node();
double rowOuterHeight = naturalMeasure.height() + margin.vertical();
- double fullPageHeight = state.canvas.innerHeight();
+ double fullPageHeight = state.activeInnerHeight();
if (rowOuterHeight > fullPageHeight + CAPACITY_TOLERANCE) {
throw atomicTooLarge(path, rowOuterHeight, fullPageHeight);
}
@@ -712,7 +739,7 @@ private void compileStackedLayer(PreparedNode prepared,
MeasureResult naturalMeasure) {
DocumentNode node = prepared.node();
double stackOuterHeight = naturalMeasure.height() + margin.vertical();
- double fullPageHeight = state.canvas.innerHeight();
+ double fullPageHeight = state.activeInnerHeight();
if (stackOuterHeight > fullPageHeight + CAPACITY_TOLERANCE) {
throw atomicTooLarge(path, stackOuterHeight, fullPageHeight);
}
@@ -853,7 +880,7 @@ private void compileAtomicLeaf(PreparedNode prepared,
Padding padding = toPadding(node.padding());
MeasureResult naturalMeasure = prepared.measureResult();
double outerHeight = naturalMeasure.height() + margin.vertical();
- double fullPageHeight = state.canvas.innerHeight();
+ double fullPageHeight = state.activeInnerHeight();
if (outerHeight > fullPageHeight + CAPACITY_TOLERANCE) {
throw atomicTooLarge(path, outerHeight, fullPageHeight);
@@ -1034,7 +1061,7 @@ private void compileSplittableLeaf(PreparedNode prepared,
Padding currentPadding = toPadding(currentNode.padding());
MeasureResult pieceMeasure = current.measureResult();
double pieceOuterHeight = pieceMeasure.height() + currentMargin.vertical();
- double fullPageOuterHeight = state.canvas.innerHeight();
+ double fullPageOuterHeight = state.activeInnerHeight();
if (pieceOuterHeight <= state.remainingHeight() + EPS) {
state.touchPage();
@@ -1440,16 +1467,20 @@ private List compositeDecorationFragments(PreparedNode placed = new ArrayList<>();
- // On bled edges the clamp bound is the physical page edge rather than the
- // content-area edge, so the fill reaches past the top/bottom margin.
- double pageTopY = bleed.bleeds(DocumentEdge.TOP)
- ? canvas.height()
- : canvas.height() - canvas.margin().top();
- double pageBottomY = bleed.bleeds(DocumentEdge.BOTTOM)
- ? 0.0
- : canvas.margin().bottom();
-
+ // The intermediate-page band edges follow each page's own margin, so a fill
+ // spanning a per-page-margin boundary clamps to the right content area on
+ // every page. With no per-page margins every page resolves to the canvas
+ // margin and the bounds are identical for all pages, as before.
+ PageGeometry geometry = fragmentContext.pageGeometry();
for (int pageIndex = startPage; pageIndex <= endPage; pageIndex++) {
+ // On bled edges the clamp bound is the physical page edge rather than the
+ // content-area edge, so the fill reaches past the top/bottom margin.
+ double pageTopY = bleed.bleeds(DocumentEdge.TOP)
+ ? canvas.height()
+ : canvas.height() - pageMarginTop(canvas, geometry, pageIndex);
+ double pageBottomY = bleed.bleeds(DocumentEdge.BOTTOM)
+ ? 0.0
+ : pageMarginBottom(canvas, geometry, pageIndex);
double segmentTopY = pageIndex == startPage ? startPageTopY : pageTopY;
double segmentBottomY = pageIndex == endPage ? endPageBottomY : pageBottomY;
segmentTopY = Math.min(segmentTopY, pageTopY);
@@ -1479,6 +1510,14 @@ private List compositeDecorationFragments(PreparedNode compositeOverlayFragments(PreparedNode prepared,
NodeDefinition definition,
String path,
@@ -1496,10 +1535,10 @@ private List compositeOverlayFragments(PreparedNode placed = new ArrayList<>();
- double pageTopY = canvas.height() - canvas.margin().top();
- double pageBottomY = canvas.margin().bottom();
-
+ PageGeometry geometry = fragmentContext.pageGeometry();
for (int pageIndex = startPage; pageIndex <= endPage; pageIndex++) {
+ double pageTopY = canvas.height() - pageMarginTop(canvas, geometry, pageIndex);
+ double pageBottomY = pageMarginBottom(canvas, geometry, pageIndex);
double segmentTopY = pageIndex == startPage ? startPageTopY : pageTopY;
double segmentBottomY = pageIndex == endPage ? endPageBottomY : pageBottomY;
segmentTopY = Math.min(segmentTopY, pageTopY);
@@ -1572,7 +1611,7 @@ private void advanceSpace(double amount, CompilerState state) {
state.newPage();
}
state.touchPage();
- state.usedHeight = Math.min(state.canvas.innerHeight(), state.usedHeight + amount);
+ state.usedHeight = Math.min(state.activeInnerHeight(), state.usedHeight + amount);
}
/**
diff --git a/src/main/java/com/demcha/compose/document/layout/PageGeometry.java b/src/main/java/com/demcha/compose/document/layout/PageGeometry.java
new file mode 100644
index 000000000..9eb571063
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/PageGeometry.java
@@ -0,0 +1,79 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.engine.components.style.Margin;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Resolves page geometry (margin, inner width, inner height) as a function of the
+ * page index, so the compiler can place content against a margin that varies from
+ * page to page. Backed by the document-wide {@link LayoutCanvas} plus a list of
+ * {@link PageMarginOverride}s; a page no override covers resolves to the canvas's
+ * own margin.
+ *
+ * Overrides are consulted in list order and the last one that covers a
+ * page wins (mirroring the page-background layering rule). When the override list
+ * is empty the resolver returns the canvas geometry unchanged for every page, so a
+ * document with no per-page margins is laid out exactly as before.
+ *
+ * @author Artem Demchyshyn
+ * @since 1.9.0
+ */
+public final class PageGeometry {
+
+ private final LayoutCanvas canvas;
+ private final List overrides;
+
+ private PageGeometry(LayoutCanvas canvas, List overrides) {
+ this.canvas = Objects.requireNonNull(canvas, "canvas");
+ this.overrides = List.copyOf(overrides);
+ }
+
+ /**
+ * Creates a resolver for the given canvas and per-page overrides.
+ *
+ * @param canvas the document-wide canvas (its margin is the fallback)
+ * @param overrides per-page margin overrides; may be empty
+ * @return a page-geometry resolver
+ */
+ public static PageGeometry of(LayoutCanvas canvas, List overrides) {
+ return new PageGeometry(canvas, overrides == null ? List.of() : overrides);
+ }
+
+ /**
+ * The margin to apply on the given page.
+ *
+ * @param pageIndex 0-based page index
+ * @return the last covering override's margin, or the canvas margin
+ */
+ public Margin marginForPage(int pageIndex) {
+ Margin resolved = canvas.margin();
+ for (PageMarginOverride override : overrides) {
+ if (override.coversPage(pageIndex)) {
+ resolved = override.margin();
+ }
+ }
+ return resolved;
+ }
+
+ /**
+ * The content width on the given page (page width minus the page's horizontal margin).
+ *
+ * @param pageIndex 0-based page index
+ * @return inner width, clamped to be non-negative
+ */
+ public double innerWidthForPage(int pageIndex) {
+ return Math.max(0.0, canvas.width() - marginForPage(pageIndex).horizontal());
+ }
+
+ /**
+ * The content height on the given page (page height minus the page's vertical margin).
+ *
+ * @param pageIndex 0-based page index
+ * @return inner height, clamped to be non-negative
+ */
+ public double innerHeightForPage(int pageIndex) {
+ return Math.max(0.0, canvas.height() - marginForPage(pageIndex).vertical());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/layout/PageMarginOverride.java b/src/main/java/com/demcha/compose/document/layout/PageMarginOverride.java
new file mode 100644
index 000000000..335272bb3
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/PageMarginOverride.java
@@ -0,0 +1,45 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.engine.components.style.Margin;
+
+import java.util.Objects;
+
+/**
+ * Engine-side per-page-range margin override: the margin to apply to a contiguous
+ * run of pages, addressed by 0-based page index. The canonical
+ * {@code PageMarginRule} (1-based, public API) is translated into this internal
+ * form by {@code DocumentSession} before it reaches the compiler.
+ *
+ * @param fromPageIndex first page index the override covers (0-based, inclusive)
+ * @param toPageIndexExclusive one past the last page index covered (exclusive)
+ * @param margin the margin to apply across the covered pages
+ * @author Artem Demchyshyn
+ * @since 1.9.0
+ */
+public record PageMarginOverride(int fromPageIndex, int toPageIndexExclusive, Margin margin) {
+
+ /**
+ * Validates the index range and margin.
+ */
+ public PageMarginOverride {
+ Objects.requireNonNull(margin, "margin");
+ if (fromPageIndex < 0) {
+ throw new IllegalArgumentException("fromPageIndex must be >= 0 but was " + fromPageIndex);
+ }
+ if (toPageIndexExclusive <= fromPageIndex) {
+ throw new IllegalArgumentException(
+ "toPageIndexExclusive (" + toPageIndexExclusive + ") must be greater than fromPageIndex ("
+ + fromPageIndex + ")");
+ }
+ }
+
+ /**
+ * Whether this override covers the given page.
+ *
+ * @param pageIndex a 0-based page index
+ * @return {@code true} when {@code fromPageIndex <= pageIndex < toPageIndexExclusive}
+ */
+ public boolean coversPage(int pageIndex) {
+ return pageIndex >= fromPageIndex && pageIndex < toPageIndexExclusive;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/layout/PrepareContext.java b/src/main/java/com/demcha/compose/document/layout/PrepareContext.java
index 2fef1d3dd..677f5e296 100644
--- a/src/main/java/com/demcha/compose/document/layout/PrepareContext.java
+++ b/src/main/java/com/demcha/compose/document/layout/PrepareContext.java
@@ -62,6 +62,31 @@ default boolean markdownEnabled() {
default OptionalInt resolvedPage(String anchor) {
return OptionalInt.empty();
}
+
+ /**
+ * Per-page geometry resolver for documents with per-page margin overrides, or
+ * {@code null} when the document uses a single document-wide margin (the common
+ * case, laid out exactly as before).
+ *
+ * @return the page-geometry resolver, or {@code null}
+ */
+ default PageGeometry pageGeometry() {
+ return null;
+ }
+
+ /**
+ * The page a node begins on, carried over from the previous layout pass so this
+ * pass can measure the node at that page's content width (the per-page-margin
+ * fixed point). Returns {@code fallback} on the first pass and for documents
+ * without per-page margins.
+ *
+ * @param path stable semantic path of the node
+ * @param fallback value to use when no assignment is carried over
+ * @return the 0-based start page assigned in the previous pass, or {@code fallback}
+ */
+ default int assignedStartPage(String path, int fallback) {
+ return fallback;
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java b/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java
index 55f82bdaf..cbfe6894f 100644
--- a/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java
+++ b/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java
@@ -28,6 +28,7 @@
import com.demcha.compose.document.layout.BoxConstraints;
import com.demcha.compose.document.layout.BuiltInNodeDefinitions;
import com.demcha.compose.document.layout.FragmentContext;
+import com.demcha.compose.document.layout.PageGeometry;
import com.demcha.compose.document.layout.FragmentPlacement;
import com.demcha.compose.document.layout.LayoutCanvas;
import com.demcha.compose.document.layout.LayoutFragment;
@@ -1123,6 +1124,11 @@ public LayoutCanvas canvas() {
return canvas;
}
+ @Override
+ public PageGeometry pageGeometry() {
+ return null;
+ }
+
@Override
public boolean markdownEnabled() {
return true;
diff --git a/src/test/java/com/demcha/compose/document/api/PageMarginTest.java b/src/test/java/com/demcha/compose/document/api/PageMarginTest.java
new file mode 100644
index 000000000..0f08718c2
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/api/PageMarginTest.java
@@ -0,0 +1,202 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.OptionalInt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * {@code pageMargins(...)} overrides the page margin for ranges of pages, changing
+ * the content box (both horizontally and vertically) per page. Asserted against the
+ * placed layout graph, so both the horizontal origin (left margin) and the vertical
+ * origin (top margin) are checked directly.
+ */
+class PageMarginTest {
+
+ private static PlacedNode node(LayoutGraph graph, String semanticName) {
+ return graph.nodes().stream()
+ .filter(n -> semanticName.equals(n.semanticName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no placed node named " + semanticName));
+ }
+
+ /** Top edge of a placed node in page coordinates (origin bottom-left). */
+ private static double topEdge(PlacedNode n) {
+ return n.placementY() + n.placementHeight();
+ }
+
+ @Test
+ void perPageMarginShiftsTheContentBoxHorizontallyAndVertically() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.symmetric(15, 10)), // wide cover
+ PageMarginRule.from(2, DocumentInsets.symmetric(40, 70)))); // narrow body
+ session.pageFlow()
+ .addParagraph(p -> p.name("cover").text("Cover"))
+ .addPageBreak(b -> { })
+ .addParagraph(p -> p.name("body").text("Body"))
+ .build();
+
+ LayoutGraph graph = session.layoutGraph();
+ PlacedNode cover = node(graph, "cover");
+ PlacedNode body = node(graph, "body");
+
+ // Page 1 (cover rule): left margin 10, top margin 15.
+ assertThat(cover.startPage()).isZero();
+ assertThat(cover.placementX()).isCloseTo(10.0, within(0.5));
+ assertThat(topEdge(cover)).isCloseTo(300.0 - 15.0, within(0.5));
+
+ // Page 2 (body rule): left margin 70, top margin 40.
+ assertThat(body.startPage()).isEqualTo(1);
+ assertThat(body.placementX()).isCloseTo(70.0, within(0.5));
+ assertThat(topEdge(body)).isCloseTo(300.0 - 40.0, within(0.5));
+ }
+ }
+
+ @Test
+ void naturalOverflowResolvesEachBlockAtItsStartPageWidth() throws Exception {
+ // Same vertical margin on both pages (so pagination is width-independent),
+ // different horizontal margin — the fixed point must measure each block at
+ // the width of the page it naturally flows onto.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.symmetric(20, 10)),
+ PageMarginRule.from(2, DocumentInsets.symmetric(20, 70))));
+ var flow = session.pageFlow();
+ for (int i = 0; i < 40; i++) {
+ final int index = i;
+ flow.addParagraph(p -> p.name("p" + index).text("Line " + index));
+ }
+ flow.build();
+
+ LayoutGraph graph = session.layoutGraph();
+ assertThat(graph.totalPages()).isGreaterThan(1);
+
+ PlacedNode firstOnPage0 = graph.nodes().stream()
+ .filter(n -> n.startPage() == 0 && n.semanticName() != null)
+ .findFirst().orElseThrow();
+ PlacedNode firstOnPage1 = graph.nodes().stream()
+ .filter(n -> n.startPage() == 1 && n.semanticName() != null)
+ .findFirst().orElseThrow();
+
+ assertThat(firstOnPage0.placementX()).isCloseTo(10.0, within(0.5));
+ assertThat(firstOnPage1.placementX()).isCloseTo(70.0, within(0.5));
+ }
+ }
+
+ @Test
+ void laterRuleWinsWhenRangesOverlap() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.from(1, DocumentInsets.of(10)), // all pages, left 10
+ PageMarginRule.page(1, DocumentInsets.of(50)))); // page 1 overrides to 50
+ session.pageFlow().addParagraph(p -> p.name("first").text("First")).build();
+
+ PlacedNode first = node(session.layoutGraph(), "first");
+ assertThat(first.placementX()).isCloseTo(50.0, within(0.5));
+ }
+ }
+
+ @Test
+ void emptyRulesLeaveTheDocumentWideMarginInPlace() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 300)
+ .margin(DocumentInsets.of(33))
+ .create()) {
+ session.pageMargins(List.of());
+ session.pageFlow().addParagraph(p -> p.name("only").text("Only")).build();
+
+ assertThat(node(session.layoutGraph(), "only").placementX()).isCloseTo(33.0, within(0.5));
+ }
+ }
+
+ @Test
+ void aBlockSpanningAMarginBoundaryKeepsItsStartPageWidth() throws Exception {
+ // Documented limitation: a single block is laid out at the width of the page
+ // it BEGINS on, even when it overflows onto a page whose margin differs.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(220, 150)
+ .margin(DocumentInsets.of(16))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.symmetric(16, 10)),
+ PageMarginRule.from(2, DocumentInsets.symmetric(16, 70))));
+ session.pageFlow().addParagraph(p -> p.name("long").text(
+ "This single paragraph is deliberately long enough to wrap across many "
+ + "lines and overflow from the first page onto the second, so it spans a "
+ + "per-page-margin boundary. Because a block is measured at the width of "
+ + "the page it begins on, it keeps the first page's content column for its "
+ + "whole length rather than re-wrapping at the narrower body margin.")).build();
+
+ LayoutGraph graph = session.layoutGraph();
+ PlacedNode block = node(graph, "long");
+ assertThat(block.startPage()).isZero();
+ assertThat(block.endPage()).isGreaterThan(0); // it really spans the boundary
+ assertThat(block.placementX()).isCloseTo(10.0, within(0.5)); // page-1 width throughout, not 70
+ }
+ }
+
+ @Test
+ void perPageMarginCoexistsWithPageReferences() throws Exception {
+ // The unified fixed point resolves page numbers AND per-page widths together.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.symmetric(20, 10)),
+ PageMarginRule.from(2, DocumentInsets.symmetric(20, 70))));
+ var flow = session.pageFlow();
+ flow.addParagraph(p -> p.name("intro").text("Appendix on page"));
+ flow.addPageReference("appendix", DocumentTextStyle.DEFAULT, TextAlign.LEFT);
+ for (int i = 0; i < 30; i++) {
+ final int n = i;
+ flow.addParagraph(p -> p.text("Filler line " + n));
+ }
+ flow.addParagraph(p -> p.name("appendix").anchor("appendix").text("Appendix"));
+ flow.build();
+
+ // Page reference resolved to a genuine forward page...
+ OptionalInt resolved = session.pageIndex().pageNumberOf("appendix");
+ assertThat(resolved).isPresent();
+ assertThat(resolved.getAsInt()).isGreaterThan(1);
+
+ // ...and the per-page margin still applies on each page.
+ LayoutGraph graph = session.layoutGraph();
+ assertThat(node(graph, "intro").placementX()).isCloseTo(10.0, within(0.5));
+ assertThat(node(graph, "appendix").placementX()).isCloseTo(70.0, within(0.5));
+ }
+ }
+
+ @Test
+ void ruleRejectsInvalidRangeAndNegativeInsets() {
+ assertThatThrownBy(() -> new PageMarginRule(0, 2, DocumentInsets.of(10)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("fromPage");
+ assertThatThrownBy(() -> new PageMarginRule(3, 3, DocumentInsets.of(10)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("toPageExclusive");
+ assertThatThrownBy(() -> PageMarginRule.page(1, new DocumentInsets(-1, 0, 0, 0)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("bleed");
+ }
+}
From 6578a84c89a2ee9c951bd30b5f89a24ab75146d1 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 26 Jun 2026 14:19:49 +0100
Subject: [PATCH 2/2] fix(api): reject non-finite page margins; cover
per-page-margin edges
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
docs/api-stability.md | 2 +-
.../features/layout/PerPageMarginExample.java | 2 +-
.../compose/document/api/DocumentSession.java | 12 +-
.../compose/document/api/PageMarginRule.java | 17 ++-
.../compose/document/api/PageMarginTest.java | 112 +++++++++++++++++-
5 files changed, 134 insertions(+), 11 deletions(-)
diff --git a/docs/api-stability.md b/docs/api-stability.md
index e4063a959..cf9198e14 100644
--- a/docs/api-stability.md
+++ b/docs/api-stability.md
@@ -176,7 +176,7 @@ window starts, and its `Status` flips to `deprecated 1.x`.
| Element | Tier now | Status | Why the 1.x shape is a compromise | 2.0 action | ADR | Issue |
|---|---|---|---|---|---|---|
-| _none yet_ | | | | | | |
+| `DocumentSession.pageMargins(List)` / `PageMarginRule` | Stable | planned | Per-page margins resolve a block's content width by the page it *begins* on (the engine measures each block once, before pagination). A margin that changes the content width therefore does not re-wrap a block mid-flow across a page boundary. | Revisit a page-aware per-line/per-fragment width model so a block can re-wrap when it crosses a margin boundary, if demand warrants. | — | — |
---
diff --git a/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java b/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
index b2ba9aecd..2dff26e19 100644
--- a/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java
@@ -22,7 +22,7 @@
* {@code
* document.pageMargins(List.of(
* PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover
- * PageMarginRule.from(2, DocumentInsets.symmetric(40, 86)))); // book body
+ * PageMarginRule.from(2, DocumentInsets.symmetric(36, 86)))); // book body
* }
*
* @author Artem Demchyshyn
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index d8e098d9e..5f0586f73 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -464,11 +464,13 @@ public DocumentSession pageBackgrounds(List fills) {
* empty list to clear (the default — every page uses the document-wide margin).
*
* Because content is measured before it is paginated, each top-level block is
- * laid out at the content width of the page it begins on. A margin that changes
- * the content width therefore takes effect where content breaks onto a covered
- * page — the model is different margins for different page ranges, not a margin
- * that changes mid-block. To extend a single node past the page edge instead,
- * use {@code bleed(...)} (see {@link com.demcha.compose.document.style.DocumentBleed}).
+ * laid out at the content width and against the vertical space of the page
+ * it begins on. A margin that changes the content width or the usable height
+ * therefore takes effect where content breaks onto a covered page — the model is
+ * different margins for different page ranges, not a margin that changes mid-block,
+ * and a keep-together block's fit is judged against its starting page's height. To
+ * extend a single node past the page edge instead, use {@code bleed(...)} (see
+ * {@link com.demcha.compose.document.style.DocumentBleed}).
*
* @param rules ordered list of per-page margin overrides, or {@code null}/empty to clear
* @return this session
diff --git a/src/main/java/com/demcha/compose/document/api/PageMarginRule.java b/src/main/java/com/demcha/compose/document/api/PageMarginRule.java
index 8106d5fdf..84cc0e501 100644
--- a/src/main/java/com/demcha/compose/document/api/PageMarginRule.java
+++ b/src/main/java/com/demcha/compose/document/api/PageMarginRule.java
@@ -50,13 +50,18 @@ public record PageMarginRule(int fromPage, int toPageExclusive, DocumentInsets i
throw new IllegalArgumentException(
"toPageExclusive (" + toPageExclusive + ") must be greater than fromPage (" + fromPage + ")");
}
- if (insets.top() < 0 || insets.right() < 0 || insets.bottom() < 0 || insets.left() < 0) {
+ if (!isNonNegativeFinite(insets.top()) || !isNonNegativeFinite(insets.right())
+ || !isNonNegativeFinite(insets.bottom()) || !isNonNegativeFinite(insets.left())) {
throw new IllegalArgumentException(
- "page margin must be non-negative; use bleed() to extend a node past the page edge "
- + "(see DocumentBleed). Insets were " + insets);
+ "page margin must be a finite, non-negative value; use bleed() to extend a node past "
+ + "the page edge (see DocumentBleed). Insets were " + insets);
}
}
+ private static boolean isNonNegativeFinite(double value) {
+ return Double.isFinite(value) && value >= 0.0;
+ }
+
/**
* A rule covering a single page.
*
@@ -65,6 +70,9 @@ public record PageMarginRule(int fromPage, int toPageExclusive, DocumentInsets i
* @return a rule covering exactly {@code page}
*/
public static PageMarginRule page(int page, DocumentInsets insets) {
+ if (page == Integer.MAX_VALUE) {
+ throw new IllegalArgumentException("page must be < Integer.MAX_VALUE; use from(...) for an open-ended range");
+ }
return new PageMarginRule(page, page + 1, insets);
}
@@ -80,6 +88,9 @@ public static PageMarginRule range(int fromPage, int toPage, DocumentInsets inse
if (toPage < fromPage) {
throw new IllegalArgumentException("toPage (" + toPage + ") must be >= fromPage (" + fromPage + ")");
}
+ if (toPage == Integer.MAX_VALUE) {
+ throw new IllegalArgumentException("toPage must be < Integer.MAX_VALUE; use from(...) for an open-ended range");
+ }
return new PageMarginRule(fromPage, toPage + 1, insets);
}
diff --git a/src/test/java/com/demcha/compose/document/api/PageMarginTest.java b/src/test/java/com/demcha/compose/document/api/PageMarginTest.java
index 0f08718c2..b6b645b64 100644
--- a/src/test/java/com/demcha/compose/document/api/PageMarginTest.java
+++ b/src/test/java/com/demcha/compose/document/api/PageMarginTest.java
@@ -7,7 +7,9 @@
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import java.nio.file.Path;
import java.util.List;
import java.util.OptionalInt;
@@ -23,6 +25,9 @@
*/
class PageMarginTest {
+ @TempDir
+ Path tempDir;
+
private static PlacedNode node(LayoutGraph graph, String semanticName) {
return graph.nodes().stream()
.filter(n -> semanticName.equals(n.semanticName()))
@@ -188,7 +193,7 @@ void perPageMarginCoexistsWithPageReferences() throws Exception {
}
@Test
- void ruleRejectsInvalidRangeAndNegativeInsets() {
+ void ruleRejectsInvalidRangeAndNonFiniteOrNegativeInsets() {
assertThatThrownBy(() -> new PageMarginRule(0, 2, DocumentInsets.of(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("fromPage");
@@ -198,5 +203,110 @@ void ruleRejectsInvalidRangeAndNegativeInsets() {
assertThatThrownBy(() -> PageMarginRule.page(1, new DocumentInsets(-1, 0, 0, 0)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("bleed");
+ // NaN / Infinity must be rejected at the public type, not slip through to the engine.
+ assertThatThrownBy(() -> PageMarginRule.page(1, new DocumentInsets(Double.NaN, 0, 0, 0)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("finite");
+ assertThatThrownBy(() -> PageMarginRule.page(1, new DocumentInsets(0, Double.POSITIVE_INFINITY, 0, 0)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("finite");
+ // The +1 factories must not overflow Integer.MAX_VALUE into a confusing message.
+ assertThatThrownBy(() -> PageMarginRule.page(Integer.MAX_VALUE, DocumentInsets.of(10)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("from(");
+ assertThatThrownBy(() -> PageMarginRule.range(1, Integer.MAX_VALUE, DocumentInsets.of(10)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("from(");
+ }
+
+ @Test
+ void nullRulesClearLikeAnEmptyList() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 300)
+ .margin(DocumentInsets.of(28))
+ .create()) {
+ session.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.of(8))));
+ session.pageMargins(null); // clears back to the document-wide margin
+ session.pageFlow().addParagraph(p -> p.name("only").text("Only")).build();
+
+ assertThat(node(session.layoutGraph(), "only").placementX()).isCloseTo(28.0, within(0.5));
+ }
+ }
+
+ @Test
+ void aMiddleOnlyRuleLeavesNeighbourPagesAtTheBaseMargin() throws Exception {
+ // A rule for page 2 only: page 1 and page 3 keep the base margin, page 2 is overridden.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 160)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(PageMarginRule.page(2, DocumentInsets.symmetric(20, 80))));
+ session.pageFlow()
+ .addParagraph(p -> p.name("p1").text("Page one"))
+ .addPageBreak(b -> { })
+ .addParagraph(p -> p.name("p2").text("Page two"))
+ .addPageBreak(b -> { })
+ .addParagraph(p -> p.name("p3").text("Page three"))
+ .build();
+
+ LayoutGraph graph = session.layoutGraph();
+ assertThat(node(graph, "p1").placementX()).isCloseTo(20.0, within(0.5)); // base
+ assertThat(node(graph, "p2").placementX()).isCloseTo(80.0, within(0.5)); // override
+ assertThat(node(graph, "p3").placementX()).isCloseTo(20.0, within(0.5)); // base again
+ }
+ }
+
+ @Test
+ void everyTopLevelBlockIsPlacedConsistentlyWithItsStartPage() throws Exception {
+ // The coupled fixed point must converge to a consistent assignment: every block's
+ // placement X must match the left margin of the page it actually lands on (no block
+ // measured for one page but placed against another's geometry).
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 150)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(
+ PageMarginRule.page(1, DocumentInsets.symmetric(20, 12)),
+ PageMarginRule.from(2, DocumentInsets.symmetric(20, 64))));
+ var flow = session.pageFlow();
+ for (int i = 0; i < 30; i++) {
+ final int n = i;
+ flow.addParagraph(p -> p.name("b" + n).text("Block " + n + " sitting near a boundary"));
+ }
+ flow.build();
+
+ LayoutGraph graph = session.layoutGraph();
+ assertThat(graph.totalPages()).isGreaterThan(1);
+ for (PlacedNode block : graph.nodes()) {
+ if (block.parentPath() != null && block.semanticName() != null
+ && block.semanticName().startsWith("b")) {
+ double expectedLeft = block.startPage() == 0 ? 12.0 : 64.0;
+ assertThat(block.placementX())
+ .as("block %s on page %d", block.semanticName(), block.startPage())
+ .isCloseTo(expectedLeft, within(0.5));
+ }
+ }
+ }
+ }
+
+ @Test
+ void eachMultiSectionSectionResolvesItsOwnSectionLocalPageMargins() throws Exception {
+ DocumentSession cover = GraphCompose.document().pageSize(240, 200).margin(DocumentInsets.of(20)).create();
+ cover.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.of(12))));
+ cover.pageFlow().addParagraph(p -> p.name("cover").text("Cover")).build();
+ assertThat(node(cover.layoutGraph(), "cover").placementX()).isCloseTo(12.0, within(0.5));
+
+ DocumentSession body = GraphCompose.document().pageSize(240, 200).margin(DocumentInsets.of(20)).create();
+ // Section-local page 1 → its own rule, independent of the cover section.
+ body.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.of(72))));
+ body.pageFlow().addParagraph(p -> p.name("body").text("Body")).build();
+ assertThat(node(body.layoutGraph(), "body").placementX()).isCloseTo(72.0, within(0.5));
+
+ Path out = tempDir.resolve("multi-margins.pdf");
+ try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
+ doc.buildPdf();
+ assertThat(doc.sectionSnapshots()).hasSize(2);
+ }
+ assertThat(out).exists();
}
}