Skip to content

Commit 03db61a

Browse files
authored
feat(api): content bleed — sections fill to the trimmed page edge (#228)
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.
1 parent 23e8007 commit 03db61a

13 files changed

Lines changed: 506 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **Content bleed: `DocumentBleed` / `DocumentEdge`** (`@since 1.9.0`). Flow
16+
builders gain `bleed(DocumentBleed)` and `bleedToEdge(DocumentEdge...)`, so a
17+
section's background fill extends to the trimmed physical page edge on the
18+
declared sides — a full-bleed masthead band or an edge-to-edge colour panel —
19+
while the section's children stay inside the content margin (text never runs
20+
off the page). It is the content-side twin of `PageBackgroundFill` and the
21+
intent-revealing replacement for the hand-computed negative-margin idiom,
22+
resolved against the active page margin at layout time. Nodes that do not bleed
23+
render byte-identically to before.
24+
1525
- **In-PDF navigation: anchors + internal links** (`@since 1.9.0`). Every flow
1626
and leaf builder gains `anchor(String)`, declaring a named destination at the
1727
element's top-left — `section.anchor("intro")`, `paragraph.anchor("fn-1")`, and
1.24 KB
Binary file not shown.

examples/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ are with the canonical DSL, then jump to its detailed section below.
7575
| [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) |
7676
| [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) |
7777
| [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) |
78+
| [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) |
7879

7980
### 📋 Templates recommended
8081

@@ -415,6 +416,26 @@ flow.addAligned(HorizontalAlign.RIGHT, anyFixedNode);
415416
[📄 View PDF](../assets/readme/examples/block-align.pdf) ·
416417
[📜 Full source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java)
417418

419+
### Content bleed
420+
421+
A section's background fill normally stops at the page content margin.
422+
`bleed(DocumentBleed)` / `bleedToEdge(DocumentEdge...)` extends the fill to the
423+
trimmed physical page edge on the declared sides — a full-bleed masthead band or
424+
an edge-to-edge accent strip — while the section's children stay inside the
425+
content margin, so a heading never runs off the page. It is the content-side twin
426+
of `pageBackground(...)` and the intent-revealing replacement for the
427+
hand-computed negative-margin idiom.
428+
429+
```java
430+
page.addSection(band -> band
431+
.fillColor(ink)
432+
.bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT)
433+
.addParagraph("Title")); // title stays in the safe area
434+
```
435+
436+
[📄 View PDF](../assets/readme/examples/content-bleed.pdf) ·
437+
[📜 Full source](src/main/java/com/demcha/examples/features/layout/BleedExample.java)
438+
418439
### Advanced tables
419440

420441
`DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`.

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.demcha.examples.features.debug.DebugOverlayExample;
77
import com.demcha.examples.features.docx.WordExportExample;
88
import com.demcha.examples.features.chrome.PdfChromeExample;
9+
import com.demcha.examples.features.layout.BleedExample;
910
import com.demcha.examples.features.layout.BlockAlignExample;
1011
import com.demcha.examples.features.lists.NestedListExample;
1112
import com.demcha.examples.features.shapes.PhotoClipExample;
@@ -145,6 +146,7 @@ public static void main(String[] args) throws Exception {
145146
System.out.println("Generated: " + PhotoClipExample.generate());
146147
System.out.println("Generated: " + SvgIconGalleryExample.generate());
147148
System.out.println("Generated: " + BlockAlignExample.generate());
149+
System.out.println("Generated: " + BleedExample.generate());
148150
System.out.println("Generated: " + TransformsExample.generate());
149151
System.out.println("Generated: " + TableAdvancedExample.generate());
150152

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.demcha.examples.features.layout;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.style.DocumentBleed;
6+
import com.demcha.compose.document.style.DocumentColor;
7+
import com.demcha.compose.document.style.DocumentEdge;
8+
import com.demcha.compose.document.style.DocumentInsets;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.examples.support.ExampleOutputPaths;
11+
12+
import java.nio.file.Path;
13+
14+
/**
15+
* Runnable showcase for v1.9 content bleed: a section's background fill extends
16+
* to the trimmed physical page edge on the declared sides, while its children
17+
* stay inside the content margin — a full-bleed masthead band whose title never
18+
* runs off the page. The intent-revealing replacement for the hand-computed
19+
* negative-margin idiom.
20+
*
21+
* <pre>{@code
22+
* page.addSection(band -> band
23+
* .fillColor(ink)
24+
* .bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT)
25+
* .addParagraph("Title")); // title stays in the safe area
26+
*
27+
* page.addSection(rule -> rule
28+
* .fillColor(accent)
29+
* .bleed(DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT)));
30+
* }</pre>
31+
*
32+
* @author Artem Demchyshyn
33+
*/
34+
public final class BleedExample {
35+
36+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
37+
private static final DocumentColor ACCENT = DocumentColor.rgb(196, 30, 58);
38+
private static final DocumentColor MUTED = DocumentColor.rgb(90, 96, 105);
39+
40+
private BleedExample() {
41+
}
42+
43+
/**
44+
* Renders the bleed sheet: a full-bleed masthead, a body paragraph, and a
45+
* side-to-side accent band.
46+
*
47+
* @return path to the generated PDF
48+
* @throws Exception if rendering or file IO fails
49+
*/
50+
public static Path generate() throws Exception {
51+
Path pdfFile = ExampleOutputPaths.prepare("features/layout", "content-bleed.pdf");
52+
53+
DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED);
54+
55+
try (DocumentSession document = GraphCompose.document(pdfFile)
56+
.pageSize(420, 480)
57+
.margin(DocumentInsets.of(36))
58+
.create()) {
59+
document.pageFlow(page -> {
60+
// Full-bleed masthead: fill reaches the top + both side edges,
61+
// the heading text stays seated in the content margin.
62+
page.addSection(band -> band
63+
.fillColor(INK)
64+
.padding(DocumentInsets.of(16))
65+
.bleedToEdge(DocumentEdge.TOP, DocumentEdge.LEFT, DocumentEdge.RIGHT)
66+
.addParagraph(p -> p
67+
.text("Content bleed")
68+
.textStyle(DocumentTextStyle.DEFAULT.withSize(22)
69+
.withColor(DocumentColor.rgb(255, 255, 255)))));
70+
71+
page.addParagraph(p -> p
72+
.text("band.bleedToEdge(TOP, LEFT, RIGHT) — the fill runs to the page "
73+
+ "edge while the title stays inside the content margin, so text "
74+
+ "never clips. The intent-revealing replacement for a hand-tuned "
75+
+ "negative margin.")
76+
.textStyle(DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED))
77+
.padding(new DocumentInsets(12, 0, 6, 0)));
78+
79+
page.addParagraph(p -> p
80+
.text("rule.bleed(DocumentBleed.of(LEFT, RIGHT))")
81+
.textStyle(caption)
82+
.padding(DocumentInsets.top(14)));
83+
84+
// Edge-to-edge accent band: horizontal bleed only.
85+
page.addSection(rule -> rule
86+
.fillColor(ACCENT)
87+
.padding(new DocumentInsets(5, 0, 5, 0))
88+
.bleed(DocumentBleed.of(DocumentEdge.LEFT, DocumentEdge.RIGHT))
89+
.addParagraph(p -> p
90+
.text("edge to edge")
91+
.textStyle(DocumentTextStyle.DEFAULT.withSize(9)
92+
.withColor(DocumentColor.rgb(255, 255, 255)))));
93+
94+
page.addParagraph(p -> p
95+
.text("Without bleed, the same band would stop at the content margin "
96+
+ "on every side.")
97+
.textStyle(DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED))
98+
.padding(DocumentInsets.top(12)));
99+
});
100+
101+
document.buildPdf();
102+
}
103+
104+
return pdfFile;
105+
}
106+
107+
public static void main(String[] args) throws Exception {
108+
System.out.println("Generated: " + generate());
109+
}
110+
}

src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public abstract class AbstractFlowBuilder<T extends AbstractFlowBuilder<T, N>, N
3333
private DocumentStroke stroke;
3434
private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO;
3535
private DocumentBorders borders = DocumentBorders.NONE;
36+
private DocumentBleed bleed = DocumentBleed.none();
3637

3738
/**
3839
* Creates a base flow builder.
@@ -131,6 +132,41 @@ public T margin(float top, float right, float bottom, float left) {
131132
return margin(new DocumentInsets(top, right, bottom, left));
132133
}
133134

135+
/**
136+
* Declares the page edges on which this flow bleeds past the page content
137+
* margin to the trimmed physical page edge. The intent-revealing replacement
138+
* for the hand-computed negative-margin idiom — the engine resolves the bleed
139+
* against the active page margin at layout time.
140+
*
141+
* <p>Only the flow's background fill/border extends to the edge — its
142+
* children stay inside the content margin, so text never runs off the page.
143+
* Horizontal bleed widens the flow to the side edges; vertical bleed extends
144+
* it toward the top/bottom edge and is meaningful for a flow already seated
145+
* against that edge (e.g. a masthead band at the top of the page).</p>
146+
*
147+
* @param bleed edges to bleed, or {@code null}/{@link DocumentBleed#none()} to clear
148+
* @return this builder
149+
* @see #bleedToEdge(DocumentEdge...)
150+
* @since 1.9.0
151+
*/
152+
public T bleed(DocumentBleed bleed) {
153+
this.bleed = bleed == null ? DocumentBleed.none() : bleed;
154+
return self();
155+
}
156+
157+
/**
158+
* Shorthand for {@link #bleed(DocumentBleed)} with the given edges — e.g.
159+
* {@code bleedToEdge(DocumentEdge.LEFT, DocumentEdge.RIGHT)} for a band that
160+
* reaches both side edges.
161+
*
162+
* @param edges edges to bleed; no edges clears the bleed
163+
* @return this builder
164+
* @since 1.9.0
165+
*/
166+
public T bleedToEdge(DocumentEdge... edges) {
167+
return bleed(DocumentBleed.of(edges));
168+
}
169+
134170
/**
135171
* Sets the flow background fill.
136172
*
@@ -1082,6 +1118,10 @@ protected DocumentCornerRadius cornerRadius() {
10821118
protected DocumentBorders borders() {
10831119
return borders;
10841120
}
1121+
1122+
protected DocumentBleed bleed() {
1123+
return bleed;
1124+
}
10851125
}
10861126

10871127
/**

src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public SectionBuilder keepTogether(boolean value) {
5151
@Override
5252
protected SectionNode buildNode() {
5353
return new SectionNode(name(), children(), spacing(), padding(), margin(), fillColor(),
54-
stroke(), cornerRadius(), borders(), keepTogether, anchor());
54+
stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed());
5555
}
5656

5757
/**

src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.demcha.compose.document.node.DocumentNode;
66
import com.demcha.compose.document.node.LayerStackNode;
77
import com.demcha.compose.document.node.PageBreakNode;
8+
import com.demcha.compose.document.style.DocumentBleed;
9+
import com.demcha.compose.document.style.DocumentEdge;
810
import com.demcha.compose.engine.components.style.Margin;
911
import com.demcha.compose.engine.components.style.Padding;
1012
import org.slf4j.Logger;
@@ -391,22 +393,50 @@ private void compileComposite(PreparedNode<DocumentNode> prepared,
391393
advanceSpace(padding.bottom() + margin.bottom(), state);
392394
int endPage = state.pageIndex;
393395
double endPageBottomY = state.pageTop() - state.usedHeight + margin.bottom();
396+
397+
// Content bleed: the decoration box (fill/border) extends to the trimmed
398+
// page edge on the declared edges, while children stay in the content
399+
// region (so text never runs off the page). Byte-identical when the node
400+
// does not bleed — every value below collapses to the in-margin geometry.
401+
DocumentBleed bleed = node.bleed();
402+
double decorX = placementX;
403+
double decorWidth = naturalMeasure.width();
404+
double decorTopY = placementTopY;
405+
double decorBottomY = endPageBottomY;
406+
if (bleed.any()) {
407+
double pageWidth = state.canvas.width();
408+
double pageHeight = state.canvas.height();
409+
if (bleed.bleeds(DocumentEdge.LEFT)) {
410+
decorWidth += decorX;
411+
decorX = 0.0;
412+
}
413+
if (bleed.bleeds(DocumentEdge.RIGHT)) {
414+
decorWidth = Math.max(0.0, pageWidth - decorX);
415+
}
416+
if (bleed.bleeds(DocumentEdge.TOP)) {
417+
decorTopY = pageHeight;
418+
}
419+
if (bleed.bleeds(DocumentEdge.BOTTOM)) {
420+
decorBottomY = 0.0;
421+
}
422+
}
394423
List<PlacedFragment> decorationFragments = compositeDecorationFragments(
395424
prepared,
396425
definition,
397426
path,
398427
parentPath,
399428
childIndex,
400429
depth,
401-
placementX,
402-
placementTopY,
403-
endPageBottomY,
404-
naturalMeasure.width(),
430+
decorX,
431+
decorTopY,
432+
decorBottomY,
433+
decorWidth,
405434
startPage,
406435
endPage,
407436
margin,
408437
padding,
409438
state.canvas,
439+
bleed,
410440
fragmentContext);
411441
if (!decorationFragments.isEmpty()) {
412442
fragments.addAll(decorationInsertIndex, decorationFragments);
@@ -585,6 +615,7 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
585615
margin,
586616
padding,
587617
state.canvas,
618+
DocumentBleed.none(),
588619
fragmentContext);
589620
if (!decorationFragments.isEmpty()) {
590621
fragments.addAll(decorationInsertIndex, decorationFragments);
@@ -701,6 +732,7 @@ private void compileStackedLayer(PreparedNode<DocumentNode> prepared,
701732
margin,
702733
padding,
703734
state.canvas,
735+
DocumentBleed.none(),
704736
fragmentContext);
705737
if (!decorationFragments.isEmpty()) {
706738
fragments.addAll(decorationInsertIndex, decorationFragments);
@@ -1212,6 +1244,7 @@ private double compileNodeInFixedSlot(PreparedNode<DocumentNode> prepared,
12121244
margin,
12131245
padding,
12141246
canvas,
1247+
DocumentBleed.none(),
12151248
fragmentContext);
12161249
if (!stackDecorations.isEmpty()) {
12171250
fragments.addAll(decorationInsertIndex, stackDecorations);
@@ -1306,6 +1339,7 @@ private double compileNodeInFixedSlot(PreparedNode<DocumentNode> prepared,
13061339
margin,
13071340
padding,
13081341
canvas,
1342+
DocumentBleed.none(),
13091343
fragmentContext);
13101344
if (!decorationFragments.isEmpty()) {
13111345
fragments.addAll(decorationInsertIndex, decorationFragments);
@@ -1355,10 +1389,17 @@ private List<PlacedFragment> compositeDecorationFragments(PreparedNode<DocumentN
13551389
Margin margin,
13561390
Padding padding,
13571391
LayoutCanvas canvas,
1392+
DocumentBleed bleed,
13581393
FragmentContext fragmentContext) {
13591394
List<PlacedFragment> placed = new ArrayList<>();
1360-
double pageTopY = canvas.height() - canvas.margin().top();
1361-
double pageBottomY = canvas.margin().bottom();
1395+
// On bled edges the clamp bound is the physical page edge rather than the
1396+
// content-area edge, so the fill reaches past the top/bottom margin.
1397+
double pageTopY = bleed.bleeds(DocumentEdge.TOP)
1398+
? canvas.height()
1399+
: canvas.height() - canvas.margin().top();
1400+
double pageBottomY = bleed.bleeds(DocumentEdge.BOTTOM)
1401+
? 0.0
1402+
: canvas.margin().bottom();
13621403

13631404
for (int pageIndex = startPage; pageIndex <= endPage; pageIndex++) {
13641405
double segmentTopY = pageIndex == startPage ? startPageTopY : pageTopY;

src/main/java/com/demcha/compose/document/node/DocumentNode.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.demcha.compose.document.node;
22

3+
import com.demcha.compose.document.style.DocumentBleed;
34
import com.demcha.compose.document.style.DocumentInsets;
45

56
import java.util.List;
@@ -70,5 +71,18 @@ default String nodeKind() {
7071
default boolean keepTogether() {
7172
return false;
7273
}
74+
75+
/**
76+
* Edges on which this node bleeds past the page content margin to the
77+
* trimmed physical page edge. Default {@link DocumentBleed#none()} (normal
78+
* in-margin placement), so nodes that do not opt in are placed exactly as
79+
* before.
80+
*
81+
* @return the edges to bleed; never {@code null}
82+
* @since 1.9.0
83+
*/
84+
default DocumentBleed bleed() {
85+
return DocumentBleed.none();
86+
}
7387
}
7488

0 commit comments

Comments
 (0)