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
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,24 @@ Entries land here as they merge.
`DocumentPaint` gains endpoint-exact `LinearAxis` / `RadialCircle` forms
and `PathNode` / `PathBuilder` grow `fill(paint)` / `strokePaint(paint)`
with solid paints normalising to the flat-colour path (byte-identical
output for non-gradient documents). The XML reader refuses DOCTYPEs (no
XXE); CSS, text, filters, focal radials, non-pad `spreadMethod` and
translucent stops stay deliberately out of scope — the reader fails
loudly rather than rendering them wrong.
output for non-gradient documents). **Stroke fidelity**: the reader honours
`stroke-linecap` / `stroke-linejoin` (rendered as native PDF `J` / `j`
operators via new `DocumentLineCap` / `DocumentLineJoin`, also on
`PathBuilder.lineCap()` / `lineJoin()`) and `stroke-dasharray`, the full
CSS named-colour table (147 keywords), `rgb()` / `rgba()` with numbers or
percentages, `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa` hex, and absolute
length units (`px` / `pt` / `pc` / `in` / `mm` / `cm`) on stroke widths;
relative units and unknown colours fail with the supported alternatives
listed. `SvgIcon#node(width)` now scales stroke widths and dash lengths
with the geometry (they live in user units), so an icon drawn smaller than
its source no longer renders an over-thick outline. Content the reader
can't render (`text`, `image`, `use`, masks, clips, filters) is dropped
with a single deduplicated warn-log per kind instead of silently, and the
DOCX backend warns once per geometry-only node kind (`path`, `polygon`,
`shape`, …) it drops. The XML reader refuses DOCTYPEs (no XXE); CSS
stylesheets, text, filters, focal radials, non-pad `spreadMethod` and
translucent gradient stops stay deliberately out of scope — the reader
fails loudly rather than rendering them wrong.
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
values...)` draws a filled mini-area silhouette on the text baseline, and
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line
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 @@ -4,6 +4,8 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentLineJoin;
import com.demcha.compose.document.style.DocumentPaint;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.svg.SvgIcon;
Expand Down Expand Up @@ -77,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, 920)
.pageSize(420, 1010)
.margin(DocumentInsets.of(28))
.create()) {
document.pageFlow(page -> page
Expand Down Expand Up @@ -111,6 +113,17 @@ public static Path generate() throws Exception {
.stroke(DocumentStroke.of(INK, 1.8))
.dashed(6, 3)
.margin(DocumentInsets.bottom(16)))
.addParagraph("Stroke caps & joins — butt, round, square (lineCap / lineJoin)")
.addRow(row -> row.spacing(24).evenWeights().margin(DocumentInsets.bottom(16))
.addSection(col -> col.spacing(4)
.add(cappedZigzag("Butt", DocumentLineCap.BUTT, DocumentLineJoin.MITER))
.addParagraph("BUTT / MITER"))
.addSection(col -> col.spacing(4)
.add(cappedZigzag("Round", DocumentLineCap.ROUND, DocumentLineJoin.ROUND))
.addParagraph("ROUND / ROUND"))
.addSection(col -> col.spacing(4)
.add(cappedZigzag("Square", DocumentLineCap.SQUARE, DocumentLineJoin.BEVEL))
.addParagraph("SQUARE / BEVEL")))
.addParagraph("SVG path import — Material 'favorite' heart via SvgPath.parse")
.addPath(path -> path
.name("HeartIcon")
Expand Down Expand Up @@ -159,6 +172,25 @@ public static Path generate() throws Exception {
return pdfFile;
}

/**
* A thick open zig-zag whose ends and corner expose the cap / join style.
* Drawn fat (8 pt) so BUTT vs ROUND vs SQUARE ends and MITER vs ROUND vs
* BEVEL corners read clearly.
*/
private static com.demcha.compose.document.node.DocumentNode cappedZigzag(
String name, DocumentLineCap cap, DocumentLineJoin join) {
return new com.demcha.compose.document.dsl.PathBuilder()
.name("Cap" + name)
.size(96, 44)
.moveTo(0.06, 0.18)
.lineTo(0.5, 0.92)
.lineTo(0.94, 0.18)
.stroke(DocumentStroke.of(INK, 8))
.lineCap(cap)
.lineJoin(join)
.build();
}

public static void main(String[] args) throws Exception {
System.out.println("Generated: " + generate());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void render(PlacedFragment fragment,

if (payload.fillPaint() == null && payload.strokePaint() == null) {
PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(),
payload.dashPattern(),
payload.dashPattern(), payload.lineCap(), payload.lineJoin(),
s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments()));
return;
}
Expand Down Expand Up @@ -83,11 +83,12 @@ public void render(PlacedFragment fragment,
payload.strokePaint(), resources, x, y, width, height));
stream.setLineWidth((float) payload.stroke().width());
PdfShapeGeometry.applyDashPattern(stream, payload.dashPattern());
PdfShapeGeometry.applyStrokeStyle(stream, payload.lineCap(), payload.lineJoin());
PdfShapeGeometry.addPathSegments(stream, x, y, width, height, payload.segments());
stream.stroke();
} else if (hasStrokeWidth && payload.stroke().strokeColor() != null) {
PdfShapeGeometry.fillAndStrokePath(stream, null, payload.stroke(),
payload.dashPattern(),
payload.dashPattern(), payload.lineCap(), payload.lineJoin(),
s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments()));
}
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.demcha.compose.document.backend.fixed.pdf.handlers;

import com.demcha.compose.document.style.DocumentDashPattern;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentLineJoin;
import com.demcha.compose.document.style.DocumentPathSegment;
import com.demcha.compose.document.style.ShapePoint;
import com.demcha.compose.engine.components.content.shape.Stroke;
Expand Down Expand Up @@ -43,6 +45,21 @@ static void fillAndStrokePath(PDPageContentStream stream,
Stroke stroke,
DocumentDashPattern dashPattern,
PathEmitter path) throws IOException {
fillAndStrokePath(stream, fillColor, stroke, dashPattern, null, null, path);
}

/**
* Variant with explicit stroke cap and join styles. {@code null} (or the
* PDF defaults {@code BUTT} / {@code MITER}) emits no extra operators, so
* every existing call site stays byte-identical.
*/
static void fillAndStrokePath(PDPageContentStream stream,
Color fillColor,
Stroke stroke,
DocumentDashPattern dashPattern,
DocumentLineCap lineCap,
DocumentLineJoin lineJoin,
PathEmitter path) throws IOException {
boolean hasFill = fillColor != null;
boolean hasStroke = stroke != null
&& stroke.strokeColor() != null
Expand All @@ -58,6 +75,7 @@ static void fillAndStrokePath(PDPageContentStream stream,
stream.setStrokingColor(stroke.strokeColor().color());
stream.setLineWidth((float) stroke.width());
applyDashPattern(stream, dashPattern);
applyStrokeStyle(stream, lineCap, lineJoin);
}
if (hasFill) {
PdfAlphaSupport.applyFillAlpha(stream, fillColor);
Expand All @@ -76,6 +94,21 @@ static void fillAndStrokePath(PDPageContentStream stream,
}
}

/**
* Emits line cap / join operators only when they differ from the PDF
* defaults, keeping default-styled output byte-identical.
*/
static void applyStrokeStyle(PDPageContentStream stream,
DocumentLineCap lineCap,
DocumentLineJoin lineJoin) throws IOException {
if (lineCap != null && lineCap != DocumentLineCap.BUTT) {
stream.setLineCapStyle(lineCap.pdfCode());
}
if (lineJoin != null && lineJoin != DocumentLineJoin.MITER) {
stream.setLineJoinStyle(lineJoin.pdfCode());
}
}

/**
* Appends a closed polygon path to the stream. Normalized vertices (see
* {@link ShapePoint}) are scaled into the {@code [x, x+width] × [y, y+height]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
// each session sees the warning at least once.
private final AtomicBoolean shapeContainerWarned = new AtomicBoolean(false);
private final AtomicBoolean chartWarned = new AtomicBoolean(false);
// Geometry-only node kinds already warned about this export pass.
private final java.util.Set<String> warnedNodeKinds =
java.util.concurrent.ConcurrentHashMap.newKeySet();

/**
* Creates a DOCX semantic backend.
Expand All @@ -86,6 +89,7 @@ public String name() {
public byte[] export(DocumentGraph graph, SemanticExportContext context) throws Exception {
shapeContainerWarned.set(false);
chartWarned.set(false);
warnedNodeKinds.clear();
try (XWPFDocument document = new XWPFDocument()) {
applyPageGeometry(document, context.canvas());
applyOutputOptions(document, context.outputOptions());
Expand Down Expand Up @@ -147,14 +151,31 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio
writeChartFallback(document, chart);
} else if (node instanceof com.demcha.compose.document.node.ListNode list) {
writeList(document, list);
} else if (node instanceof ContainerNode || node instanceof SectionNode) {
} else if (node instanceof ContainerNode || node instanceof SectionNode
|| node instanceof com.demcha.compose.document.node.LayerStackNode
|| node instanceof com.demcha.compose.document.node.CanvasLayerNode) {
// Overlay/positioned wrappers have no DOCX analogue for their
// geometry, but their children can be semantic (text, images) —
// render them sequentially rather than dropping the subtree.
for (DocumentNode child : node.children()) {
writeNode(document, child);
}
} else {
// Geometry-only node kinds (line, ellipse, shape, path, polygon,
// barcode) have no semantic Word analogue. Warn once per kind so a
// dropped chart-line or icon is visible in the log instead of
// silently missing; authors needing pixel-perfect output use the
// PDF fixed-layout backend.
warnUnsupported(node);
}
}

/** One warning per dropped node kind, deduplicated across the export. */
private void warnUnsupported(DocumentNode node) {
if (warnedNodeKinds.add(node.nodeKind())) {
LOG.warn("DocxSemanticBackend: dropping '{}' node(s) — geometry has no semantic "
+ "Word analogue; use the PDF backend for pixel-perfect output", node.nodeKind());
}
// Unsupported node kinds (line, ellipse, shape, barcode) are silently
// skipped in the semantic export. Authors who need pixel-perfect output
// should use the PDF fixed-layout backend.
}

/**
Expand Down
32 changes: 31 additions & 1 deletion src/main/java/com/demcha/compose/document/dsl/PathBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentDashPattern;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentLineJoin;
import com.demcha.compose.document.style.DocumentPaint;
import com.demcha.compose.document.style.DocumentPathSegment;
import com.demcha.compose.document.style.DocumentStroke;
Expand Down Expand Up @@ -51,6 +53,8 @@ public final class PathBuilder {
private DocumentInsets padding = DocumentInsets.zero();
private DocumentInsets margin = DocumentInsets.zero();
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
private DocumentLineCap lineCap;
private DocumentLineJoin lineJoin;

/**
* Creates a path builder.
Expand Down Expand Up @@ -260,6 +264,32 @@ public PathBuilder dashed(DocumentDashPattern pattern) {
return this;
}

/**
* Sets the stroke end-cap style; {@code null} keeps the PDF default
* ({@link DocumentLineCap#BUTT}).
*
* @param lineCap cap style, or {@code null} for the default
* @return this builder
* @since 1.8.0
*/
public PathBuilder lineCap(DocumentLineCap lineCap) {
this.lineCap = lineCap;
return this;
}

/**
* Sets the stroke corner style; {@code null} keeps the PDF default
* ({@link DocumentLineJoin#MITER}).
*
* @param lineJoin join style, or {@code null} for the default
* @return this builder
* @since 1.8.0
*/
public PathBuilder lineJoin(DocumentLineJoin lineJoin) {
this.lineJoin = lineJoin;
return this;
}

/**
* Sets the path padding.
*
Expand Down Expand Up @@ -294,6 +324,6 @@ public PathBuilder margin(DocumentInsets margin) {
*/
public PathNode build() {
return new PathNode(name, width, height, segments, fillColor, fillPaint,
stroke, strokePaint, padding, margin, dashPattern);
stroke, strokePaint, padding, margin, dashPattern, lineCap, lineJoin);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public List<LayoutFragment> emitFragments(PreparedNode<PathNode> prepared,
strokeGradient,
null,
null,
node.dashPattern())));
node.dashPattern(),
node.lineCap(),
node.lineJoin())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.demcha.compose.document.node.DocumentBookmarkOptions;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.style.DocumentDashPattern;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentLineJoin;
import com.demcha.compose.document.style.DocumentPaint;
import com.demcha.compose.document.style.DocumentPathSegment;
import com.demcha.compose.engine.components.content.shape.Stroke;
Expand Down Expand Up @@ -31,6 +33,8 @@
* @param bookmarkOptions optional fragment-level bookmark metadata
* @param dashPattern dash pattern for the stroke;
* {@link DocumentDashPattern#NONE} is solid
* @param lineCap stroke end-cap style; {@code BUTT} is the PDF default
* @param lineJoin stroke corner style; {@code MITER} is the PDF default
* @author Artem Demchyshyn
* @since 1.8.0
*/
Expand All @@ -42,14 +46,19 @@ public record PathFragmentPayload(
DocumentPaint strokePaint,
DocumentLinkOptions linkOptions,
DocumentBookmarkOptions bookmarkOptions,
DocumentDashPattern dashPattern
DocumentDashPattern dashPattern,
DocumentLineCap lineCap,
DocumentLineJoin lineJoin
) implements PdfSemanticFragmentPayload {
/**
* Copies the segment list defensively and normalizes the dash pattern.
* Copies the segment list defensively and normalizes dash and stroke
* style defaults.
*/
public PathFragmentPayload {
Objects.requireNonNull(segments, "segments");
segments = List.copyOf(segments);
dashPattern = dashPattern == null ? DocumentDashPattern.NONE : dashPattern;
lineCap = lineCap == null ? DocumentLineCap.BUTT : lineCap;
lineJoin = lineJoin == null ? DocumentLineJoin.MITER : lineJoin;
}
}
Loading