From 1c1c50b0d46c6550ff959c65ccc1f2d57da907ba Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 24 Jun 2026 10:30:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(api):=20content=20bleed=20=E2=80=94=20sect?= =?UTF-8?q?ions=20fill=20to=20the=20trimmed=20page=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sections gain bleed(DocumentBleed) / bleedToEdge(DocumentEdge...): the background fill extends to the physical page edge on the declared sides while the section's children stay inside the content margin, so a full-bleed masthead band's heading never runs off the page. The content-side twin of pageBackground(...) and the intent-revealing replacement for the hand-computed negative-margin idiom. A DocumentEdge enum + DocumentBleed record join document.style; an additive DocumentNode.bleed() default keeps every other node byte-identical; SectionNode carries the bleed; LayoutCompiler resolves the decoration box against the page canvas and relaxes the vertical content-area clamp on bled edges. Verified: ./mvnw test -pl . — 1487 tests, 0 baselines changed. DocumentBleedTest asserts the bled fill reaches x=0 / width=pageWidth / top=pageHeight; a runnable BleedExample ships with a committed preview. --- CHANGELOG.md | 10 ++ assets/readme/examples/content-bleed.pdf | Bin 0 -> 1271 bytes examples/README.md | 21 ++++ .../demcha/examples/GenerateAllExamples.java | 2 + .../features/layout/BleedExample.java | 110 ++++++++++++++++++ .../document/dsl/AbstractFlowBuilder.java | 40 +++++++ .../compose/document/dsl/SectionBuilder.java | 2 +- .../document/layout/LayoutCompiler.java | 53 ++++++++- .../compose/document/node/DocumentNode.java | 14 +++ .../compose/document/node/SectionNode.java | 37 +++++- .../compose/document/style/DocumentBleed.java | 102 ++++++++++++++++ .../compose/document/style/DocumentEdge.java | 20 ++++ .../document/dsl/DocumentBleedTest.java | 103 ++++++++++++++++ 13 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 assets/readme/examples/content-bleed.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/BleedExample.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentBleed.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentEdge.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/DocumentBleedTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f9ebe07..c4c7e41f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **Content bleed: `DocumentBleed` / `DocumentEdge`** (`@since 1.9.0`). Flow + builders gain `bleed(DocumentBleed)` and `bleedToEdge(DocumentEdge...)`, so a + section's background fill extends to the trimmed physical page edge on the + declared sides — a full-bleed masthead band or an edge-to-edge colour panel — + while the section's children stay inside the content margin (text never runs + off the page). It is the content-side twin of `PageBackgroundFill` and the + intent-revealing replacement for the hand-computed negative-margin idiom, + resolved against the active page margin at layout time. Nodes that do not bleed + render byte-identically to before. + - **In-PDF navigation: anchors + internal links** (`@since 1.9.0`). Every flow and leaf builder gains `anchor(String)`, declaring a named destination at the element's top-left — `section.anchor("intro")`, `paragraph.anchor("fn-1")`, and diff --git a/assets/readme/examples/content-bleed.pdf b/assets/readme/examples/content-bleed.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c13769579afca554cd4083ab39d4df9dd0e92249 GIT binary patch literal 1271 zcmY!laBZ^4=fsl4ocwey{jk)c;>`R! z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y zE~&}+DXAb`#U(|liMhO76?5hW`}WT^5ZL=!`f-!JbuQMWosTmG2A;>WpoZt;RZr&(Ps3qD^o`z2@V(ygP!Vy6|5wNgQQ@$0QjE+@9-TZptAo$zr} zF57*cg81tAa|*vD*KXpDN>bRU5ma(M!|y`1#mU+Qm+xdJ^=^~d8gz}N$)By)NPBVN z(cMcG<7RD%%CJ#7T?kKcV-X!W}@pk@KT)V9v0>a|n;s8>FE7gBB5wZAj})*}(A z$QJ*LdFCItU$vTj`0n-F`Zv?I&D{H>-dk{;IcHvK%A<}9Q{Qz{Rn{cgx?k5S)7`f% zSE41?d)kYW>Gj5I^~-fUxE1&97xwA(n7iozx12wVrM|t*tP=Sg_{+||Vn=%BgYfEA z-rwA+TWWqB5W8R|@aF@^L34eR-^?nfn0k3Z$p(@JUf6JTr7ELW(HeX|JYx~L_?|&7&x|;I3_3qcZnp!OvlkeXUuVLF6);w3b#qT`- zHnCe3_6;}Z#9Oi#=vnn%KX{VUC?HJQ(7)`_H-YAytP5_YyQZ#Uj$IO^t!#flu7+9e zrq3}fA!LaeLdL-S7?hu1q5#UhL0tNtE(*~$=BCarZiX&~rmkj|rp~5rmPSU77M7-_ zt`-Ismd>UwE=JB~76t~cF3u)Sra+Yjj^?Iju10P~CKkrdW`>S-3X~fW3yZjjpwu)j z{ou^1R0Tr=pab(#QY#dq4HPVaD#8__4S@xTA&^P1m`F}Y_;LQkf%6BB9AG@u<8bE4 ri3jXS#>|G?%E9)`3_AQwx>!Q2xFoTtq@oDu0!tH9E>%@me>W}wahUfz literal 0 HcmV?d00001 diff --git a/examples/README.md b/examples/README.md index 9065b50bb..5f66f26e9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -75,6 +75,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Canvas layer (free placement)](#canvas-layer-v16) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) | | [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) | ### 📋 Templates recommended @@ -415,6 +416,26 @@ flow.addAligned(HorizontalAlign.RIGHT, anyFixedNode); [📄 View PDF](../assets/readme/examples/block-align.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) +### Content bleed + +A section's background fill normally stops at the page content margin. +`bleed(DocumentBleed)` / `bleedToEdge(DocumentEdge...)` extends the fill to the +trimmed physical page edge on the declared sides — a full-bleed masthead band or +an edge-to-edge accent strip — while the section's children stay inside the +content margin, so a heading never runs off the page. It is the content-side twin +of `pageBackground(...)` and the intent-revealing replacement for the +hand-computed negative-margin idiom. + +```java +page.addSection(band -> band + .fillColor(ink) + .bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT) + .addParagraph("Title")); // title stays in the safe area +``` + +[📄 View PDF](../assets/readme/examples/content-bleed.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) + ### Advanced tables `DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`. diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index c546c72e8..bfc3adf4c 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -6,6 +6,7 @@ import com.demcha.examples.features.debug.DebugOverlayExample; import com.demcha.examples.features.docx.WordExportExample; import com.demcha.examples.features.chrome.PdfChromeExample; +import com.demcha.examples.features.layout.BleedExample; import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.PhotoClipExample; @@ -145,6 +146,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + PhotoClipExample.generate()); System.out.println("Generated: " + SvgIconGalleryExample.generate()); System.out.println("Generated: " + BlockAlignExample.generate()); + System.out.println("Generated: " + BleedExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/layout/BleedExample.java b/examples/src/main/java/com/demcha/examples/features/layout/BleedExample.java new file mode 100644 index 000000000..f4e785534 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/layout/BleedExample.java @@ -0,0 +1,110 @@ +package com.demcha.examples.features.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentBleed; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentEdge; +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; + +/** + * Runnable showcase for v1.9 content bleed: a section's background fill extends + * to the trimmed physical page edge on the declared sides, while its children + * stay inside the content margin — a full-bleed masthead band whose title never + * runs off the page. The intent-revealing replacement for the hand-computed + * negative-margin idiom. + * + *
{@code
+ * page.addSection(band -> band
+ *     .fillColor(ink)
+ *     .bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT)
+ *     .addParagraph("Title"));            // title stays in the safe area
+ *
+ * page.addSection(rule -> rule
+ *     .fillColor(accent)
+ *     .bleed(DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT)));
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class BleedExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor ACCENT = DocumentColor.rgb(196, 30, 58); + private static final DocumentColor MUTED = DocumentColor.rgb(90, 96, 105); + + private BleedExample() { + } + + /** + * Renders the bleed sheet: a full-bleed masthead, a body paragraph, and a + * side-to-side accent band. + * + * @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", "content-bleed.pdf"); + + DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(420, 480) + .margin(DocumentInsets.of(36)) + .create()) { + document.pageFlow(page -> { + // Full-bleed masthead: fill reaches the top + both side edges, + // the heading text stays seated in the content margin. + page.addSection(band -> band + .fillColor(INK) + .padding(DocumentInsets.of(16)) + .bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT) + .addParagraph(p -> p + .text("Content bleed") + .textStyle(DocumentTextStyle.DEFAULT.withSize(22) + .withColor(DocumentColor.rgb(255, 255, 255))))); + + page.addParagraph(p -> p + .text("band.bleedToEdge(TOP, LEFT, RIGHT) — the fill runs to the page " + + "edge while the title stays inside the content margin, so text " + + "never clips. The intent-revealing replacement for a hand-tuned " + + "negative margin.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED)) + .padding(new DocumentInsets(12, 0, 6, 0))); + + page.addParagraph(p -> p + .text("rule.bleed(DocumentBleed.of(LEFT, RIGHT))") + .textStyle(caption) + .padding(DocumentInsets.top(14))); + + // Edge-to-edge accent band: horizontal bleed only. + page.addSection(rule -> rule + .fillColor(ACCENT) + .padding(new DocumentInsets(5, 0, 5, 0)) + .bleed(DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT)) + .addParagraph(p -> p + .text("edge to edge") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9) + .withColor(DocumentColor.rgb(255, 255, 255))))); + + page.addParagraph(p -> p + .text("Without bleed, the same band would stop at the content margin " + + "on every side.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED)) + .padding(DocumentInsets.top(12))); + }); + + 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/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index 1da637d36..db5c7f84b 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -33,6 +33,7 @@ public abstract class AbstractFlowBuilder, N private DocumentStroke stroke; private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO; private DocumentBorders borders = DocumentBorders.NONE; + private DocumentBleed bleed = DocumentBleed.none(); /** * Creates a base flow builder. @@ -131,6 +132,41 @@ public T margin(float top, float right, float bottom, float left) { return margin(new DocumentInsets(top, right, bottom, left)); } + /** + * Declares the page edges on which this flow bleeds past the page content + * margin to the trimmed physical page edge. The intent-revealing replacement + * for the hand-computed negative-margin idiom — the engine resolves the bleed + * against the active page margin at layout time. + * + *

Only the flow's background fill/border extends to the edge — its + * children stay inside the content margin, so text never runs off the page. + * Horizontal bleed widens the flow to the side edges; vertical bleed extends + * it toward the top/bottom edge and is meaningful for a flow already seated + * against that edge (e.g. a masthead band at the top of the page).

+ * + * @param bleed edges to bleed, or {@code null}/{@link DocumentBleed#none()} to clear + * @return this builder + * @see #bleedToEdge(DocumentEdge...) + * @since 1.9.0 + */ + public T bleed(DocumentBleed bleed) { + this.bleed = bleed == null ? DocumentBleed.none() : bleed; + return self(); + } + + /** + * Shorthand for {@link #bleed(DocumentBleed)} with the given edges — e.g. + * {@code bleedToEdge(DocumentEdge.LEFT, DocumentEdge.RIGHT)} for a band that + * reaches both side edges. + * + * @param edges edges to bleed; no edges clears the bleed + * @return this builder + * @since 1.9.0 + */ + public T bleedToEdge(DocumentEdge... edges) { + return bleed(DocumentBleed.of(edges)); + } + /** * Sets the flow background fill. * @@ -1082,6 +1118,10 @@ protected DocumentCornerRadius cornerRadius() { protected DocumentBorders borders() { return borders; } + + protected DocumentBleed bleed() { + return bleed; + } } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java b/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java index 89d6a31f3..76a39f787 100644 --- a/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java @@ -51,7 +51,7 @@ public SectionBuilder keepTogether(boolean value) { @Override protected SectionNode buildNode() { return new SectionNode(name(), children(), spacing(), padding(), margin(), fillColor(), - stroke(), cornerRadius(), borders(), keepTogether, anchor()); + stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed()); } /** 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 d775f7dee..d817cbdd4 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -5,6 +5,8 @@ import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.style.DocumentBleed; +import com.demcha.compose.document.style.DocumentEdge; import com.demcha.compose.engine.components.style.Margin; import com.demcha.compose.engine.components.style.Padding; import org.slf4j.Logger; @@ -391,6 +393,33 @@ private void compileComposite(PreparedNode prepared, advanceSpace(padding.bottom() + margin.bottom(), state); int endPage = state.pageIndex; double endPageBottomY = state.pageTop() - state.usedHeight + margin.bottom(); + + // Content bleed: the decoration box (fill/border) extends to the trimmed + // page edge on the declared edges, while children stay in the content + // region (so text never runs off the page). Byte-identical when the node + // does not bleed — every value below collapses to the in-margin geometry. + DocumentBleed bleed = node.bleed(); + double decorX = placementX; + double decorWidth = naturalMeasure.width(); + double decorTopY = placementTopY; + double decorBottomY = endPageBottomY; + if (bleed.any()) { + double pageWidth = state.canvas.width(); + double pageHeight = state.canvas.height(); + if (bleed.bleeds(DocumentEdge.LEFT)) { + decorWidth += decorX; + decorX = 0.0; + } + if (bleed.bleeds(DocumentEdge.RIGHT)) { + decorWidth = Math.max(0.0, pageWidth - decorX); + } + if (bleed.bleeds(DocumentEdge.TOP)) { + decorTopY = pageHeight; + } + if (bleed.bleeds(DocumentEdge.BOTTOM)) { + decorBottomY = 0.0; + } + } List decorationFragments = compositeDecorationFragments( prepared, definition, @@ -398,15 +427,16 @@ private void compileComposite(PreparedNode prepared, parentPath, childIndex, depth, - placementX, - placementTopY, - endPageBottomY, - naturalMeasure.width(), + decorX, + decorTopY, + decorBottomY, + decorWidth, startPage, endPage, margin, padding, state.canvas, + bleed, fragmentContext); if (!decorationFragments.isEmpty()) { fragments.addAll(decorationInsertIndex, decorationFragments); @@ -585,6 +615,7 @@ private void compileHorizontalRow(PreparedNode prepared, margin, padding, state.canvas, + DocumentBleed.none(), fragmentContext); if (!decorationFragments.isEmpty()) { fragments.addAll(decorationInsertIndex, decorationFragments); @@ -701,6 +732,7 @@ private void compileStackedLayer(PreparedNode prepared, margin, padding, state.canvas, + DocumentBleed.none(), fragmentContext); if (!decorationFragments.isEmpty()) { fragments.addAll(decorationInsertIndex, decorationFragments); @@ -1212,6 +1244,7 @@ private double compileNodeInFixedSlot(PreparedNode prepared, margin, padding, canvas, + DocumentBleed.none(), fragmentContext); if (!stackDecorations.isEmpty()) { fragments.addAll(decorationInsertIndex, stackDecorations); @@ -1306,6 +1339,7 @@ private double compileNodeInFixedSlot(PreparedNode prepared, margin, padding, canvas, + DocumentBleed.none(), fragmentContext); if (!decorationFragments.isEmpty()) { fragments.addAll(decorationInsertIndex, decorationFragments); @@ -1355,10 +1389,17 @@ private List compositeDecorationFragments(PreparedNode placed = new ArrayList<>(); - double pageTopY = canvas.height() - canvas.margin().top(); - double pageBottomY = canvas.margin().bottom(); + // 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(); for (int pageIndex = startPage; pageIndex <= endPage; pageIndex++) { double segmentTopY = pageIndex == startPage ? startPageTopY : pageTopY; diff --git a/src/main/java/com/demcha/compose/document/node/DocumentNode.java b/src/main/java/com/demcha/compose/document/node/DocumentNode.java index 8fc02e943..98ea62cfb 100644 --- a/src/main/java/com/demcha/compose/document/node/DocumentNode.java +++ b/src/main/java/com/demcha/compose/document/node/DocumentNode.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.node; +import com.demcha.compose.document.style.DocumentBleed; import com.demcha.compose.document.style.DocumentInsets; import java.util.List; @@ -70,5 +71,18 @@ default String nodeKind() { default boolean keepTogether() { return false; } + + /** + * Edges on which this node bleeds past the page content margin to the + * trimmed physical page edge. Default {@link DocumentBleed#none()} (normal + * in-margin placement), so nodes that do not opt in are placed exactly as + * before. + * + * @return the edges to bleed; never {@code null} + * @since 1.9.0 + */ + default DocumentBleed bleed() { + return DocumentBleed.none(); + } } diff --git a/src/main/java/com/demcha/compose/document/node/SectionNode.java b/src/main/java/com/demcha/compose/document/node/SectionNode.java index 21efc201f..d5e0ddb1d 100644 --- a/src/main/java/com/demcha/compose/document/node/SectionNode.java +++ b/src/main/java/com/demcha/compose/document/node/SectionNode.java @@ -22,6 +22,8 @@ * not fit in the remaining page space (and fits on a fresh page) * @param anchor optional in-document navigation anchor name; renders a named * destination at the section's top-left, or {@code null} for none + * @param bleed edges on which the section bleeds to the trimmed page edge, + * or {@link DocumentBleed#none()} for normal in-margin placement * @author Artem Demchyshyn */ public record SectionNode( @@ -35,7 +37,8 @@ public record SectionNode( DocumentCornerRadius cornerRadius, DocumentBorders borders, boolean keepTogether, - String anchor + String anchor, + DocumentBleed bleed ) implements DocumentNode { /** * Normalizes optional section fields and validates child spacing. @@ -49,11 +52,43 @@ public record SectionNode( cornerRadius = cornerRadius == null ? DocumentCornerRadius.ZERO : cornerRadius; borders = borders == null ? DocumentBorders.NONE : borders; anchor = anchor == null || anchor.isBlank() ? null : anchor.trim(); + bleed = bleed == null ? DocumentBleed.none() : bleed; if (spacing < 0 || Double.isNaN(spacing) || Double.isInfinite(spacing)) { throw new IllegalArgumentException("spacing must be finite and non-negative: " + spacing); } } + /** + * Backward-compatible constructor without the bleed declaration (defaults to + * {@link DocumentBleed#none()}). + * + * @param name node name + * @param children child nodes + * @param spacing vertical spacing + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional uniform border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side borders + * @param keepTogether keep-together relocation flag + * @param anchor optional navigation anchor name + */ + public SectionNode(String name, + List children, + double spacing, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + boolean keepTogether, + String anchor) { + this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, keepTogether, + anchor, DocumentBleed.none()); + } + /** * Backward-compatible constructor without the navigation anchor (defaults to * no anchor). diff --git a/src/main/java/com/demcha/compose/document/style/DocumentBleed.java b/src/main/java/com/demcha/compose/document/style/DocumentBleed.java new file mode 100644 index 000000000..0bd3fdb00 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentBleed.java @@ -0,0 +1,102 @@ +package com.demcha.compose.document.style; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +/** + * Declares the page edges on which a node bleeds: instead of being placed inside + * the page content margin, a bled edge extends to the trimmed physical page edge. + * + *

This is the content-side twin of + * {@link com.demcha.compose.document.api.PageBackgroundFill}: where a background + * fill paints a coloured area to the page edge, a bled node draws live content + * (a band, a rule, an image) to the same edge. It replaces the hand-computed + * negative-margin idiom ({@code margin(new DocumentInsets(-pageMargin, ...))}) + * with an intent-revealing declaration the engine resolves against the active + * page margin.

+ * + *

Horizontal bleed ({@link DocumentEdge#LEFT}/{@link DocumentEdge#RIGHT}) + * widens the node to reach the side edges. Vertical bleed + * ({@link DocumentEdge#TOP}/{@link DocumentEdge#BOTTOM}) extends the node toward + * the top/bottom edge and is meaningful for a node already seated against that + * edge (e.g. a masthead band at the top of the first page). Instances are + * immutable and thread-safe.

+ * + * @param edges the set of edges to bleed; never {@code null} + * @author Artem Demchyshyn + * @see DocumentEdge + * @since 1.9.0 + */ +public record DocumentBleed(Set edges) { + + private static final DocumentBleed NONE = new DocumentBleed(Set.of()); + + /** + * Normalizes the edge set into an immutable copy. + * + * @param edges requested edges; {@code null} is treated as no edges + */ + public DocumentBleed { + edges = edges == null || edges.isEmpty() + ? Set.of() + : Collections.unmodifiableSet(EnumSet.copyOf(edges)); + } + + /** + * Returns a bleed that touches no edge — i.e. normal in-margin placement. + * + * @return the empty bleed + */ + public static DocumentBleed none() { + return NONE; + } + + /** + * Returns a bleed on all four edges (full-bleed). + * + * @return a bleed covering every edge + */ + public static DocumentBleed all() { + return new DocumentBleed(EnumSet.allOf(DocumentEdge.class)); + } + + /** + * Returns a bleed on the given edges. + * + * @param edges the edges to bleed; an empty argument list yields {@link #none()} + * @return a bleed on the requested edges + */ + public static DocumentBleed of(DocumentEdge... edges) { + if (edges == null || edges.length == 0) { + return NONE; + } + EnumSet set = EnumSet.noneOf(DocumentEdge.class); + for (DocumentEdge edge : edges) { + if (edge != null) { + set.add(edge); + } + } + return set.isEmpty() ? NONE : new DocumentBleed(set); + } + + /** + * Returns whether this bleed touches the given edge. + * + * @param edge edge to test + * @return {@code true} if the node bleeds on {@code edge} + */ + public boolean bleeds(DocumentEdge edge) { + return edges.contains(edge); + } + + /** + * Returns whether any edge bleeds. The compiler uses this as a fast path to + * keep non-bleeding nodes byte-identical. + * + * @return {@code true} if at least one edge bleeds + */ + public boolean any() { + return !edges.isEmpty(); + } +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentEdge.java b/src/main/java/com/demcha/compose/document/style/DocumentEdge.java new file mode 100644 index 000000000..07e4e6152 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentEdge.java @@ -0,0 +1,20 @@ +package com.demcha.compose.document.style; + +/** + * A physical page edge, used to declare on which sides a node bleeds past the + * page content margin to the trimmed page edge. + * + * @author Artem Demchyshyn + * @see DocumentBleed + * @since 1.9.0 + */ +public enum DocumentEdge { + /** The top page edge. */ + TOP, + /** The right page edge. */ + RIGHT, + /** The bottom page edge. */ + BOTTOM, + /** The left page edge. */ + LEFT +} diff --git a/src/test/java/com/demcha/compose/document/dsl/DocumentBleedTest.java b/src/test/java/com/demcha/compose/document/dsl/DocumentBleedTest.java new file mode 100644 index 000000000..3e48deaa9 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/DocumentBleedTest.java @@ -0,0 +1,103 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.style.DocumentBleed; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentEdge; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.awt.Color; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Placement contract for {@link DocumentBleed}: a bled section's fill reaches the + * trimmed page edge, while an identical non-bled section stays inside the content + * margin (the byte-identical baseline). + */ +class DocumentBleedTest { + + private static final double EPS = 1e-3; + private static final int PAGE_W = 400; + private static final int PAGE_H = 300; + private static final int MARGIN = 20; + private static final Color BAND = new Color(11, 22, 33); + + @Test + void bledSectionFillReachesPageEdges() { + PlacedFragment fill = bandFill(DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT, DocumentEdge.TOP)); + + assertThat(fill.x()).as("left edge").isEqualTo(0.0, within(EPS)); + assertThat(fill.width()).as("full page width").isEqualTo(PAGE_W, within(EPS)); + assertThat(fill.y() + fill.height()).as("top edge").isEqualTo(PAGE_H, within(EPS)); + } + + @Test + void nonBledSectionFillStaysInsideContentMargin() { + PlacedFragment fill = bandFill(DocumentBleed.none()); + + // The fill begins at the content-area left edge and never crosses any + // page margin — the byte-identical baseline a bled fill departs from. + assertThat(fill.x()).as("left content edge").isEqualTo(MARGIN, within(EPS)); + assertThat(fill.x() + fill.width()).as("right stays inside margin") + .isLessThanOrEqualTo(PAGE_W - MARGIN + EPS); + assertThat(fill.y() + fill.height()).as("top stays inside margin") + .isLessThanOrEqualTo(PAGE_H - MARGIN + EPS); + } + + /** Compiles a single coloured band with the given bleed and returns its fill fragment. */ + private PlacedFragment bandFill(DocumentBleed bleed) { + try (DocumentSession session = GraphCompose.document() + .pageSize(PAGE_W, PAGE_H) + .margin(DocumentInsets.of(MARGIN)) + .create()) { + + SectionNode band = new SectionBuilder() + .name("Band") + .fillColor(DocumentColor.of(BAND)) + .bleed(bleed) + .addParagraph("Masthead") + .build(); + session.add(band); + + LayoutGraph graph = session.layoutGraph(); + return graph.fragments().stream() + .filter(this::isBandFill) + .findFirst() + .orElseThrow(() -> new AssertionError("no band fill fragment emitted")); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isBandFill(PlacedFragment fragment) { + return fragment.payload() instanceof ShapeFragmentPayload payload + && payload.fillColor() != null + && payload.fillColor().getRGB() == BAND.getRGB(); + } + + @Test + void factoriesProduceExpectedEdgeSets() { + assertThat(DocumentBleed.none().any()).isFalse(); + assertThat(DocumentBleed.all().any()).isTrue(); + for (DocumentEdge edge : DocumentEdge.values()) { + assertThat(DocumentBleed.all().bleeds(edge)).as("all bleeds %s", edge).isTrue(); + } + + DocumentBleed sides = DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT); + assertThat(sides.bleeds(DocumentEdge.LEFT)).isTrue(); + assertThat(sides.bleeds(DocumentEdge.RIGHT)).isTrue(); + assertThat(sides.bleeds(DocumentEdge.TOP)).isFalse(); + assertThat(sides.bleeds(DocumentEdge.BOTTOM)).isFalse(); + + assertThat(DocumentBleed.of()).isEqualTo(DocumentBleed.none()); + assertThat(new DocumentBleed(null).any()).isFalse(); + } +}