Skip to content

Commit 1deddb8

Browse files
committed
fix(svg): clip block SVG icons to their viewBox
Block-rendered SVG icons (addSvgIcon / SvgIcon.node) had no viewBox clip, so an icon whose art extends past its viewBox — real-world exporter output such as Noto's working files, which park off-canvas geometry outside the unit box — bled past its layout box onto neighbouring content. The inline path already clips to the glyph box; the block path did not. SvgIcon.node now packages the icon as a LayerStackNode with clipToBounds set, and LayerStackDefinition emits a paired ShapeClipBegin/End (CLIP_BOUNDS) around the layers, reusing the ShapeContainer clip pipeline. clipToBounds is an opt-in LayerStackNode flag defaulting off, so existing stacks stay byte-identical; the begin and end markers share one predicate so the graphics-state save/restore pair always balances. BlockSvgRenderTest covers it: off-canvas geometry is clipped away (raster), in-box art still paints, the layer stack emits a balanced clip pair, and a plain stack emits none.
1 parent c04a34e commit 1deddb8

5 files changed

Lines changed: 321 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ PDF `GoTo` actions. External links are unchanged.
9595
could smear copies of itself across adjacent glyphs (`:package:` rendered as
9696
several duplicated boxes overlapping its neighbours). The inline SVG render now
9797
clips each icon to its glyph box, matching SVG `viewBox` semantics.
98+
- **Block SVG icons are clipped to their viewBox too.** The same off-canvas art
99+
bled past the box on the block path (`addSvgIcon(icon, w)` / `SvgIcon.node(w)`),
100+
which had no viewBox clip. A block icon's layer stack now clips its layers to
101+
the icon box: `LayerStackNode` gains an opt-in `clipToBounds` (`@since 1.9.0`,
102+
default off so existing stacks stay byte-identical) and `SvgIcon.node(...)`
103+
sets it. It reuses the `ShapeContainer` clip pipeline — one paired
104+
begin/end marker per icon — so it matches the inline fix above.
98105

99106
### Documentation
100107

@@ -133,7 +140,11 @@ PDF `GoTo` actions. External links are unchanged.
133140
dimensions, alignment default, external-link wrapping) and `InlineSvgRenderTest`
134141
(PDFBox end-to-end: text preserved with no glyph substitution, the icon's fill
135142
colour and an inline gradient both rasterize onto the page, a linked icon emits
136-
a clickable annotation, and `svgIcon` sizes by aspect ratio).
143+
a clickable annotation, and `svgIcon` sizes by aspect ratio). `InlineSvgRenderTest`
144+
also rasterizes off-canvas geometry to prove the inline glyph-box clip, and the
145+
new `BlockSvgRenderTest` does the same for the block path — off-canvas art does
146+
not bleed, in-box art still paints, the layer stack emits a balanced
147+
`CLIP_BOUNDS` begin/end pair, and a plain (non-icon) stack emits none.
137148
- `EmojiLibraryTest` (resolves shortcodes case-insensitively with/without colons,
138149
unknown → empty, `require` throws, an absent set reports unavailable and names
139150
the `graph-compose-emoji` artifact) and `EmojiRenderTest` (a known shortcode

src/main/java/com/demcha/compose/document/layout/definitions/LayerStackDefinition.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
import com.demcha.compose.document.layout.*;
44
import com.demcha.compose.document.layout.payloads.PreparedStackLayout;
5+
import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload;
6+
import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload;
57
import com.demcha.compose.document.node.DocumentNode;
68
import com.demcha.compose.document.node.LayerAlign;
79
import com.demcha.compose.document.node.LayerStackNode;
10+
import com.demcha.compose.document.style.ClipPolicy;
11+
import com.demcha.compose.document.style.ShapeOutline;
812

913
import java.util.List;
1014

15+
import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS;
1116
import static com.demcha.compose.document.layout.NodeDefinitionSupport.measureStack;
1217
import static com.demcha.compose.document.layout.NodeDefinitionSupport.toPadding;
1318

@@ -64,6 +69,67 @@ public List<DocumentNode> children(LayerStackNode node) {
6469
public List<LayoutFragment> emitFragments(PreparedNode<LayerStackNode> prepared,
6570
FragmentContext ctx,
6671
FragmentPlacement placement) {
67-
return List.of();
72+
if (!clipsToBounds(prepared)) {
73+
return List.of();
74+
}
75+
LayerStackNode node = prepared.node();
76+
double width = prepared.measureResult().width() - node.padding().horizontal();
77+
double height = prepared.measureResult().height() - node.padding().vertical();
78+
// Open a bounds clip before the layers (the "before the body" half of
79+
// the paired marker; the matching restore is emitted in
80+
// emitOverlayFragments after the layers). This reuses the
81+
// ShapeContainer clip pipeline so the block icon honours SVG viewBox
82+
// semantics — off-canvas layer geometry is cut to the box, the
83+
// block-path mirror of the inline glyph-box clip in
84+
// PdfParagraphFragmentRenderHandler.
85+
return List.of(new LayoutFragment(
86+
placement.path(),
87+
0,
88+
node.padding().left(),
89+
node.padding().bottom(),
90+
width,
91+
height,
92+
new ShapeClipBeginPayload(
93+
new ShapeOutline.Rectangle(width, height),
94+
ClipPolicy.CLIP_BOUNDS,
95+
placement.path())));
96+
}
97+
98+
@Override
99+
public List<LayoutFragment> emitOverlayFragments(PreparedNode<LayerStackNode> prepared,
100+
FragmentContext ctx,
101+
FragmentPlacement placement) {
102+
if (!clipsToBounds(prepared)) {
103+
return List.of();
104+
}
105+
// Close the bounds clip after the layers — restores the graphics
106+
// state saved by the begin marker on the same page (the stack is
107+
// atomic, so begin and end never straddle a page break). Gated by the
108+
// same condition as the begin so the save/restore pair always balances.
109+
return List.of(new LayoutFragment(
110+
placement.path(),
111+
0,
112+
0.0,
113+
0.0,
114+
0.0,
115+
0.0,
116+
new ShapeClipEndPayload(placement.path())));
117+
}
118+
119+
/**
120+
* Whether this stack emits a viewBox bounds-clip around its layers. True
121+
* only for an opted-in stack ({@code clipToBounds}) whose content box is
122+
* non-degenerate — the single source of truth shared by the begin marker
123+
* ({@link #emitFragments}) and the end marker ({@link #emitOverlayFragments})
124+
* so the graphics-state save/restore pair can never go unbalanced.
125+
*/
126+
private static boolean clipsToBounds(PreparedNode<LayerStackNode> prepared) {
127+
LayerStackNode node = prepared.node();
128+
if (!node.clipToBounds()) {
129+
return false;
130+
}
131+
double width = prepared.measureResult().width() - node.padding().horizontal();
132+
double height = prepared.measureResult().height() - node.padding().vertical();
133+
return width > EPS && height > EPS;
68134
}
69135
}

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,28 @@
1717
* <p>Pagination is atomic: the entire stack moves to the next page when its
1818
* measured height does not fit on the current page.</p>
1919
*
20-
* @param name node name used in snapshots and layout graph paths
21-
* @param layers child layers in back-to-front order
22-
* @param padding inner padding applied around all layers
23-
* @param margin outer margin around the stack
20+
* <p>When {@code clipToBounds} is set, the layers are clipped to the stack's
21+
* own box (the {@code overflow: hidden} of a stacking box) — anything a layer
22+
* paints outside the box is cut away. {@code SvgIcon.node(...)} uses this so a
23+
* block-rendered icon honours SVG {@code viewBox} semantics: real-world icon
24+
* art that parks geometry off-canvas (Noto's working files) cannot bleed past
25+
* the icon box, mirroring the inline glyph-box clip.</p>
26+
*
27+
* @param name node name used in snapshots and layout graph paths
28+
* @param layers child layers in back-to-front order
29+
* @param padding inner padding applied around all layers
30+
* @param margin outer margin around the stack
31+
* @param clipToBounds clip the layers to the stack box; {@code false} lets
32+
* layers overflow (the default, byte-identical to pre-1.9
33+
* stacks). ({@code @since 1.9.0})
2434
* @author Artem Demchyshyn
2535
*/
2636
public record LayerStackNode(
2737
String name,
2838
List<Layer> layers,
2939
DocumentInsets padding,
30-
DocumentInsets margin
40+
DocumentInsets margin,
41+
boolean clipToBounds
3142
) implements DocumentNode {
3243

3344
/**
@@ -48,6 +59,19 @@ public record LayerStackNode(
4859
margin = margin == null ? DocumentInsets.zero() : margin;
4960
}
5061

62+
/**
63+
* Back-compat constructor for a non-clipping stack (the historical
64+
* four-arg shape). Equivalent to {@code clipToBounds = false}.
65+
*
66+
* @param name node name used in snapshots and layout graph paths
67+
* @param layers child layers in back-to-front order
68+
* @param padding inner padding applied around all layers
69+
* @param margin outer margin around the stack
70+
*/
71+
public LayerStackNode(String name, List<Layer> layers, DocumentInsets padding, DocumentInsets margin) {
72+
this(name, layers, padding, margin, false);
73+
}
74+
5175
@Override
5276
public List<DocumentNode> children() {
5377
List<DocumentNode> nodes = new ArrayList<>(layers.size());

src/main/java/com/demcha/compose/document/svg/SvgIcon.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ public double aspectRatio() {
146146
* {@code ShapeContainer} / {@code LayerStack} nine-point grids — the
147147
* node-form sibling of the {@code addSvgIcon(icon, width)} flow sugar.
148148
*
149+
* <p>The stack clips its layers to the icon box ({@code clipToBounds}),
150+
* the way a browser clips an SVG to its {@code viewBox}: art that an
151+
* exporter parks outside the viewBox (Noto's working files keep
152+
* off-canvas copies) is cut away instead of bleeding past the box.</p>
153+
*
149154
* @param width target width in points; must be positive
150155
* @return layer stack rendering this icon at {@code width} points
151156
* @throws IllegalArgumentException if {@code width} is not positive
@@ -187,7 +192,7 @@ public LayerStackNode node(double width) {
187192
layer.lineCap(),
188193
layer.lineJoin())));
189194
}
190-
return new LayerStackNode("SvgIcon", stack, null, null);
195+
return new LayerStackNode("SvgIcon", stack, null, null, true);
191196
}
192197

193198
/**
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package com.demcha.compose.document.dsl;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.layout.PlacedFragment;
6+
import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload;
7+
import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload;
8+
import com.demcha.compose.document.node.LayerStackNode;
9+
import com.demcha.compose.document.node.SpacerNode;
10+
import com.demcha.compose.document.style.ClipPolicy;
11+
import com.demcha.compose.document.style.DocumentInsets;
12+
import com.demcha.compose.document.svg.SvgIcon;
13+
import com.demcha.testing.VisualTestOutputs;
14+
import org.apache.pdfbox.Loader;
15+
import org.apache.pdfbox.pdmodel.PDDocument;
16+
import org.apache.pdfbox.rendering.PDFRenderer;
17+
import org.junit.jupiter.api.Test;
18+
19+
import java.awt.image.BufferedImage;
20+
import java.nio.charset.StandardCharsets;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.util.List;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.assertj.core.api.Assertions.within;
27+
28+
/**
29+
* Render coverage for block SVG icons ({@code addSvgIcon} → {@link SvgIcon#node}
30+
* → {@code LayerStackNode}): the block path must clip its layers to the icon box
31+
* the way the inline path clips to the glyph box, so off-{@code viewBox} icon
32+
* art does not bleed onto the page. The fragment-level half of the contract
33+
* (the paired clip markers) is asserted directly; the pixel-level half is
34+
* proved by rasterizing the page.
35+
*
36+
* @see InlineSvgRenderTest#offCanvasSvgGeometryIsClippedToTheViewBox the inline
37+
* sibling this mirrors
38+
*/
39+
class BlockSvgRenderTest {
40+
41+
/** A crimson square inside the 24×24 viewBox — paints within the icon box. */
42+
private static SvgIcon inBoxCrimson() {
43+
return SvgIcon.parse("""
44+
<svg viewBox="0 0 24 24">
45+
<path d="M2 2 H22 V22 H2 Z" fill="rgb(196, 30, 58)"/>
46+
</svg>
47+
""");
48+
}
49+
50+
/**
51+
* A crimson square drawn entirely OUTSIDE the 10×10 viewBox (x 30..40 →
52+
* three-to-four icon-widths to the right). A browser clips it away at the
53+
* viewBox; unclipped it would smear across the page — the block-path twin
54+
* of the inline :package: duplicate-box bug (Noto's working file parks
55+
* off-canvas copies outside the viewBox).
56+
*/
57+
private static SvgIcon offCanvasCrimson() {
58+
return SvgIcon.parse("""
59+
<svg viewBox="0 0 10 10">
60+
<path d="M30 0 H40 V10 H30 Z" fill="rgb(196, 30, 58)"/>
61+
</svg>
62+
""");
63+
}
64+
65+
@Test
66+
void inBoxBlockSvgPaintsItsFillColor() throws Exception {
67+
try (PDDocument document = Loader.loadPDF(renderBlockIcon(inBoxCrimson(), 24))) {
68+
assertThat(document.getNumberOfPages()).isEqualTo(1);
69+
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
70+
// The crimson only reaches the page through the block icon, so a
71+
// crimson pixel proves the vector layer was drawn (positive control
72+
// for the clip test below: the clip must not eat in-box art).
73+
assertThat(containsColorNear(image, 196, 30, 58, 45))
74+
.as("in-box block SVG must paint its fill colour")
75+
.isTrue();
76+
}
77+
}
78+
79+
@Test
80+
void offCanvasBlockSvgGeometryIsClippedToTheViewBox() throws Exception {
81+
byte[] pdf = renderBlockIcon(offCanvasCrimson(), 24);
82+
try (PDDocument document = Loader.loadPDF(pdf)) {
83+
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
84+
// The off-canvas square would land well within the page if unclipped
85+
// (≈3× the icon width to the right of the box). Finding no crimson
86+
// proves the block layer stack clips to the icon box.
87+
assertThat(containsColorNear(image, 196, 30, 58, 45))
88+
.as("off-canvas block SVG geometry must be clipped to the viewBox, not bleed onto the page")
89+
.isFalse();
90+
}
91+
}
92+
93+
@Test
94+
void blockSvgIconEmitsBalancedViewBoxClipPair() throws Exception {
95+
try (DocumentSession session = GraphCompose.document()
96+
.pageSize(200, 200)
97+
.margin(DocumentInsets.of(16))
98+
.create()) {
99+
session.pageFlow(page -> page.addSvgIcon(inBoxCrimson(), 48));
100+
101+
List<PlacedFragment> fragments = session.layoutGraph().fragments();
102+
List<PlacedFragment> begins = fragments.stream()
103+
.filter(f -> f.payload() instanceof ShapeClipBeginPayload)
104+
.toList();
105+
List<PlacedFragment> ends = fragments.stream()
106+
.filter(f -> f.payload() instanceof ShapeClipEndPayload)
107+
.toList();
108+
109+
assertThat(begins).as("one viewBox clip opens around the icon").hasSize(1);
110+
assertThat(ends).as("one viewBox clip closes around the icon").hasSize(1);
111+
112+
PlacedFragment begin = begins.get(0);
113+
PlacedFragment end = ends.get(0);
114+
ShapeClipBeginPayload beginPayload = (ShapeClipBeginPayload) begin.payload();
115+
ShapeClipEndPayload endPayload = (ShapeClipEndPayload) end.payload();
116+
117+
assertThat(beginPayload.policy()).isEqualTo(ClipPolicy.CLIP_BOUNDS);
118+
assertThat(begin.path()).contains("SvgIcon");
119+
assertThat(beginPayload.ownerPath()).isEqualTo(endPayload.ownerPath());
120+
assertThat(begin.pageIndex())
121+
.as("the clip pair must restore on the same page it saves")
122+
.isEqualTo(end.pageIndex());
123+
assertThat(fragments.indexOf(begin))
124+
.as("clip-begin must precede the layers and the clip-end")
125+
.isLessThan(fragments.indexOf(end));
126+
127+
// The clip rectangle is exactly the icon box (square 24×24 viewBox
128+
// at width 48 → 48×48), so it clips to the viewBox, nothing wider.
129+
assertThat(begin.width()).isCloseTo(48.0, within(1e-6));
130+
assertThat(begin.height()).isCloseTo(48.0, within(1e-6));
131+
}
132+
}
133+
134+
@Test
135+
void plainLayerStackDoesNotClip() throws Exception {
136+
// A hand-built stack defaults to clipToBounds=false (the historical
137+
// four-arg shape), so it must emit no clip markers — the SVG opt-in
138+
// does not leak onto every layer stack.
139+
LayerStackNode plain = new LayerStackNode(
140+
"PlainStack",
141+
List.of(new LayerStackNode.Layer(
142+
new SpacerNode("Inner", 20, 20, DocumentInsets.zero(), DocumentInsets.zero()))),
143+
null,
144+
null);
145+
146+
try (DocumentSession session = GraphCompose.document()
147+
.pageSize(200, 200)
148+
.margin(DocumentInsets.of(16))
149+
.create()) {
150+
session.add(plain);
151+
152+
assertThat(plain.clipToBounds()).isFalse();
153+
assertThat(session.layoutGraph().fragments())
154+
.as("a non-clipping stack emits no clip markers")
155+
.noneMatch(f -> f.payload() instanceof ShapeClipBeginPayload
156+
|| f.payload() instanceof ShapeClipEndPayload);
157+
}
158+
}
159+
160+
@Test
161+
void writesVisualArtifact() throws Exception {
162+
// An off-canvas icon next to an in-box icon, for eyeballing: neither
163+
// the off-canvas art nor the off-canvas icon's empty box should bleed.
164+
try (DocumentSession session = GraphCompose.document()
165+
.pageSize(280, 160)
166+
.margin(DocumentInsets.of(20))
167+
.create()) {
168+
session.pageFlow(page -> {
169+
page.addParagraph(p -> p.text("Block SVG viewBox clip"));
170+
page.addSvgIcon(inBoxCrimson(), 48);
171+
page.addSvgIcon(offCanvasCrimson(), 48);
172+
});
173+
byte[] pdf = session.toPdfBytes();
174+
Path out = VisualTestOutputs.preparePdf("block-svg-viewbox-clip", "svg");
175+
Files.write(out, pdf);
176+
assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
177+
assertThat(Files.size(out)).isPositive();
178+
}
179+
}
180+
181+
private static byte[] renderBlockIcon(SvgIcon icon, double width) throws Exception {
182+
try (DocumentSession session = GraphCompose.document()
183+
.pageSize(320, 160)
184+
.margin(DocumentInsets.of(16))
185+
.create()) {
186+
session.pageFlow(page -> page.addSvgIcon(icon, width));
187+
return session.toPdfBytes();
188+
}
189+
}
190+
191+
private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) {
192+
for (int y = 0; y < image.getHeight(); y++) {
193+
for (int x = 0; x < image.getWidth(); x++) {
194+
int rgb = image.getRGB(x, y);
195+
int rr = (rgb >> 16) & 0xFF;
196+
int gg = (rgb >> 8) & 0xFF;
197+
int bb = rgb & 0xFF;
198+
if (Math.abs(rr - r) <= tolerance
199+
&& Math.abs(gg - g) <= tolerance
200+
&& Math.abs(bb - b) <= tolerance) {
201+
return true;
202+
}
203+
}
204+
}
205+
return false;
206+
}
207+
}

0 commit comments

Comments
 (0)