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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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
Expand Down
Binary file modified assets/readme/examples/vector-path.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.demcha.examples.features.chrome.PdfChromeExample;
import com.demcha.examples.features.layout.BlockAlignExample;
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;
Expand Down Expand Up @@ -132,6 +133,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: " + BlockAlignExample.generate());
System.out.println("Generated: " + TransformsExample.generate());
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>The construction logic is always the same three moves:</p>
* <ol>
* <li><b>outline</b> — the shape that does the cutting (mandatory);</li>
* <li><b>clipPolicy(CLIP_PATH)</b> — "cut children to the silhouette"
* (already the container default, written here for clarity);</li>
* <li><b>a layer</b> — the photo, sized to the <em>same</em> box and
* {@code COVER}-filled so it reaches every edge for the clip to bite.</li>
* </ol>
*
* @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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ record Entry(String title, String description, List<String> 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("layout", "block-align", "Block Alignment", "addAligned(align, node) / addSvgIcon(icon, w, align) — seat any fixed-size node left / centre / right across the content width.", "layout", "align", "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");
Expand Down Expand Up @@ -206,7 +207,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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).</p>
* rounded-rect, polygon for diamonds / arrows / stars, and a native-curve
* path for free-form {@code ShapeOutline.Path} silhouettes).</p>
*
* @author Artem Demchyshyn
*/
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<com.demcha.compose.document.style.DocumentPathSegment> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ public List<LayoutFragment> emitFragments(PreparedNode<ShapeContainerNode> 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);
}
Expand Down
Loading