From 7e63d85b373def662c5f2909cf3ca1f486413305 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 13 Jun 2026 03:42:18 +0100
Subject: [PATCH 1/4] =?UTF-8?q?feat(svg):=20ShapeOutline.Path=20clipper=20?=
=?UTF-8?q?=E2=80=94=20clip=20container=20children=20to=20a=20native-curve?=
=?UTF-8?q?=20silhouette?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ShapeOutline.Path joins the sealed outline family (curve-capable sibling
of Polygon); validates dims + MoveTo-first segments, copy-protected
- ShapeContainerBuilder.path(w, h, segments) and path(w, h, svgPath) (beta)
— turn any icon/logo into a content mask under ClipPolicy.CLIP_PATH
- outline fill/stroke rides the existing PathFragmentPayload pipeline; the
clip handler emits the same addPathSegments geometry — fill, clip and
addPath all agree on native curves
- VectorPathExample gains a heart-silhouette clip demo; +4 tests
---
CHANGELOG.md | 10 +++
assets/readme/examples/vector-path.pdf | Bin 2584 -> 2848 bytes
.../features/shapes/VectorPathExample.java | 13 +++-
.../PdfShapeClipBeginRenderHandler.java | 5 +-
.../document/dsl/ShapeContainerBuilder.java | 38 +++++++++++
.../definitions/ShapeContainerDefinition.java | 12 ++++
.../compose/document/style/ShapeOutline.java | 55 +++++++++++++++-
.../dsl/ShapeContainerBuilderTest.java | 62 ++++++++++++++++++
8 files changed, 192 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07796361..57d54b41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,16 @@ Entries land here as they merge.
every flow builder authors design shapes directly, and
`dashed(on, off, ...)` makes the stroke dashed with the same
`DocumentDashPattern` contract as lines — the pattern follows the curve.
+- **Path-outline clipper** (`@since 1.8.0`). `ShapeOutline.Path` joins the
+ sealed outline family as the curve-capable sibling of `Polygon`, so a
+ shape container can clip its children to — and fill / stroke along — an
+ arbitrary native-curve silhouette. `ShapeContainerBuilder.path(w, h,
+ segments)` takes raw `DocumentPathSegment`s; `path(w, h, svgPath)` (beta)
+ clips to an imported SVG path, turning any icon or logo into a content
+ mask under `ClipPolicy.CLIP_PATH`. The outline rides the existing
+ vector-path fragment pipeline (one source of truth for native curves) and
+ the clip handler emits the same `addPathSegments` geometry, so fill, clip,
+ and `addPath(...)` all agree.
- **SVG path import** (`@since 1.8.0`, **beta** — annotated `@Beta` while
the surface hardens against real-world exporter output). `SvgPath.parse(d)` /
`parse(d, viewBox...)` in the new `document.svg` package lowers the full
diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf
index 51223b720069294a0a9ab1a3798980682b4562dd..dd73303c248c4979eac2196c55a84a556787a659 100644
GIT binary patch
delta 2620
zcmb7FX*3iH1C0=4X@tTj#+o#m8OAIb8p906*4SlIwh@uF$v$O+I>bXsu
z=CW)0?3Xg$ZdtTMcw~$E>^0Bmk!k4bwdo4$)e{xk#Asyodh~a>sZsGY8}@>E0DT}L
z>wIHyLOxgm@1+$Bps~^m6^9?ld0;||MV+obzI%eT>rv7h(82UNS$pC~6HB|jF_e|F
z(c0Y@F&E`K91<`ls}Poe5ROYyoP!MTL~QYf}^t4XsFF`8GUA2LaSAy$qFR1@E^
zh1fPj1@@5>VeZbG#Iz6kt=u1`Xtz7wL#9guX#b^g>|?tc+C};JO0gT=r4y~v-|hQ
z-Fu<)H#fsN#+4RGg}3SBFB(W*Udo1Gna#Y>Pbi~?OMQ2rH+M4y#n5Zo(YBPaY1xnO
zb?S?Vf2cOH8fdD4>n$LxWhG@I++y%^{*m3?$u%Soar72L!oFGf3Ko|v@e8^5XSR8<
zipgASic}^r`$i*Qr9heJvem<9@h97_0
zG%n!iX5Z9yNg{X!l<{^K#mz5x*;n_>QtoPx!%}Yj`k{KO{|+Z~I%1IRh;bv$eXGmx
zfO9g=1R!#7%7L(d@)g1UGpDJ$*TK0y4u`upxu~OMZRZ{7U-RYdDjy#tbGCIQnsQ6>
zWl-e=TCDi`KVSoclgxF>$mRy5SpL!95jH7mc3Gs=urCJqtx7-YfM8j51*&Fk(m)>N14l9~n7c|NLT
zds_rGJh-CX>ayQt3t#;JUxLf!G%>!wJRwP2^HLG=@->+IR19Mz-3No~z=C2QD`8Fd
zSiM&T`yK&@%(Od&DY1;evX}|6v|T4di3*2b&sN;tehnvX9XtO8Ht3{pQN~jmxJ46}
zqc?q|^y57|DOgA0uNBqxfZ-cTETiL^V7Rr`aaQ*gYX(1<1RuZZQOL4-{YCvF^k+!EXp#(;9Qb
znvpC|w>^8M#r8q-aKhwwJ-z^s@M^Sd`XuZo-K0H>ZI{0sHIcd`6UI<2&)kr1k?rJ0
zg>?*xSlGi$F(xx}RriqI>Q%s(?c#dG3a)~M2ye#~X^H;S)8-h6c8nk`K3k{K2e&><
zH)jfFrH^M8DH~@5)DcIze*bl^?M!y#AI(v8wj48
zgHk$jk*{dIV5ECn@Jga0!jfA1e~>78(4x9@FtynG9f
zD@FS5;y(`2R=-z>KWk6IxzlPcr(nV5d6BX~ZCFn>lj
zQtDdg%i8mO7v)BF!N=766k~tZiEOZ6o287-9gOgQvVtl{k-TR+1m5)>ZJp!4g&Xc(
zBum+Fhpv_+4x6_8QU2~{ZVZjbQ4+|Wch1_OYYd*1d6xe0>9kpPHo
zSb!%8V&z6XFMeSb|{MzmN1ZNKxQH-RUvzki#Uv=KdTTfNKgoNwCIQ}sh_Y?DL3Q?xXwv2*Iye2cpSCm}zn
z8XQ*@JU8e|W93<|yi>4ES$X0~3o5cyLwwv&d}(s3>(R>L_oZrDekS6Bs}I-i?Az+xU+N7`Q_c-!b9F3(l=5}#pIL7KwT=R=zp;_J(yj3vs-?`#
z<$j>6ahIkxQ
z3ynh}&`2mk2adxVB6SQA+ITntjnYBr;7~d+Lp)3yNq}n;47BiCT827Ms1{Tkfkzt{
z=!5>%0{s7YFam^5i=lo8y6xc^3UY>mU`R%m5KOa2^cV=p*Ps`a$>{
zg_B+Z6^9$*zy}$mE2ASYu!0`-$8m$-ttF&_LtKMGLW4Xh04*IT0-&mjw;}-k3lk>7
A_y7O^
delta 2405
zcmb7?X*ARe1IC36V~bq-GVx9**BG-I=GrNfb&@6fn907**q8r=RE)e6y4KJjj3te-
zmE|HOVvwz(xaD3QOyz`x3`Y1%IX?+bEV-`=n?5rPPHcE3zZlrS7hrR;bUDh(H&gaw
zRIc}v{T+_nxP6P8WWGr3z+~U#&A_g#ZB2CXk{6+te4)2Nsu|8%9pMQr^EKZ07$|WR
zCfgIU*l4~-zRAt^sM-KYhvWPV`qwQ?M^Q}Qd3JouQB&8dOHMjc(Eq}-kwaw}shffu
zfWOz}QRNJKaG%_Wu$OaL9re}w8`IT3KP^khC$Fbk;I@JHtuR&$S}8)|p+$R$7HZt;
zY5`PLY|}kNm>Rr+4dM7Vx+pEFN2ZL(1A0DF4`PCErpcYx^asW>r7R>Z)zb=&<)D0X
z9*GegK`;Ddx^~5oE%tN!}0D2GRIm{Yo9u<`u
zktcHCUU(G&D4q~IXA&SHo#?q~n*It%-2T16Bo(<|JBMeik(Vm!_gXvDDRS>!A}XE;NsGqj0-4&zq}ULY?%5a+8|1zf;%VUCNE@RaTqMxFOHvKomM8A
zwk~$rOe~m^<;>1=PSGf1N
zm=}j-sI(VeJJYJzL0{MBAtY70K|H&34wf0|vM$+WrDpd^Kmq+*lTm4)Dy}qXVFj_x
zl~8>O5PO{m?Qa>Kf)BKFnr>hSA`|J{a8pQ{uv6XXvl<@F?>|)r1}J?p8~S1jd=niE
zR1MWyzA6>QrX6f#N?_@rNzPIa1wyG0O#!Ehu*5At`x;lMCka(DnW5Q2mwz%b{8NHPTG
zKn%V}4FkjMA#gYtW*rntB}YSG)}ek>vL)F+ZxJL4pfO-$WAJ~e;^6qwLBh*mW%!AM
zf^TVzPUxrr{nVOfh9Fz%x!+tr$z_)R9DAhS04Tq6WhMj^{Q?U5rPF!UQ5G#H@oh5Cl*TfIU%Z^t(gzD-}wfTS~x5kaXNv%?5b7vK8o;(92
zR_n``1%Lba@!aVtxgJSwJuv7Zrx@gBj|jrQ)ZGw*{fb>7gqUo99k&th;&V+Y{XIG3
zWL+oyrhW<2
z9}a+;>RcKrSv6WN93OrK>dRP7%+?w
z5kZB(e{2LW%*GPpfkRtbV6j-VrJ25!g(cG5424D_kpRY04`GJU(>E})FhC*=;23jr
zEXERT{Udr>^o^XnsY#M?MpDIWS*~in`U0YP
SyI@tYJ`xFrLaiLE!T$jS22haz
diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java
index 086d659b..0e0c3cbb 100644
--- a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java
@@ -79,7 +79,7 @@ public static Path generate() throws Exception {
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf");
try (DocumentSession document = GraphCompose.document(pdfFile)
- .pageSize(420, 1010)
+ .pageSize(420, 1130)
.margin(DocumentInsets.of(28))
.create()) {
document.pageFlow(page -> page
@@ -152,6 +152,17 @@ public static Path generate() throws Exception {
.closePath()
.fill(BRAND_AXIS)
.margin(DocumentInsets.bottom(16)))
+ .addParagraph("SVG-path clip — content clipped to a heart silhouette (CLIP_PATH)")
+ .addContainer(card -> card
+ .name("HeartClip")
+ .path(96, 96, SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
+ .clipPolicy(com.demcha.compose.document.style.ClipPolicy.CLIP_PATH)
+ // A full-box gradient layer renders heart-shaped:
+ // the container clips its children to the outline.
+ .layer(new com.demcha.compose.document.dsl.ShapeBuilder()
+ .size(96, 96)
+ .fill(BRAND_AXIS)
+ .build()))
.addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath")
.addPath(path -> path
.name("Ribbon")
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
index fd64f555..2a1f7e4d 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
@@ -28,7 +28,8 @@
* {@link ClipPolicy#CLIP_BOUNDS} the clip path is the axis-aligned outline
* rectangle; for {@link ClipPolicy#CLIP_PATH} it is the geometric outline
* (ellipse for circle/ellipse, uniform or per-corner rounded rectangle for
- * rounded-rect, polygon for diamonds / arrows / stars).
+ * rounded-rect, polygon for diamonds / arrows / stars, and a native-curve
+ * path for free-form {@code ShapeOutline.Path} silhouettes).
*
* @author Artem Demchyshyn
*/
@@ -120,6 +121,8 @@ public void render(PlacedFragment fragment,
stream.addRect(x, y, width, height);
} else if (outline instanceof ShapeOutline.Polygon p) {
PdfShapeGeometry.addPolygonPath(stream, x, y, width, height, p.points());
+ } else if (outline instanceof ShapeOutline.Path path) {
+ PdfShapeGeometry.addPathSegments(stream, x, y, width, height, path.segments());
} else {
throw new IllegalStateException("Unknown outline: " + outline);
}
diff --git a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
index b1c28c50..3d4b25b0 100644
--- a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
@@ -193,6 +193,44 @@ public ShapeContainerBuilder chevron(double width, double height, ShapeOutline.D
return this;
}
+ /**
+ * Sets a free-form path outline — children clip to (and the outline fills /
+ * strokes along) the native-curve silhouette. Segments use the same
+ * normalized unit box as {@code addPath(...)} ({@code (0,0)} bottom-left,
+ * {@code y} up).
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param segments normalized path segments, starting with a move-to
+ * @return this builder
+ * @since 1.8.0
+ */
+ public ShapeContainerBuilder path(double width, double height,
+ java.util.List segments) {
+ this.outline = ShapeOutline.path(width, height, segments);
+ return this;
+ }
+
+ /**
+ * Sets a free-form path outline from a parsed SVG path — clip a container's
+ * children to an imported icon or logo silhouette. The SVG path is already
+ * normalized to the unit box with the y-axis flipped, so only the box size
+ * remains to choose (use {@code svgPath.aspectRatio()} to keep proportions).
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param svgPath parsed SVG path geometry
+ * @return this builder
+ * @since 1.8.0
+ */
+ @com.demcha.compose.document.api.Beta
+ public ShapeContainerBuilder path(double width, double height,
+ com.demcha.compose.document.svg.SvgPath svgPath) {
+ Objects.requireNonNull(svgPath, "svgPath");
+ this.outline = ShapeOutline.path(width, height, svgPath.segments());
+ return this;
+ }
+
/**
* Replaces the outline with a pre-built {@link ShapeOutline} value.
*
diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
index fd831594..c2fed070 100644
--- a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
+++ b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
@@ -141,6 +141,18 @@ public List emitFragments(PreparedNode prepa
width,
height,
new PolygonFragmentPayload(p.points(), awtFill, stroke, null, null));
+ } else if (outline instanceof ShapeOutline.Path path) {
+ // The outline fill/stroke rides the same vector-path fragment
+ // pipeline as PathNode — native curves, one source of truth.
+ outlineFragment = new LayoutFragment(
+ placement.path(),
+ 0,
+ padLeft,
+ padBottom,
+ width,
+ height,
+ new PathFragmentPayload(path.segments(), awtFill, null, stroke, null,
+ null, null, null, null, null));
} else {
throw new IllegalStateException("Unsupported shape outline: " + outline);
}
diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
index 94a297cd..eb144909 100644
--- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
+++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
@@ -20,7 +20,8 @@ public sealed interface ShapeOutline permits
ShapeOutline.RoundedRectangle,
ShapeOutline.RoundedRectanglePerCorner,
ShapeOutline.Ellipse,
- ShapeOutline.Polygon {
+ ShapeOutline.Polygon,
+ ShapeOutline.Path {
/**
* Returns the outline outer width.
@@ -140,6 +141,44 @@ record Polygon(double width, double height, List points) implements
}
}
+ /**
+ * Free-form outline described by normalized {@link DocumentPathSegment}s —
+ * the curve-capable sibling of {@link Polygon}. Segments live in a unit
+ * box ({@code (0,0)} bottom-left, {@code y} up, the
+ * {@link com.demcha.compose.document.node.PathNode} convention) and are
+ * scaled to {@code width × height} at render time, so one segment list
+ * clips and fills at any size. Use it to clip a container's children to an
+ * arbitrary vector — a heart, a logo silhouette, an imported SVG path —
+ * via {@link com.demcha.compose.document.style.ClipPolicy#CLIP_PATH}.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param segments normalized path segments, starting with a
+ * {@link DocumentPathSegment.MoveTo}
+ * @since 1.8.0
+ */
+ record Path(double width, double height, List segments)
+ implements ShapeOutline {
+ /**
+ * Validates dimensions and the segment list; copy-protects the segments.
+ */
+ public Path {
+ requirePositive("width", width);
+ requirePositive("height", height);
+ Objects.requireNonNull(segments, "segments");
+ segments = List.copyOf(segments);
+ if (segments.size() < 2) {
+ throw new IllegalArgumentException(
+ "path outline needs at least a MoveTo and one drawing segment: " + segments.size());
+ }
+ if (!(segments.get(0) instanceof DocumentPathSegment.MoveTo)) {
+ throw new IllegalArgumentException(
+ "path outline must start with a MoveTo segment, found: "
+ + segments.get(0).getClass().getSimpleName());
+ }
+ }
+ }
+
/**
* Cardinal direction for directional figures (arrows, chevrons).
*
@@ -225,6 +264,20 @@ static Polygon polygon(double width, double height, List points) {
return new Polygon(width, height, points);
}
+ /**
+ * Creates a {@link Path} outline from normalized path segments — a
+ * free-form clip / fill silhouette with native curves.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param segments normalized path segments, starting with a move-to
+ * @return path outline
+ * @since 1.8.0
+ */
+ static Path path(double width, double height, List segments) {
+ return new Path(width, height, segments);
+ }
+
/**
* Creates a four-point diamond (rhombus) inscribed in the box.
*
diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
index 1ca84442..24c592ff 100644
--- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
@@ -164,6 +164,68 @@ void perCornerRoundedRectOutlineReachesTheClipPayload() {
}
}
+ @Test
+ void pathOutlineReachesTheClipPayloadAndRenders() throws Exception {
+ // A free-form path outline (a diamond via segments) must travel to the
+ // clip-begin payload as a ShapeOutline.Path, fill its outline, and the
+ // document must render without error.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+
+ session.add(new ShapeContainerBuilder()
+ .name("Gem")
+ .path(120.0, 90.0, java.util.List.of(
+ com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.5, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 0.5),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.5, 0.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 0.5),
+ com.demcha.compose.document.style.DocumentPathSegment.close()))
+ .fillColor(BRAND)
+ .center(spacer("Inside", 50.0, 16.0))
+ .build());
+
+ List fragments = session.layoutGraph().fragments();
+ int begin = indexOfPayload(fragments, ShapeClipBeginPayload.class);
+ assertThat(begin).isGreaterThanOrEqualTo(0);
+ ShapeOutline outline = ((ShapeClipBeginPayload) fragments.get(begin).payload()).outline();
+ assertThat(outline).isInstanceOf(ShapeOutline.Path.class);
+ assertThat(((ShapeOutline.Path) outline).segments()).hasSize(5);
+
+ byte[] pdf = session.toPdfBytes();
+ assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
+ }
+ }
+
+ @Test
+ void svgPathBridgeProducesAPathOutline() {
+ // path(w, h, SvgPath) clips a container to an imported icon silhouette.
+ var icon = com.demcha.compose.document.svg.SvgPath.parse("M0 0 H10 V10 H0 Z", 0, 0, 10, 10);
+ var node = new ShapeContainerBuilder()
+ .name("IconClip")
+ .path(64.0, 64.0, icon)
+ .center(spacer("x", 20, 20))
+ .build();
+
+ assertThat(node.outline()).isInstanceOf(ShapeOutline.Path.class);
+ assertThat(((ShapeOutline.Path) node.outline()).segments())
+ .isEqualTo(icon.segments());
+ }
+
+ @Test
+ void pathOutlineValidatesSegments() {
+ assertThatThrownBy(() -> new ShapeContainerBuilder()
+ .name("Bad")
+ .path(80.0, 80.0, java.util.List.of(
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 1.0)))
+ .center(spacer("x", 10, 10))
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must start with a MoveTo");
+ }
+
@Test
void positionOffsetMovesLayerInScreenSpaceUnits() {
try (DocumentSession session = GraphCompose.document()
From c129244e3d44eea749d7e6c0eff4fe70bc4613e6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 13 Jun 2026 10:02:15 +0100
Subject: [PATCH 2/4] fix(svg): render ShapeOutline.Path as an inline shape too
(review fix)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adversarial PR review caught a missed third instanceof-over-ShapeOutline:
PdfParagraphFragmentRenderHandler.renderShape threw IllegalStateException
for Path, reachable via the public paragraph.shape(ShapeOutline, color) /
RichText.shape(...) surfaces (a layout-then-render-time crash).
- add the Path branch to the inline-shape renderer (same addPathSegments)
- ShapeOutlineRenderCoverageTest: reflect over getPermittedSubclasses() and
drive every permit through BOTH the container clip and inline-shape
surfaces — fails red without the fix, and blocks the next permit from
repeating the miss
- Javadoc caveat on ShapeOutline.Path: non-zero winding, multi-subpath holes,
close() for a connected stroke
- multi-subpath donut render test
---
.../PdfParagraphFragmentRenderHandler.java | 2 +
.../compose/document/style/ShapeOutline.java | 7 ++
.../ShapeOutlineRenderCoverageTest.java | 103 ++++++++++++++++++
.../dsl/ShapeContainerBuilderTest.java | 33 ++++++
4 files changed, 145 insertions(+)
create mode 100644 src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
index 640a7251..e67ae8ed 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
@@ -163,6 +163,8 @@ private static void renderShape(PDPageContentStream stream,
(float) Math.min(c.bottomLeft(), maxRadius));
} else if (outline instanceof ShapeOutline.Polygon p) {
PdfShapeGeometry.addPolygonPath(s, lx, ly, lw, lh, p.points());
+ } else if (outline instanceof ShapeOutline.Path path) {
+ PdfShapeGeometry.addPathSegments(s, lx, ly, lw, lh, path.segments());
} else {
throw new IllegalStateException("Unknown inline outline: " + outline);
}
diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
index eb144909..2f3e2424 100644
--- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
+++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
@@ -151,6 +151,13 @@ record Polygon(double width, double height, List points) implements
* arbitrary vector — a heart, a logo silhouette, an imported SVG path —
* via {@link com.demcha.compose.document.style.ClipPolicy#CLIP_PATH}.
*
+ * Fill and clip use the non-zero winding rule and implicitly close each
+ * subpath, so a silhouette with several {@code MoveTo} rings cuts a hole
+ * (a donut, the bowl of an "O") only where an inner ring winds opposite the
+ * outer one. An outline stroke, in contrast, follows the segments
+ * literally and does not auto-close — end the path with a close segment for
+ * a connected stroked border.
+ *
* @param width outer width in points
* @param height outer height in points
* @param segments normalized path segments, starting with a
diff --git a/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java b/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java
new file mode 100644
index 00000000..270ac871
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java
@@ -0,0 +1,103 @@
+package com.demcha.compose.document.architecture;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.document.style.ShapePoint;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Exhaustiveness guard: every {@link ShapeOutline} permit must render through
+ * both outline-consuming surfaces — the shape-container clip path and
+ * the inline-shape run — without throwing. Each surface dispatches on the
+ * outline kind with an {@code instanceof} chain that ends in an
+ * {@code IllegalStateException}; this test reflects over
+ * {@code getPermittedSubclasses()} so the next permit added to the sealed type
+ * cannot silently miss a render branch (the lesson from the
+ * {@code ShapeOutline.Path} clipper, where the inline-shape switch was missed).
+ */
+class ShapeOutlineRenderCoverageTest {
+
+ /** One representative instance per permitted ShapeOutline kind. */
+ private static final Map, ShapeOutline> REPRESENTATIVES = Map.of(
+ ShapeOutline.Rectangle.class, new ShapeOutline.Rectangle(40, 24),
+ ShapeOutline.RoundedRectangle.class, new ShapeOutline.RoundedRectangle(40, 24, 6),
+ ShapeOutline.RoundedRectanglePerCorner.class,
+ new ShapeOutline.RoundedRectanglePerCorner(40, 24, DocumentCornerRadius.right(6)),
+ ShapeOutline.Ellipse.class, new ShapeOutline.Ellipse(40, 24),
+ ShapeOutline.Polygon.class, ShapeOutline.diamond(40, 24),
+ ShapeOutline.Path.class, ShapeOutline.path(40, 24, List.of(
+ DocumentPathSegment.moveTo(0.5, 1.0),
+ DocumentPathSegment.lineTo(1.0, 0.0),
+ DocumentPathSegment.lineTo(0.0, 0.0),
+ DocumentPathSegment.close())));
+
+ @Test
+ void everyPermittedOutlineHasARepresentative() {
+ Set> permits = Set.of(ShapeOutline.class.getPermittedSubclasses());
+ Set> covered = REPRESENTATIVES.keySet();
+ // If this fails, a new ShapeOutline permit was added — give it a
+ // representative here AND a render branch in BOTH surfaces below.
+ assertThat(covered).containsExactlyInAnyOrderElementsOf(permits);
+ }
+
+ @Test
+ void everyOutlineClipsInAContainerWithoutThrowing() throws Exception {
+ for (ShapeOutline outline : REPRESENTATIVES.values()) {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(200, 160)
+ .margin(DocumentInsets.of(16))
+ .create()) {
+ session.add(new ShapeContainerBuilder()
+ .name("Clip" + outline.getClass().getSimpleName())
+ .outline(outline)
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .fillColor(DocumentColor.rgb(20, 80, 95))
+ .center(spacer())
+ .build());
+ byte[] pdf = session.toPdfBytes();
+ assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII))
+ .as("clip render of " + outline.getClass().getSimpleName())
+ .isEqualTo("%PDF-");
+ }
+ }
+ }
+
+ @Test
+ void everyOutlineRendersAsAnInlineShapeWithoutThrowing() throws Exception {
+ for (ShapeOutline outline : REPRESENTATIVES.values()) {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(200, 160)
+ .margin(DocumentInsets.of(16))
+ .create()) {
+ session.dsl().pageFlow().name("Flow")
+ .addParagraph(p -> p
+ .text("inline ")
+ .shape(outline, DocumentColor.rgb(196, 30, 58)))
+ .build();
+ byte[] pdf = session.toPdfBytes();
+ assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII))
+ .as("inline render of " + outline.getClass().getSimpleName())
+ .isEqualTo("%PDF-");
+ }
+ }
+ }
+
+ private static com.demcha.compose.document.node.DocumentNode spacer() {
+ return new com.demcha.compose.document.dsl.SpacerBuilder().size(12, 12).build();
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
index 24c592ff..e49ef382 100644
--- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
@@ -213,6 +213,39 @@ void svgPathBridgeProducesAPathOutline() {
.isEqualTo(icon.segments());
}
+ @Test
+ void multiSubpathPathOutlineRenders() throws Exception {
+ // A donut silhouette: an outer ring and an inner counter-wound ring
+ // (two MoveTo subpaths). Non-zero winding cuts the hole; the document
+ // must render without error — guards multi-MoveTo clip geometry.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(200, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+
+ session.add(new ShapeContainerBuilder()
+ .name("Donut")
+ .path(100.0, 100.0, java.util.List.of(
+ com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.0, 0.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 0.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.close(),
+ // inner ring, opposite winding → hole
+ com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.3, 0.3),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.3, 0.7),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.7, 0.7),
+ com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.7, 0.3),
+ com.demcha.compose.document.style.DocumentPathSegment.close()))
+ .fillColor(BRAND)
+ .center(spacer("x", 20, 20))
+ .build());
+
+ byte[] pdf = session.toPdfBytes();
+ assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
+ }
+ }
+
@Test
void pathOutlineValidatesSegments() {
assertThatThrownBy(() -> new ShapeContainerBuilder()
From 3d1be5a250cb5f85761f2cf3980b988f46a3ce63 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 13 Jun 2026 14:28:01 +0100
Subject: [PATCH 3/4] docs(examples): SVG-path clip catalog tile + photo-clip
silhouette example
Adds an SVG-path clip tile to the flagship feature catalog and a runnable
PhotoClipExample (photo clipped to circle / SVG heart / star), wired into
GenerateAllExamples and ShowcaseMetadata per the example-registration convention.
---
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/shapes/PhotoClipExample.java | 152 ++++++++++++++++++
.../flagships/FeatureCatalogExample.java | 15 ++
.../examples/support/ShowcaseMetadata.java | 3 +-
4 files changed, 171 insertions(+), 1 deletion(-)
create mode 100644 examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index 19697f81..8062dd7b 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -7,6 +7,7 @@
import com.demcha.examples.features.docx.WordExportExample;
import com.demcha.examples.features.chrome.PdfChromeExample;
import com.demcha.examples.features.lists.NestedListExample;
+import com.demcha.examples.features.shapes.PhotoClipExample;
import com.demcha.examples.features.shapes.ShapeContainerExample;
import com.demcha.examples.features.shapes.VectorPathExample;
import com.demcha.examples.features.snapshots.LayoutSnapshotRegressionExample;
@@ -131,6 +132,7 @@ public static void main(String[] args) throws Exception {
// v1.5 visual primitives
System.out.println("Generated: " + ShapeContainerExample.generate());
System.out.println("Generated: " + VectorPathExample.generate());
+ System.out.println("Generated: " + PhotoClipExample.generate());
System.out.println("Generated: " + SvgIconGalleryExample.generate());
System.out.println("Generated: " + TransformsExample.generate());
System.out.println("Generated: " + TableAdvancedExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java
new file mode 100644
index 00000000..9d88e321
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java
@@ -0,0 +1,152 @@
+package com.demcha.examples.features.shapes;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.svg.SvgPath;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * Clip one rectangular photo to a free-form silhouette — the v1.8
+ * {@code ShapeOutline.Path} clip. Three "cookie-cutters" (a circle, an SVG
+ * heart, a star) cut the same photo into three shapes.
+ *
+ * The construction logic is always the same three moves:
+ *
+ * - outline — the shape that does the cutting (mandatory);
+ * - clipPolicy(CLIP_PATH) — "cut children to the silhouette"
+ * (already the container default, written here for clarity);
+ * - a layer — the photo, sized to the same box and
+ * {@code COVER}-filled so it reaches every edge for the clip to bite.
+ *
+ *
+ * @author Artem Demchyshyn
+ */
+public final class PhotoClipExample {
+
+ private static final DocumentColor INK = DocumentColor.rgb(34, 38, 50);
+ private static final DocumentColor GOLD = DocumentColor.rgb(196, 153, 76);
+
+ /** Material Icons "favorite" heart (Apache 2.0), viewBox 0 0 24 24. */
+ private static final String HEART_D =
+ "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3"
+ + "c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5"
+ + "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
+
+ private static final double BOX = 150;
+
+ private PhotoClipExample() {
+ }
+
+ /**
+ * Renders the sheet: one photo, three silhouettes.
+ *
+ * @return path to the generated PDF
+ * @throws Exception if rendering or resource IO fails
+ */
+ public static Path generate() throws Exception {
+ Path pdf = ExampleOutputPaths.prepare("features/shapes", "photo-clip.pdf");
+ DocumentImageData photo = photo();
+
+ try (DocumentSession document = GraphCompose.document(pdf)
+ .pageSize(560, 300)
+ .margin(DocumentInsets.of(28))
+ .create()) {
+ document.pageFlow(page -> page
+ .addParagraph(p -> p
+ .text("Photo clipped to a silhouette")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK)))
+ .addParagraph(p -> p
+ .text("One rectangular photo, three cookie-cutters. The image fills "
+ + "each box (COVER); the outline cuts it to shape, native curves "
+ + "stay crisp at any zoom.")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9.5)
+ .withColor(DocumentColor.rgb(90, 96, 105)))
+ .padding(DocumentInsets.bottom(12)))
+ .addRow(row -> row.spacing(20).evenWeights()
+ .addSection(s -> s.spacing(6)
+ .add(circleClip(photo))
+ .addParagraph("circle(150)"))
+ .addSection(s -> s.spacing(6)
+ .add(heartClip(photo))
+ .addParagraph("path(150, 150, SvgPath heart)"))
+ .addSection(s -> s.spacing(6)
+ .add(starClip(photo))
+ .addParagraph("star(150, 150)"))));
+ document.buildPdf();
+ }
+ return pdf;
+ }
+
+ /** Circle cutter — an ellipse clip, available before v1.8. */
+ private static DocumentNode circleClip(DocumentImageData photo) {
+ return new ShapeContainerBuilder()
+ .name("CirclePhoto")
+ .circle(BOX) // 1. outline = the cutter (mandatory)
+ .clipPolicy(ClipPolicy.CLIP_PATH) // 2. cut children to the silhouette
+ .stroke(DocumentStroke.of(GOLD, 2)) // (optional) gold rim along the cut
+ .center(cover(photo)) // 3. the photo, COVER-filling the box
+ .build();
+ }
+
+ /** Heart cutter — the v1.8 free-form path clip (native Béziers). */
+ private static DocumentNode heartClip(DocumentImageData photo) {
+ return new ShapeContainerBuilder()
+ .name("HeartPhoto")
+ .path(BOX, BOX, SvgPath.parse(HEART_D, 0, 0, 24, 24))
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .stroke(DocumentStroke.of(GOLD, 2))
+ .center(cover(photo))
+ .build();
+ }
+
+ /** Five-point star cutter. */
+ private static DocumentNode starClip(DocumentImageData photo) {
+ return new ShapeContainerBuilder()
+ .name("StarPhoto")
+ .star(BOX, BOX)
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .stroke(DocumentStroke.of(GOLD, 2))
+ .center(cover(photo))
+ .build();
+ }
+
+ /**
+ * The photo sized to fill the WHOLE box with {@code COVER}, so it reaches
+ * every edge of the silhouette and the clip has something to cut. A smaller
+ * size or {@code CONTAIN} would leave gaps inside the shape; {@code STRETCH}
+ * would distort the photo.
+ */
+ private static DocumentNode cover(DocumentImageData photo) {
+ return new ImageBuilder()
+ .source(photo)
+ .size(BOX, BOX)
+ .fitMode(DocumentImageFitMode.COVER)
+ .build();
+ }
+
+ private static DocumentImageData photo() throws Exception {
+ try (InputStream in = Objects.requireNonNull(
+ PhotoClipExample.class.getResourceAsStream("/engine-hero.jpg"),
+ "engine-hero.jpg missing from examples/src/main/resources/")) {
+ return DocumentImageData.fromBytes(in.readAllBytes());
+ }
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java
index 5c004b3a..0d575f65 100644
--- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java
+++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java
@@ -346,6 +346,21 @@ public static Path generate() throws Exception {
.svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
.fillColor(DocumentColor.rgb(196, 30, 58))));
+ feature(flow, "SVG-path clip — content clipped to a curve silhouette (CLIP_PATH)", """
+ section.addContainer(card -> card
+ .path(72, 72, SvgPath.parse(HEART_D, 0, 0, 24, 24))
+ .clipPolicy(ClipPolicy.CLIP_PATH) // full-box gradient renders heart-shaped
+ .layer(new ShapeBuilder().size(72, 72)
+ .fill(DocumentPaint.linear(GOLD, TEAL)).build()))""",
+ demo -> demo.addContainer(card -> card
+ .name("HeartClip")
+ .path(72, 72, SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .layer(new com.demcha.compose.document.dsl.ShapeBuilder()
+ .size(72, 72)
+ .fill(DocumentPaint.linear(GOLD, TEAL))
+ .build())));
+
feature(flow, "SVG icons (beta) — multicolour files centred on tile cards", """
SvgIcon icon = SvgIcon.read(Path.of("icons/apple.svg")); // layers + resolved paints
card.roundedRect(74, 64, 8) // fixed box = the tile
diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
index 14ed92b2..40be43aa 100644
--- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
+++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
@@ -97,6 +97,7 @@ record Entry(String title, String description, List tags, String codeUrl
feature("shapes", "shape-container", "Shape-as-Container", "Rounded rect, ellipse, circle containers with ClipPolicy and layered children.", "shapes", "clip");
feature("svg", "svg-icon-gallery", "SVG Icon Gallery", "34 real-world multicolour svgrepo icons through SvgIcon.parse — native vector layers, the whole set 156 KB of sources.", "svg", "icons", "v1.8");
feature("shapes", "vector-path", "Vector Paths (Bézier)", "addPath(...) — free-form design shapes with native cubic Bézier curves: stroked waves, filled blobs, mixed line/curve ribbons. No tessellation.", "shapes", "bezier", "v1.8");
+ feature("shapes", "photo-clip", "Photo Clip (silhouette)", "A raster photo clipped to a free-form silhouette — circle, SVG heart, star — via ShapeContainer.path(...) + ClipPolicy.CLIP_PATH; the image COVER-fills each box so the native-curve outline crops it crisply at any zoom.", "shapes", "clip", "v1.8");
feature("transforms", "transforms", "Layers + Transforms", "rotate / scale on every leaf builder + LayerStack with explicit z-index.", "transforms", "layers");
feature("text", "rich-text-showcase", "Rich Text", "Inline runs with bold / italic / colour / link options, markdown parsing.", "text", "rich");
feature("text", "section-presets", "Section Presets", "Pre-baked section bands, accent strips, soft panels for templates.", "text", "sections");
@@ -204,7 +205,7 @@ private static void feature(String group, String id, String title, String desc,
case "lists" -> "lists/NestedListExample.java";
case "tables" -> id.contains("composed") ? "tables/ComposedTableCellExample.java" : "tables/TableAdvancedExample.java";
case "canvas" -> "canvas/CanvasLayerExample.java";
- case "shapes" -> "shapes/ShapeContainerExample.java";
+ case "shapes" -> id.equals("photo-clip") ? "shapes/PhotoClipExample.java" : "shapes/ShapeContainerExample.java";
case "transforms" -> "transforms/TransformsExample.java";
case "text" -> id.equals("section-presets") ? "text/SectionPresetsExample.java" : "text/RichTextShowcaseExample.java";
case "themes" -> "themes/CustomBusinessThemeExample.java";
From fb89f615c3a194481ac2568797b46b963c4acb43 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 13 Jun 2026 14:28:27 +0100
Subject: [PATCH 4/4] =?UTF-8?q?test(style):=20native-curve=20clip=20covera?=
=?UTF-8?q?ge=20=E2=80=94=20cubic=20segment=20+=20inline=20visual?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a CubicTo clip test (curve survives to the clip payload, not flattened)
and a self-contained ShapeClipPathVisualTest that samples the rendered pixels
(triangle clip ~50% coverage, base-at-bottom) to guard a dropped/empty clip,
wrong scale, or y-flip without a committed pixel baseline.
---
.../dsl/ShapeContainerBuilderTest.java | 39 ++++++++
.../visual/ShapeClipPathVisualTest.java | 98 +++++++++++++++++++
2 files changed, 137 insertions(+)
create mode 100644 src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java
diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
index e49ef382..d08e67eb 100644
--- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java
@@ -198,6 +198,45 @@ void pathOutlineReachesTheClipPayloadAndRenders() throws Exception {
}
}
+ @Test
+ void cubicCurveOutlineKeepsItsCurveSegmentThroughTheClipPipeline() throws Exception {
+ // Every other path-clip test is line-only. A native cubic Bézier
+ // (curveTo) must survive to the clip payload as a CubicTo segment —
+ // NOT flattened to lines — and render without error, since native
+ // curves are the whole point of ShapeOutline.Path. This asserts the
+ // clip *payload*; ShapeClipPathVisualTest covers the rendered pixels.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(200, 160)
+ .margin(DocumentInsets.of(16))
+ .create()) {
+
+ session.add(new ShapeContainerBuilder()
+ .name("Petal")
+ .path(120.0, 90.0, java.util.List.of(
+ com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.5, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.cubicTo(
+ 1.1, 0.9, 1.0, 0.1, 0.5, 0.0),
+ com.demcha.compose.document.style.DocumentPathSegment.cubicTo(
+ 0.0, 0.1, -0.1, 0.9, 0.5, 1.0),
+ com.demcha.compose.document.style.DocumentPathSegment.close()))
+ .fillColor(BRAND)
+ .center(spacer("Inside", 40.0, 16.0))
+ .build());
+
+ List fragments = session.layoutGraph().fragments();
+ int begin = indexOfPayload(fragments, ShapeClipBeginPayload.class);
+ assertThat(begin).isGreaterThanOrEqualTo(0);
+ ShapeOutline outline = ((ShapeClipBeginPayload) fragments.get(begin).payload()).outline();
+ assertThat(outline).isInstanceOf(ShapeOutline.Path.class);
+ assertThat(((ShapeOutline.Path) outline).segments())
+ .as("the cubic segment must reach the clip payload, not be flattened to lines")
+ .anyMatch(s -> s instanceof com.demcha.compose.document.style.DocumentPathSegment.CubicTo);
+
+ byte[] pdf = session.toPdfBytes();
+ assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
+ }
+ }
+
@Test
void svgPathBridgeProducesAPathOutline() {
// path(w, h, SvgPath) clips a container to an imported icon silhouette.
diff --git a/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java b/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java
new file mode 100644
index 00000000..e2de80b9
--- /dev/null
+++ b/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java
@@ -0,0 +1,98 @@
+package com.demcha.testing.visual;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.testing.visual.PdfVisualRegression;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Deterministic visual check that a {@code ShapeOutline.Path} clip actually
+ * shapes the rendered pixels — the gap the {@code %PDF-} smoke tests cannot
+ * cover. A full-box red layer clipped to an upward triangle must paint roughly
+ * half the box (a triangle is half its bounding box) with the wide base at the
+ * BOTTOM. So a dropped clip (≈100% red), an empty clip (≈0% red), a wrong scale,
+ * and a y-flip (apex/base swapped) all fail this test.
+ *
+ * Self-contained on purpose: it samples the in-memory render instead of
+ * diffing a committed PNG baseline, so it stays deterministic and
+ * anti-aliasing-tolerant across platforms without a blessed pixel reference.
+ */
+class ShapeClipPathVisualTest {
+
+ private static final int BOX = 120;
+
+ @Test
+ void pathClipShapesTheRenderedPixelsRightSideUp() throws Exception {
+ BufferedImage page = PdfVisualRegression.standard()
+ .renderPages(renderTriangleClip())
+ .get(0);
+
+ int w = page.getWidth();
+ int h = page.getHeight();
+ long red = 0;
+ long redTop = 0;
+ long redBottom = 0;
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ if (isRed(page.getRGB(x, y))) {
+ red++;
+ if (y < h / 2) {
+ redTop++;
+ } else {
+ redBottom++;
+ }
+ }
+ }
+ }
+
+ double fraction = (double) red / ((long) w * h);
+ assertThat(fraction)
+ .as("clipped red triangle should cover ~half the box "
+ + "(clip applied, right scale) — got %.3f", fraction)
+ .isBetween(0.30, 0.70);
+ assertThat(redBottom)
+ .as("wide base at the bottom, narrow apex at the top — guards a y-flip")
+ .isGreaterThan(redTop * 2);
+ }
+
+ /** A 120x120 container clipping a full-box red layer to an upward triangle. */
+ private static byte[] renderTriangleClip() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(BOX, BOX)
+ .margin(DocumentInsets.zero())
+ .create()) {
+ session.add(new ShapeContainerBuilder()
+ .name("TriangleClip")
+ .path(BOX, BOX, List.of(
+ DocumentPathSegment.moveTo(0.5, 1.0), // apex, top (y grows up)
+ DocumentPathSegment.lineTo(1.0, 0.0), // base, bottom-right
+ DocumentPathSegment.lineTo(0.0, 0.0), // base, bottom-left
+ DocumentPathSegment.close()))
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .layer(new ShapeBuilder()
+ .size(BOX, BOX)
+ .fillColor(DocumentColor.rgb(220, 20, 20))
+ .build())
+ .build());
+ return session.toPdfBytes();
+ }
+ }
+
+ private static boolean isRed(int rgb) {
+ int r = (rgb >> 16) & 0xFF;
+ int g = (rgb >> 8) & 0xFF;
+ int b = rgb & 0xFF;
+ return r > 140 && g < 110 && b < 110;
+ }
+}