Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ PDF `GoTo` actions. External links are unchanged.

### Public API

- **`DocumentSession.pageMargins(List<PageMarginRule>)`** (`@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
Expand Down
Binary file added assets/readme/examples/per-page-margin.pdf
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>)` / `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. | — | — |

---

Expand Down
18 changes: 18 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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&nbsp;1 is a full-bleed cover
* ({@link DocumentInsets#zero()} margin), so the same {@code fillColor} section
* spans the whole sheet; pages&nbsp;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.
*
* <pre>{@code
* document.pageMargins(List.of(
* PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover
* PageMarginRule.from(2, DocumentInsets.symmetric(36, 86)))); // book body
* }</pre>
*
* @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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public final class DocumentSession implements AutoCloseable {
private boolean markdown;
private DocumentDebugOptions debug = DocumentDebugOptions.none();
private List<PageBackgroundFill> pageBackgrounds = List.of();
private List<PageMarginRule> pageMargins = List.of();
private PdfMeasurementResources measurementResources;
private boolean closed;

Expand Down Expand Up @@ -451,6 +452,37 @@ public DocumentSession pageBackgrounds(List<PageBackgroundFill> 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.
*
* <p>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).</p>
*
* <p>Because content is measured before it is paginated, each top-level block is
* laid out at the content width <em>and</em> 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}).</p>
*
* @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<PageMarginRule> 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
Expand Down Expand Up @@ -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<String, Integer> 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<String, Integer> resolved = hasPageReference ? resolvedPageNumbers(graph) : Map.of();
Map<String, Integer> startPages = hasMargins ? nodeStartPages(graph) : Map.of();
for (int pass = 0; pass < MAX_PAGE_REFERENCE_PASSES; pass++) {
graph = compilePass(resolved);
Map<String, Integer> rendered = resolvedPageNumbers(graph);
if (rendered.equals(resolved)) {
graph = compilePass(resolved, geometry, startPages);
Map<String, Integer> renderedPages = hasPageReference ? resolvedPageNumbers(graph) : Map.of();
Map<String, Integer> 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<String, Integer> resolvedPages) {
private LayoutGraph compilePass(Map<String, Integer> resolvedPages,
PageGeometry geometry,
Map<String, Integer> 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<PageMarginOverride> 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<String, Integer> nodeStartPages(LayoutGraph graph) {
Map<String, Integer> starts = new HashMap<>();
for (PlacedNode node : graph.nodes()) {
starts.put(node.path(), node.startPage());
}
return starts;
}

private static boolean containsPageReference(List<DocumentNode> nodes) {
for (DocumentNode node : nodes) {
if (node instanceof PageReferenceNode || containsPageReference(node.children())) {
Expand Down
Loading
Loading