Skip to content

Commit 88b0be3

Browse files
committed
feat(svg): stroke fidelity + colour/unit hardening for the beta SVG reader
- stroke-linecap/linejoin → native PDF J/j operators (new DocumentLineCap/ DocumentLineJoin, PathBuilder.lineCap()/lineJoin()); emitted only when non-default so default-styled output stays byte-identical - stroke-dasharray honoured; 147 CSS named colours, rgb()/rgba() with numbers or percentages, #rgb/#rgba/#rrggbb/#rrggbbaa hex, absolute length units (px/pt/pc/in/mm/cm) on stroke widths — all in new SvgColors/SvgStyles - fix: SvgIcon#node(width) now scales stroke width AND dash lengths by width/sourceWidth (they live in user units) — icons drawn below source size no longer render over-thick outlines - loud skips: one deduplicated warn-log per dropped kind in the reader (text/image/use/...) and in DocxSemanticBackend (geometry-only nodes); LayerStackNode/CanvasLayerNode now recurse so child text survives DOCX - unknown colours / relative units fail with the supported set listed - VectorPathExample gains a caps & joins demo; +22 tests
1 parent 7ea0ee0 commit 88b0be3

21 files changed

Lines changed: 1162 additions & 101 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,24 @@ Entries land here as they merge.
9898
`DocumentPaint` gains endpoint-exact `LinearAxis` / `RadialCircle` forms
9999
and `PathNode` / `PathBuilder` grow `fill(paint)` / `strokePaint(paint)`
100100
with solid paints normalising to the flat-colour path (byte-identical
101-
output for non-gradient documents). The XML reader refuses DOCTYPEs (no
102-
XXE); CSS, text, filters, focal radials, non-pad `spreadMethod` and
103-
translucent stops stay deliberately out of scope — the reader fails
104-
loudly rather than rendering them wrong.
101+
output for non-gradient documents). **Stroke fidelity**: the reader honours
102+
`stroke-linecap` / `stroke-linejoin` (rendered as native PDF `J` / `j`
103+
operators via new `DocumentLineCap` / `DocumentLineJoin`, also on
104+
`PathBuilder.lineCap()` / `lineJoin()`) and `stroke-dasharray`, the full
105+
CSS named-colour table (147 keywords), `rgb()` / `rgba()` with numbers or
106+
percentages, `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa` hex, and absolute
107+
length units (`px` / `pt` / `pc` / `in` / `mm` / `cm`) on stroke widths;
108+
relative units and unknown colours fail with the supported alternatives
109+
listed. `SvgIcon#node(width)` now scales stroke widths and dash lengths
110+
with the geometry (they live in user units), so an icon drawn smaller than
111+
its source no longer renders an over-thick outline. Content the reader
112+
can't render (`text`, `image`, `use`, masks, clips, filters) is dropped
113+
with a single deduplicated warn-log per kind instead of silently, and the
114+
DOCX backend warns once per geometry-only node kind (`path`, `polygon`,
115+
`shape`, …) it drops. The XML reader refuses DOCTYPEs (no XXE); CSS
116+
stylesheets, text, filters, focal radials, non-pad `spreadMethod` and
117+
translucent gradient stops stay deliberately out of scope — the reader
118+
fails loudly rather than rendering them wrong.
105119
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
106120
values...)` draws a filled mini-area silhouette on the text baseline, and
107121
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line
234 Bytes
Binary file not shown.

examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import com.demcha.compose.document.api.DocumentSession;
55
import com.demcha.compose.document.style.DocumentColor;
66
import com.demcha.compose.document.style.DocumentInsets;
7+
import com.demcha.compose.document.style.DocumentLineCap;
8+
import com.demcha.compose.document.style.DocumentLineJoin;
79
import com.demcha.compose.document.style.DocumentPaint;
810
import com.demcha.compose.document.style.DocumentStroke;
911
import com.demcha.compose.document.svg.SvgIcon;
@@ -77,7 +79,7 @@ public static Path generate() throws Exception {
7779
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf");
7880

7981
try (DocumentSession document = GraphCompose.document(pdfFile)
80-
.pageSize(420, 920)
82+
.pageSize(420, 1010)
8183
.margin(DocumentInsets.of(28))
8284
.create()) {
8385
document.pageFlow(page -> page
@@ -111,6 +113,17 @@ public static Path generate() throws Exception {
111113
.stroke(DocumentStroke.of(INK, 1.8))
112114
.dashed(6, 3)
113115
.margin(DocumentInsets.bottom(16)))
116+
.addParagraph("Stroke caps & joins — butt, round, square (lineCap / lineJoin)")
117+
.addRow(row -> row.spacing(24).evenWeights().margin(DocumentInsets.bottom(16))
118+
.addSection(col -> col.spacing(4)
119+
.add(cappedZigzag("Butt", DocumentLineCap.BUTT, DocumentLineJoin.MITER))
120+
.addParagraph("BUTT / MITER"))
121+
.addSection(col -> col.spacing(4)
122+
.add(cappedZigzag("Round", DocumentLineCap.ROUND, DocumentLineJoin.ROUND))
123+
.addParagraph("ROUND / ROUND"))
124+
.addSection(col -> col.spacing(4)
125+
.add(cappedZigzag("Square", DocumentLineCap.SQUARE, DocumentLineJoin.BEVEL))
126+
.addParagraph("SQUARE / BEVEL")))
114127
.addParagraph("SVG path import — Material 'favorite' heart via SvgPath.parse")
115128
.addPath(path -> path
116129
.name("HeartIcon")
@@ -159,6 +172,25 @@ public static Path generate() throws Exception {
159172
return pdfFile;
160173
}
161174

175+
/**
176+
* A thick open zig-zag whose ends and corner expose the cap / join style.
177+
* Drawn fat (8 pt) so BUTT vs ROUND vs SQUARE ends and MITER vs ROUND vs
178+
* BEVEL corners read clearly.
179+
*/
180+
private static com.demcha.compose.document.node.DocumentNode cappedZigzag(
181+
String name, DocumentLineCap cap, DocumentLineJoin join) {
182+
return new com.demcha.compose.document.dsl.PathBuilder()
183+
.name("Cap" + name)
184+
.size(96, 44)
185+
.moveTo(0.06, 0.18)
186+
.lineTo(0.5, 0.92)
187+
.lineTo(0.94, 0.18)
188+
.stroke(DocumentStroke.of(INK, 8))
189+
.lineCap(cap)
190+
.lineJoin(join)
191+
.build();
192+
}
193+
162194
public static void main(String[] args) throws Exception {
163195
System.out.println("Generated: " + generate());
164196
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public void render(PlacedFragment fragment,
5050

5151
if (payload.fillPaint() == null && payload.strokePaint() == null) {
5252
PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(),
53-
payload.dashPattern(),
53+
payload.dashPattern(), payload.lineCap(), payload.lineJoin(),
5454
s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments()));
5555
return;
5656
}
@@ -83,11 +83,12 @@ public void render(PlacedFragment fragment,
8383
payload.strokePaint(), resources, x, y, width, height));
8484
stream.setLineWidth((float) payload.stroke().width());
8585
PdfShapeGeometry.applyDashPattern(stream, payload.dashPattern());
86+
PdfShapeGeometry.applyStrokeStyle(stream, payload.lineCap(), payload.lineJoin());
8687
PdfShapeGeometry.addPathSegments(stream, x, y, width, height, payload.segments());
8788
stream.stroke();
8889
} else if (hasStrokeWidth && payload.stroke().strokeColor() != null) {
8990
PdfShapeGeometry.fillAndStrokePath(stream, null, payload.stroke(),
90-
payload.dashPattern(),
91+
payload.dashPattern(), payload.lineCap(), payload.lineJoin(),
9192
s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments()));
9293
}
9394
} finally {

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

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

33
import com.demcha.compose.document.style.DocumentDashPattern;
4+
import com.demcha.compose.document.style.DocumentLineCap;
5+
import com.demcha.compose.document.style.DocumentLineJoin;
46
import com.demcha.compose.document.style.DocumentPathSegment;
57
import com.demcha.compose.document.style.ShapePoint;
68
import com.demcha.compose.engine.components.content.shape.Stroke;
@@ -43,6 +45,21 @@ static void fillAndStrokePath(PDPageContentStream stream,
4345
Stroke stroke,
4446
DocumentDashPattern dashPattern,
4547
PathEmitter path) throws IOException {
48+
fillAndStrokePath(stream, fillColor, stroke, dashPattern, null, null, path);
49+
}
50+
51+
/**
52+
* Variant with explicit stroke cap and join styles. {@code null} (or the
53+
* PDF defaults {@code BUTT} / {@code MITER}) emits no extra operators, so
54+
* every existing call site stays byte-identical.
55+
*/
56+
static void fillAndStrokePath(PDPageContentStream stream,
57+
Color fillColor,
58+
Stroke stroke,
59+
DocumentDashPattern dashPattern,
60+
DocumentLineCap lineCap,
61+
DocumentLineJoin lineJoin,
62+
PathEmitter path) throws IOException {
4663
boolean hasFill = fillColor != null;
4764
boolean hasStroke = stroke != null
4865
&& stroke.strokeColor() != null
@@ -58,6 +75,7 @@ static void fillAndStrokePath(PDPageContentStream stream,
5875
stream.setStrokingColor(stroke.strokeColor().color());
5976
stream.setLineWidth((float) stroke.width());
6077
applyDashPattern(stream, dashPattern);
78+
applyStrokeStyle(stream, lineCap, lineJoin);
6179
}
6280
if (hasFill) {
6381
PdfAlphaSupport.applyFillAlpha(stream, fillColor);
@@ -76,6 +94,21 @@ static void fillAndStrokePath(PDPageContentStream stream,
7694
}
7795
}
7896

97+
/**
98+
* Emits line cap / join operators only when they differ from the PDF
99+
* defaults, keeping default-styled output byte-identical.
100+
*/
101+
static void applyStrokeStyle(PDPageContentStream stream,
102+
DocumentLineCap lineCap,
103+
DocumentLineJoin lineJoin) throws IOException {
104+
if (lineCap != null && lineCap != DocumentLineCap.BUTT) {
105+
stream.setLineCapStyle(lineCap.pdfCode());
106+
}
107+
if (lineJoin != null && lineJoin != DocumentLineJoin.MITER) {
108+
stream.setLineJoinStyle(lineJoin.pdfCode());
109+
}
110+
}
111+
79112
/**
80113
* Appends a closed polygon path to the stream. Normalized vertices (see
81114
* {@link ShapePoint}) are scaled into the {@code [x, x+width] × [y, y+height]}

src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
7070
// each session sees the warning at least once.
7171
private final AtomicBoolean shapeContainerWarned = new AtomicBoolean(false);
7272
private final AtomicBoolean chartWarned = new AtomicBoolean(false);
73+
// Geometry-only node kinds already warned about this export pass.
74+
private final java.util.Set<String> warnedNodeKinds =
75+
java.util.concurrent.ConcurrentHashMap.newKeySet();
7376

7477
/**
7578
* Creates a DOCX semantic backend.
@@ -86,6 +89,7 @@ public String name() {
8689
public byte[] export(DocumentGraph graph, SemanticExportContext context) throws Exception {
8790
shapeContainerWarned.set(false);
8891
chartWarned.set(false);
92+
warnedNodeKinds.clear();
8993
try (XWPFDocument document = new XWPFDocument()) {
9094
applyPageGeometry(document, context.canvas());
9195
applyOutputOptions(document, context.outputOptions());
@@ -147,14 +151,31 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio
147151
writeChartFallback(document, chart);
148152
} else if (node instanceof com.demcha.compose.document.node.ListNode list) {
149153
writeList(document, list);
150-
} else if (node instanceof ContainerNode || node instanceof SectionNode) {
154+
} else if (node instanceof ContainerNode || node instanceof SectionNode
155+
|| node instanceof com.demcha.compose.document.node.LayerStackNode
156+
|| node instanceof com.demcha.compose.document.node.CanvasLayerNode) {
157+
// Overlay/positioned wrappers have no DOCX analogue for their
158+
// geometry, but their children can be semantic (text, images) —
159+
// render them sequentially rather than dropping the subtree.
151160
for (DocumentNode child : node.children()) {
152161
writeNode(document, child);
153162
}
163+
} else {
164+
// Geometry-only node kinds (line, ellipse, shape, path, polygon,
165+
// barcode) have no semantic Word analogue. Warn once per kind so a
166+
// dropped chart-line or icon is visible in the log instead of
167+
// silently missing; authors needing pixel-perfect output use the
168+
// PDF fixed-layout backend.
169+
warnUnsupported(node);
170+
}
171+
}
172+
173+
/** One warning per dropped node kind, deduplicated across the export. */
174+
private void warnUnsupported(DocumentNode node) {
175+
if (warnedNodeKinds.add(node.nodeKind())) {
176+
LOG.warn("DocxSemanticBackend: dropping '{}' node(s) — geometry has no semantic "
177+
+ "Word analogue; use the PDF backend for pixel-perfect output", node.nodeKind());
154178
}
155-
// Unsupported node kinds (line, ellipse, shape, barcode) are silently
156-
// skipped in the semantic export. Authors who need pixel-perfect output
157-
// should use the PDF fixed-layout backend.
158179
}
159180

160181
/**

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.demcha.compose.document.style.DocumentColor;
66
import com.demcha.compose.document.style.DocumentDashPattern;
77
import com.demcha.compose.document.style.DocumentInsets;
8+
import com.demcha.compose.document.style.DocumentLineCap;
9+
import com.demcha.compose.document.style.DocumentLineJoin;
810
import com.demcha.compose.document.style.DocumentPaint;
911
import com.demcha.compose.document.style.DocumentPathSegment;
1012
import com.demcha.compose.document.style.DocumentStroke;
@@ -51,6 +53,8 @@ public final class PathBuilder {
5153
private DocumentInsets padding = DocumentInsets.zero();
5254
private DocumentInsets margin = DocumentInsets.zero();
5355
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
56+
private DocumentLineCap lineCap;
57+
private DocumentLineJoin lineJoin;
5458

5559
/**
5660
* Creates a path builder.
@@ -260,6 +264,32 @@ public PathBuilder dashed(DocumentDashPattern pattern) {
260264
return this;
261265
}
262266

267+
/**
268+
* Sets the stroke end-cap style; {@code null} keeps the PDF default
269+
* ({@link DocumentLineCap#BUTT}).
270+
*
271+
* @param lineCap cap style, or {@code null} for the default
272+
* @return this builder
273+
* @since 1.8.0
274+
*/
275+
public PathBuilder lineCap(DocumentLineCap lineCap) {
276+
this.lineCap = lineCap;
277+
return this;
278+
}
279+
280+
/**
281+
* Sets the stroke corner style; {@code null} keeps the PDF default
282+
* ({@link DocumentLineJoin#MITER}).
283+
*
284+
* @param lineJoin join style, or {@code null} for the default
285+
* @return this builder
286+
* @since 1.8.0
287+
*/
288+
public PathBuilder lineJoin(DocumentLineJoin lineJoin) {
289+
this.lineJoin = lineJoin;
290+
return this;
291+
}
292+
263293
/**
264294
* Sets the path padding.
265295
*
@@ -294,6 +324,6 @@ public PathBuilder margin(DocumentInsets margin) {
294324
*/
295325
public PathNode build() {
296326
return new PathNode(name, width, height, segments, fillColor, fillPaint,
297-
stroke, strokePaint, padding, margin, dashPattern);
327+
stroke, strokePaint, padding, margin, dashPattern, lineCap, lineJoin);
298328
}
299329
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ public List<LayoutFragment> emitFragments(PreparedNode<PathNode> prepared,
9090
strokeGradient,
9191
null,
9292
null,
93-
node.dashPattern())));
93+
node.dashPattern(),
94+
node.lineCap(),
95+
node.lineJoin())));
9496
}
9597
}

src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.demcha.compose.document.node.DocumentBookmarkOptions;
44
import com.demcha.compose.document.node.DocumentLinkOptions;
55
import com.demcha.compose.document.style.DocumentDashPattern;
6+
import com.demcha.compose.document.style.DocumentLineCap;
7+
import com.demcha.compose.document.style.DocumentLineJoin;
68
import com.demcha.compose.document.style.DocumentPaint;
79
import com.demcha.compose.document.style.DocumentPathSegment;
810
import com.demcha.compose.engine.components.content.shape.Stroke;
@@ -31,6 +33,8 @@
3133
* @param bookmarkOptions optional fragment-level bookmark metadata
3234
* @param dashPattern dash pattern for the stroke;
3335
* {@link DocumentDashPattern#NONE} is solid
36+
* @param lineCap stroke end-cap style; {@code BUTT} is the PDF default
37+
* @param lineJoin stroke corner style; {@code MITER} is the PDF default
3438
* @author Artem Demchyshyn
3539
* @since 1.8.0
3640
*/
@@ -42,14 +46,19 @@ public record PathFragmentPayload(
4246
DocumentPaint strokePaint,
4347
DocumentLinkOptions linkOptions,
4448
DocumentBookmarkOptions bookmarkOptions,
45-
DocumentDashPattern dashPattern
49+
DocumentDashPattern dashPattern,
50+
DocumentLineCap lineCap,
51+
DocumentLineJoin lineJoin
4652
) implements PdfSemanticFragmentPayload {
4753
/**
48-
* Copies the segment list defensively and normalizes the dash pattern.
54+
* Copies the segment list defensively and normalizes dash and stroke
55+
* style defaults.
4956
*/
5057
public PathFragmentPayload {
5158
Objects.requireNonNull(segments, "segments");
5259
segments = List.copyOf(segments);
5360
dashPattern = dashPattern == null ? DocumentDashPattern.NONE : dashPattern;
61+
lineCap = lineCap == null ? DocumentLineCap.BUTT : lineCap;
62+
lineJoin = lineJoin == null ? DocumentLineJoin.MITER : lineJoin;
5463
}
5564
}

0 commit comments

Comments
 (0)