Skip to content

Commit 7f6e840

Browse files
authored
Merge pull request #168 from DemchaAV/feat/path-node-primitive
feat(engine): PathNode vector-path primitive with native PDF curve operators
2 parents 5340383 + 64fafef commit 7f6e840

13 files changed

Lines changed: 767 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ Entries land here as they merge.
5252
into the new general-purpose `PolygonNode` (arc-tessellated ring polygons at
5353
a fixed 3° step — deterministic vertices, no new render handlers), which also
5454
lays the groundwork for SVG icon-path import.
55+
- **Vector path primitive** (`@since 1.8.0`). New `PathNode` — the open-path,
56+
curve-capable sibling of `PolygonNode`: normalized `DocumentPathSegment`s
57+
(`moveTo` / `lineTo` / cubic `cubicTo` / `close`; Bézier control points are
58+
free to overshoot the unit box) are scaled to the node's box and rendered
59+
with native PDF curve operators, so curves stay perfectly smooth at any
60+
zoom level instead of being tessellated into straight pieces. Atomic
61+
pagination, deterministic layout snapshots, fill (non-zero winding rule)
62+
and/or stroke. This is the leaf vehicle for smooth chart lines, decorative
63+
design shapes, and future SVG path import.
5564
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
5665
values...)` draws a filled mini-area silhouette on the text baseline, and
5766
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line

src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ private static List<PdfFragmentRenderHandler<?>> defaultHandlers() {
9696
new PdfLineFragmentRenderHandler(),
9797
new PdfEllipseFragmentRenderHandler(),
9898
new PdfPolygonFragmentRenderHandler(),
99+
new PdfPathFragmentRenderHandler(),
99100
new PdfImageFragmentRenderHandler(),
100101
new PdfTableRowFragmentRenderHandler(),
101102
new PdfShapeClipBeginRenderHandler(),
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.demcha.compose.document.backend.fixed.pdf.handlers;
2+
3+
import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler;
4+
import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment;
5+
import com.demcha.compose.document.layout.PlacedFragment;
6+
import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
7+
import org.apache.pdfbox.pdmodel.PDPageContentStream;
8+
9+
import java.io.IOException;
10+
11+
/**
12+
* Renders fixed vector-path fragments with native PDF line and cubic-Bézier
13+
* operators — curves stay smooth at any zoom level.
14+
*
15+
* @author Artem Demchyshyn
16+
*/
17+
public final class PdfPathFragmentRenderHandler
18+
implements PdfFragmentRenderHandler<PathFragmentPayload> {
19+
20+
/**
21+
* Creates the path fragment renderer.
22+
*/
23+
public PdfPathFragmentRenderHandler() {
24+
}
25+
26+
@Override
27+
public Class<PathFragmentPayload> payloadType() {
28+
return PathFragmentPayload.class;
29+
}
30+
31+
@Override
32+
public void render(PlacedFragment fragment,
33+
PathFragmentPayload payload,
34+
PdfRenderEnvironment environment) throws IOException {
35+
if (fragment.width() <= 0 || fragment.height() <= 0) {
36+
return;
37+
}
38+
PDPageContentStream stream = environment.pageSurface(fragment.pageIndex());
39+
float x = (float) fragment.x();
40+
float y = (float) fragment.y();
41+
float width = (float) fragment.width();
42+
float height = (float) fragment.height();
43+
PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(),
44+
s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments()));
45+
}
46+
}

src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java

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

3+
import com.demcha.compose.document.style.DocumentPathSegment;
34
import com.demcha.compose.document.style.ShapePoint;
45
import com.demcha.compose.engine.components.content.shape.Stroke;
56
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -79,6 +80,35 @@ static void addPolygonPath(PDPageContentStream stream,
7980
stream.closePath();
8081
}
8182

83+
/**
84+
* Appends a normalized segment path scaled to the fragment box, emitting
85+
* native line and cubic-Bézier operators. Segments follow the
86+
* {@link DocumentPathSegment} contract: normalized unit-box coordinates,
87+
* PDF y-up orientation, {@code MoveTo} first, control points free to
88+
* overshoot the box.
89+
*/
90+
static void addPathSegments(PDPageContentStream stream,
91+
float x,
92+
float y,
93+
float width,
94+
float height,
95+
List<DocumentPathSegment> segments) throws IOException {
96+
for (DocumentPathSegment segment : segments) {
97+
if (segment instanceof DocumentPathSegment.MoveTo move) {
98+
stream.moveTo(x + (float) (move.x() * width), y + (float) (move.y() * height));
99+
} else if (segment instanceof DocumentPathSegment.LineTo line) {
100+
stream.lineTo(x + (float) (line.x() * width), y + (float) (line.y() * height));
101+
} else if (segment instanceof DocumentPathSegment.CubicTo cubic) {
102+
stream.curveTo(
103+
x + (float) (cubic.control1X() * width), y + (float) (cubic.control1Y() * height),
104+
x + (float) (cubic.control2X() * width), y + (float) (cubic.control2Y() * height),
105+
x + (float) (cubic.x() * width), y + (float) (cubic.y() * height));
106+
} else if (segment instanceof DocumentPathSegment.Close) {
107+
stream.closePath();
108+
}
109+
}
110+
}
111+
82112
/**
83113
* Appends a closed rounded-rectangle path whose four corners may have
84114
* independent radii. Each radius gets its own Bezier arc; a zero radius

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) {
4343
.register(new TableDefinition())
4444
.register(new CanvasLayerDefinition())
4545
.register(new PolygonDefinition())
46+
.register(new PathDefinition())
4647
.register(new ChartDefinition());
4748
}
4849
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.demcha.compose.document.layout.definitions;
2+
3+
import com.demcha.compose.document.layout.*;
4+
import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
5+
import com.demcha.compose.document.node.PathNode;
6+
7+
import java.util.List;
8+
9+
import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS;
10+
import static com.demcha.compose.document.layout.NodeDefinitionSupport.toStroke;
11+
12+
/**
13+
* Layout definition for {@link PathNode}: a fixed-size atomic vector-path
14+
* fragment rendered through the path fragment pipeline with native curve
15+
* operators.
16+
*
17+
* @author Artem Demchyshyn
18+
* @since 1.8.0
19+
*/
20+
public final class PathDefinition implements NodeDefinition<PathNode> {
21+
22+
/**
23+
* Creates the path layout definition.
24+
*/
25+
public PathDefinition() {
26+
}
27+
28+
@Override
29+
public Class<PathNode> nodeType() {
30+
return PathNode.class;
31+
}
32+
33+
@Override
34+
public PreparedNode<PathNode> prepare(PathNode node, PrepareContext ctx, BoxConstraints constraints) {
35+
return PreparedNode.leaf(node, new MeasureResult(
36+
node.width() + node.padding().horizontal(),
37+
node.height() + node.padding().vertical()));
38+
}
39+
40+
@Override
41+
public PaginationPolicy paginationPolicy(PathNode node) {
42+
return PaginationPolicy.ATOMIC;
43+
}
44+
45+
@Override
46+
public List<LayoutFragment> emitFragments(PreparedNode<PathNode> prepared,
47+
FragmentContext ctx,
48+
FragmentPlacement placement) {
49+
PathNode node = prepared.node();
50+
double width = Math.max(0.0, placement.width() - node.padding().horizontal());
51+
double height = Math.max(0.0, placement.height() - node.padding().vertical());
52+
if (width <= EPS || height <= EPS) {
53+
return List.of();
54+
}
55+
return List.of(new LayoutFragment(
56+
placement.path(),
57+
0,
58+
node.padding().left(),
59+
node.padding().bottom(),
60+
width,
61+
height,
62+
new PathFragmentPayload(
63+
node.segments(),
64+
node.fillColor() == null ? null : node.fillColor().color(),
65+
toStroke(node.stroke()),
66+
null,
67+
null)));
68+
}
69+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.demcha.compose.document.layout.payloads;
2+
3+
import com.demcha.compose.document.node.DocumentBookmarkOptions;
4+
import com.demcha.compose.document.node.DocumentLinkOptions;
5+
import com.demcha.compose.document.style.DocumentPathSegment;
6+
import com.demcha.compose.engine.components.content.shape.Stroke;
7+
8+
import java.awt.*;
9+
import java.util.List;
10+
import java.util.Objects;
11+
12+
/**
13+
* PDF payload for a resolved vector-path fragment (curved chart strokes,
14+
* decorative design shapes, imported icon paths). The normalized segments are
15+
* scaled to the placed fragment's size by the render handler, which emits
16+
* native PDF line and cubic-Bézier operators.
17+
*
18+
* @param segments normalized path segments, starting with a move-to
19+
* @param fillColor optional fill color (non-zero winding rule)
20+
* @param stroke optional stroke
21+
* @param linkOptions optional fragment-level link metadata
22+
* @param bookmarkOptions optional fragment-level bookmark metadata
23+
* @author Artem Demchyshyn
24+
* @since 1.8.0
25+
*/
26+
public record PathFragmentPayload(
27+
List<DocumentPathSegment> segments,
28+
Color fillColor,
29+
Stroke stroke,
30+
DocumentLinkOptions linkOptions,
31+
DocumentBookmarkOptions bookmarkOptions
32+
) implements PdfSemanticFragmentPayload {
33+
/**
34+
* Copies the segment list defensively.
35+
*/
36+
public PathFragmentPayload {
37+
Objects.requireNonNull(segments, "segments");
38+
segments = List.copyOf(segments);
39+
}
40+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.demcha.compose.document.node;
2+
3+
import com.demcha.compose.document.style.DocumentColor;
4+
import com.demcha.compose.document.style.DocumentInsets;
5+
import com.demcha.compose.document.style.DocumentPathSegment;
6+
import com.demcha.compose.document.style.DocumentStroke;
7+
8+
import java.util.List;
9+
import java.util.Objects;
10+
11+
/**
12+
* Atomic filled/stroked vector path inside a fixed-size box. Segments are
13+
* normalized {@link DocumentPathSegment}s scaled to the node's
14+
* {@code width × height} at render time — the open-path, curve-capable
15+
* sibling of {@link PolygonNode}.
16+
*
17+
* <p>This is the leaf vehicle for arbitrary vector geometry with real cubic
18+
* Bézier curves: smooth chart lines compile into it, decorative design
19+
* shapes can be authored against it, and imported SVG paths land here
20+
* tomorrow. The PDF backend emits native {@code curveTo} operators, so
21+
* curves stay perfectly smooth at any zoom level instead of being
22+
* tessellated into straight pieces.</p>
23+
*
24+
* @param name node name used in snapshots and layout graph paths
25+
* @param width resolved box width
26+
* @param height resolved box height
27+
* @param segments normalized path segments; must start with a
28+
* {@link DocumentPathSegment.MoveTo}
29+
* @param fillColor optional fill colour (non-zero winding rule)
30+
* @param stroke optional outline stroke
31+
* @param padding inner padding
32+
* @param margin outer margin
33+
* @author Artem Demchyshyn
34+
* @since 1.8.0
35+
*/
36+
public record PathNode(
37+
String name,
38+
double width,
39+
double height,
40+
List<DocumentPathSegment> segments,
41+
DocumentColor fillColor,
42+
DocumentStroke stroke,
43+
DocumentInsets padding,
44+
DocumentInsets margin
45+
) implements DocumentNode {
46+
/**
47+
* Validates dimensions and the segment list; copy-protects the segments.
48+
*/
49+
public PathNode {
50+
name = name == null ? "" : name;
51+
Objects.requireNonNull(segments, "segments");
52+
segments = List.copyOf(segments);
53+
if (segments.size() < 2) {
54+
throw new IllegalArgumentException(
55+
"path needs at least a MoveTo and one drawing segment: " + segments.size());
56+
}
57+
if (!(segments.get(0) instanceof DocumentPathSegment.MoveTo)) {
58+
throw new IllegalArgumentException(
59+
"path must start with a MoveTo segment, found: "
60+
+ segments.get(0).getClass().getSimpleName());
61+
}
62+
padding = padding == null ? DocumentInsets.zero() : padding;
63+
margin = margin == null ? DocumentInsets.zero() : margin;
64+
if (width <= 0 || Double.isNaN(width) || Double.isInfinite(width)) {
65+
throw new IllegalArgumentException("width must be finite and positive: " + width);
66+
}
67+
if (height <= 0 || Double.isNaN(height) || Double.isInfinite(height)) {
68+
throw new IllegalArgumentException("height must be finite and positive: " + height);
69+
}
70+
}
71+
72+
@Override
73+
public String nodeKind() {
74+
return "Path";
75+
}
76+
}

0 commit comments

Comments
 (0)