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 000000000..f0cef8f46 Binary files /dev/null and b/assets/readme/examples/per-page-margin.pdf differ 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/README.md b/examples/README.md index d2f004097..868097d1d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,6 +76,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Transforms](#transforms) | `rotate`, `scale`, and per-layer `zIndex` swap | [PDF](../assets/readme/examples/transforms.pdf) · [Source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) | | [Block alignment](#block-alignment) | `addAligned(align, node)` / `addSvgIcon(icon, w, align)` — seat any fixed-size node left / centre / right across the content width | [PDF](../assets/readme/examples/block-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) | | [Content bleed](#content-bleed) | `band.bleedToEdge(TOP, LEFT, RIGHT)` / `bleed(DocumentBleed.of(...))` — a section's fill reaches the trimmed page edge while its children stay in the content margin | [PDF](../assets/readme/examples/content-bleed.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) | +| [Per-page margin](#per-page-margin) | `pageMargins(List.of(PageMarginRule.page(1, zero()), PageMarginRule.from(2, …)))` — a full-bleed cover and a book-margin body in one document | [PDF](../assets/readme/examples/per-page-margin.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/PerPageMarginExample.java) | | [Row columns & TOC](#row-columns--toc) | `row.columns(auto(), weight(1), auto())` — size columns by content / fixed points / weight; with `line().fill()` it builds a dot-leader table of contents | [PDF](../assets/readme/examples/row-columns.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) | | [Row vertical align](#row-vertical-align) | `row.verticalAlign(TOP / CENTER / BOTTOM)` — seat a row's children on the cross axis within the band set by the tallest child | [PDF](../assets/readme/examples/row-vertical-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java) | | [Row flex & arrangement](#row-flex--arrangement) | `row.pushRight()` / `flexSpacer()` springs + `arrangement(SPACE_BETWEEN / CENTER / …)` — push children apart or justify leftover width | [PDF](../assets/readme/examples/row-flex.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowFlexExample.java) | @@ -480,6 +481,23 @@ page.addSection(band -> 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..2dff26e19 --- /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(36, 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..5f0586f73 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,37 @@ 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 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 + * @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 +834,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..84cc0e501 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/api/PageMarginRule.java @@ -0,0 +1,117 @@ +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 (!isNonNegativeFinite(insets.top()) || !isNonNegativeFinite(insets.right()) + || !isNonNegativeFinite(insets.bottom()) || !isNonNegativeFinite(insets.left())) { + throw new IllegalArgumentException( + "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. + * + * @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) { + 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); + } + + /** + * 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 + ")"); + } + 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); + } + + /** + * 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..b6b645b64 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/api/PageMarginTest.java @@ -0,0 +1,312 @@ +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 org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +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 { + + @TempDir + Path tempDir; + + 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 ruleRejectsInvalidRangeAndNonFiniteOrNegativeInsets() { + 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"); + // 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(); + } +}