From 88b0be3e61a8caacbf9f7267b3ab1d14f80f7a85 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 03:26:36 +0100 Subject: [PATCH] feat(svg): stroke fidelity + colour/unit hardening for the beta SVG reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 22 +- assets/readme/examples/vector-path.pdf | Bin 2350 -> 2584 bytes .../features/shapes/VectorPathExample.java | 34 ++- .../PdfPathFragmentRenderHandler.java | 5 +- .../fixed/pdf/handlers/PdfShapeGeometry.java | 33 +++ .../backend/semantic/DocxSemanticBackend.java | 29 ++- .../compose/document/dsl/PathBuilder.java | 32 ++- .../layout/definitions/PathDefinition.java | 4 +- .../layout/payloads/PathFragmentPayload.java | 13 +- .../compose/document/node/PathNode.java | 44 +++- .../document/style/DocumentLineCap.java | 32 +++ .../document/style/DocumentLineJoin.java | 32 +++ .../compose/document/svg/SvgColors.java | 86 +++++++ .../demcha/compose/document/svg/SvgIcon.java | 57 ++++- .../compose/document/svg/SvgIconReader.java | 197 +++++++++------ .../compose/document/svg/SvgStyles.java | 237 ++++++++++++++++++ .../fixed/pdf/PdfPathStrokeStyleTest.java | 107 ++++++++ .../semantic/DocxGeometryDropTest.java | 75 ++++++ .../compose/document/dsl/PathBuilderTest.java | 28 +++ .../compose/document/svg/SvgIconTest.java | 91 +++++++ .../compose/document/svg/SvgStylesTest.java | 105 ++++++++ 21 files changed, 1162 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentLineCap.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentLineJoin.java create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgColors.java create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgStyles.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathStrokeStyleTest.java create mode 100644 src/test/java/com/demcha/compose/document/backend/semantic/DocxGeometryDropTest.java create mode 100644 src/test/java/com/demcha/compose/document/svg/SvgStylesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ce1ba8..07796361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf index 0ab247f33f5a1926168ec58cb623a1fa60d4e25c..51223b720069294a0a9ab1a3798980682b4562dd 100644 GIT binary patch delta 2220 zcmbu%`9ISS0|0QjX3j{Cxp@{!hF!+KZpw0h<;Xqc4$Yb4vk+qPER-`eggMg4u`pj( z$u;CE6qUYM`;sFe&-45Z&+GmByGg82{NH#kBpROgl1~LFbG8nbMGJOMG#63`;{`bzXlRfT8Rx})<0kR6t@<_gm7C~&(%T3eaGNB0*luqsN+$ZBg!Lv(H?}WL ze>Y92na&=EP%-Jt5QT@ZIE%KrUCC!<=$2C#Yk}ZUjq2TS{dImT>kAh`e4thCFGNRi z?6R<^SP$Y*!?mDUp+Q&_Y-52}jdt_TEc~%2;B9f2jA%CjXWIC@P+;U4)p3e}_}-mc zJfl0xS;{=mJ+3K&g3AybJ_$Q>_BgP#OXf;5iK?j z4M#qHG{MmO!R~eWF;Rr1gu|apdn|TtkN8sTs#U%y;$rt%(TrW*E^!L7UOb!wRfDHQ z(ij^4eY;6Pp91$n>8#u%h2E8>iQZqMO#rDE{TgP0HHnN&4$lK0yB6Ms1CXu1UV8iL+M zx_&NLDxsm1=ekspIcGf4uUsO^c(^77MY|7F8d z0uZ0^BiPz*OGdbw%ce9qWxxO@`g`bel<8?nBp&rt6nq9OoAq-ROc;MaI}{^HVu zv};HQUnw<~@krFG+q#j$6|4nu7b#v-)01FVl>G~5c-9>Wb zdb%%Uszod&D@Q`y^4vwmnc=~FEeTf(?$|F@(CW*~A^HLmsFy))Qp@&?bOVkYB4ci_ zlJ0BB(8N!S0DlsU?L);9vKJ|1sW;9Z-6Ikcm@(A`6|Q|wCdFZCavg>DF1A8CNn1K> zxQJX2h;5z9#84ufwnXUW3fALX(x|_h^h*Qfv84&i>+pS+uzWXwXE+YpJkVFt9_U~; zWuhIxQ>m<5Mv5tX_H`F7skk+N{8||pAoJCDc*h9%B`_E$AF8%?S2T=9Jl;+h#*m2p zuC5*6c`Tg3cRoggB*xp?{Gq;)*RyJYTY4c`r9Te6g%NttP&ekfUO80L|1nhG;rfL@ zI}ic7l2s;W(9SrRCJCq){v_V=Bpbh0KxJ+XfG$3v@7#d~FIGks?(N|mkB+x3>|_+v z!ho^Gy$Cr-@tB`V*A!GPu3v`&PZ||* zKq0z}WE_zv{ABfV!qyt}TXSfd)vk}ne5v~RQzD^7*c0s}O~uQyE25i<&YIEk>Kw>i ztplX48lv2MTBamOmDg!vQ?lIq<;mg_5HoyqX^!0-k+nd3fH~xtcyi`sTg>hx<$Ls3 zW}S57vF>3@cQZM5?p1;PW?sqC-&=%{YrS2gB$?d!*3+ZGg!~0q^p%VzHDXx2GquVs zT;Ml6wsq^F-5&w1%ZamJo)@k>!Y8Isa9VF{m2WIrCCOg4v#3mkad_2;W91zBVW1-R z8xB&seTOvn&v|E1%S!(C_w@!6rNdmh?m&3hjg}cf#qJTYA}Ic|APpXW)6? zc+7xcS?#mO1;0|rUC=QulG3JX8aGYmwa-GopVK_&720UOE>QmH=3EHq_8TbZk1odz zr|a33KHkz2en{N&m|BKaZC{NESB%irRH~SnRuiW-PGYj+1yoaWrrir9vNvh)OLT)U zpF0n+d^IGzQD45s{nwW-S1!y*_KL9TfkD7Y5)$NM0}sNz)!gQVJ;tm%gc$Dsn6wn4 zb2ulJeojn#yY2AF$%sh?i?9%fN?8>ur=1F@jF&%@HC0NU#VD*&Y7-w5AATStR#Y9y z#;W)nJ-QQXHmr*4DGkb=I)=0r0ah3*MdQXrCQS9<_u-&zWk}^M{M`C*En6u$3-FEO zGPuz`^GBG@NC4CXd&^dp?MMwd71QhOaf5a!I8Ztynse&1ruq;XXXZ3{R!>>7boE{B z_qRWkPk9~~zCT+XJN16%X4#^P-uuAF%81#&Yi99@O4*;Qb!ciWqhKaa=L^lwhrANe zJwE4Hn>$`JW7OT)Toz(fK7J$yCX;-eYSr|@3SYumW9UD%6LRWwaN6;Y%uc`oIByt) z1yE+D7z_qwW~^gwYKAZ|Mxqc11lmjsZj9E_(KR;JMIdyw(IzGsv>D3czqBwG<~pWG z3<_zYjWkE=8Yp7_pMv{;M_xML^-G$1sqp{bO9BUs{$5pH+n}*-&ge$ fcSJ-9T2Y-9ymhz)@_51W{5l8(KNM delta 1999 zcmbu6`8yMi1IJx0=Ez({xebjNvnydU%ZHKsj&kK{YmP?6XQj+7=P0op@zuc`l{4bA z+(*vQRw+KCIm${>^6mRKe1CX9&+B>r@Os|Q^Da@Sm&*+jg~1R7_a(HcWnS1zen_bo ze6P-6ii=K$7Wn)TXY*}R-!n0GAY1eDWDRZ>ESTNkZB~VJ>m4NWPN3O^`su&^`n#IJ z{q)M^j(z%zm^-8m&NAXmUTrCIW9!rJz7Fo*?RWJx+_x!%zrUM*zkFuMzNx4vBoOFO z`mmwe-m~&P#077f@-X)Zb&H;?Gip6y=S@)289FWhM6;_Ywx%lUlAS!s|D%<4eL|>U zG*0m74X0I|BM6_`T{xiLLcW_^TUhs@0KRn6zd`j%xg}!GJL$bI|7&SxWADrwI#tNn z5y+CHgjOB_aLWi2-f^X7{2F_`_PNn#G7snEIof+v$*!%hJT$1qm z2wc_~!Bl2|^2TVCi{ag$UwdWvRCp!}D|P0^AR02eN)yFeLIOcsbt;9-`MD$3B9Vhr zIuDgx2T&r_GPbf*)vZu=_TVsiXX$s@HS!}(OmZpi1RHIP$#O80-G|SGP8-@8{&n1i zmE)b}3!O=x5;dWHr7kCk0o1wU1O2Oqy|2x6X0qIvh3l(1w}|QGl1v~i6}($HBhPJF zi+$acmhBsy5r$uBJHO}fLP%plZaT8PXA|N@um4WJ1?D+KB48N&+V?w(nwY~DDZ`}YT zZC5YGgkpl~c2|y^tCe!q=wZ3#^cW$pUdOPaET9tqxSz@pqD6nzar<|zju_@Xqp@*z zEFi)g#HJQ&XfG;8_j=46ym&kgZ?PKfGPxN^tTIp#hH8%?eC=6q7GXAxBytaQHU8N z`XwuWT%HYa&YAFmBSsD)T7p_;CxJUK{9K#l5*0jcLidTSr zFS7HwxH;0V#n6Z{{ynj`n|o|ZAlZLbas2bpu2H@Q;#ywV3*Ee+<=_){t6ZpjW&}ln zs(Y)(!uSCzJFrm$asdLdCNwLjbBV7Eo5l4ZTl6Xc&|7PoBH?U87jvg%hSHZ|KU8*8 zlKhhL7g4~t&aNH-`;xg)Skfp0Ph_A6 z15w%rs{AknDlR90mWFsKJrz(MPpxw#6AjT)&cZlkw9t_&xDTtpq6U+p*~UB82gN1dl0s%H=*E5iH+hxKV%@;|2A{xVa^mDhp$KICHg+8EIP4sZA+zv=3zn#<(`@%9sBTow&#T5#=0gA$if*F7fmGlNG`>W`ONZxQU zSVP>I_gVy>*<&xn7gECSUzE;wHjvZ`=VO3pc#_^ey)#8SUwA8;Y74ejm_+GRVe`>+Bpb)6KAxj- z_FQ+Eb)q13qJD~MKxxV!V7Oc%4z-WBycPI+xTa^TPfo|5?H%cH%+O{WVuH&hIj{j! z;9`ZU?K|7q=FbebZi{DSVZ;OTmQPb<)qR%mB14hmU-FvPVmZ>cxPG7Zl4YC3he25O=w>0qG?G zFJPEM2y@h)&m2-QDD`dHQ`|?v+j=WUCiQGQL;Tmb5(gg|AsuRx7^$Z@T`|Rn*x7qa zF7%*XwY8z{v?4=#n2)kdq0jPrSKZO7pDx_}kG page @@ -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") @@ -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()); } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java index 9ce9ae7e..2d41516a 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java @@ -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; } @@ -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 { diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java index f11d32b9..49a0499f 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java @@ -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; @@ -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 @@ -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); @@ -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]} diff --git a/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java b/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java index 310b0743..94ff939f 100644 --- a/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java @@ -70,6 +70,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // 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 warnedNodeKinds = + java.util.concurrent.ConcurrentHashMap.newKeySet(); /** * Creates a DOCX semantic backend. @@ -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()); @@ -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. } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java index 6abdf682..1183e85d 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -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; @@ -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. @@ -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. * @@ -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); } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java index 2dc33d35..8434185c 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java @@ -90,6 +90,8 @@ public List emitFragments(PreparedNode prepared, strokeGradient, null, null, - node.dashPattern()))); + node.dashPattern(), + node.lineCap(), + node.lineJoin()))); } } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java index 902b96e0..8f62b990 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java @@ -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; @@ -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 */ @@ -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; } } diff --git a/src/main/java/com/demcha/compose/document/node/PathNode.java b/src/main/java/com/demcha/compose/document/node/PathNode.java index 8f8e29bd..7cd8191b 100644 --- a/src/main/java/com/demcha/compose/document/node/PathNode.java +++ b/src/main/java/com/demcha/compose/document/node/PathNode.java @@ -3,6 +3,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; @@ -42,6 +44,10 @@ * @param margin outer margin * @param dashPattern dash pattern for the stroke; defaults to * {@link DocumentDashPattern#NONE} (solid) + * @param lineCap stroke end-cap style; defaults to + * {@link DocumentLineCap#BUTT} (the PDF default) + * @param lineJoin stroke corner style; defaults to + * {@link DocumentLineJoin#MITER} (the PDF default) * @author Artem Demchyshyn * @since 1.8.0 */ @@ -56,7 +62,9 @@ public record PathNode( DocumentPaint strokePaint, DocumentInsets padding, DocumentInsets margin, - DocumentDashPattern dashPattern + DocumentDashPattern dashPattern, + DocumentLineCap lineCap, + DocumentLineJoin lineJoin ) implements DocumentNode { /** * Validates dimensions, the segment list, and the paint pairing; @@ -78,6 +86,8 @@ public record PathNode( padding = padding == null ? DocumentInsets.zero() : padding; margin = margin == null ? DocumentInsets.zero() : margin; dashPattern = dashPattern == null ? DocumentDashPattern.NONE : dashPattern; + lineCap = lineCap == null ? DocumentLineCap.BUTT : lineCap; + lineJoin = lineJoin == null ? DocumentLineJoin.MITER : lineJoin; if (width <= 0 || Double.isNaN(width) || Double.isInfinite(width)) { throw new IllegalArgumentException("width must be finite and positive: " + width); } @@ -114,7 +124,37 @@ public PathNode(String name, DocumentInsets margin, DocumentDashPattern dashPattern) { this(name, width, height, segments, fillColor, null, stroke, null, - padding, margin, dashPattern); + padding, margin, dashPattern, null, null); + } + + /** + * Compatibility constructor with paints but default caps and joins. + * + * @param name node name + * @param width resolved box width + * @param height resolved box height + * @param segments normalized path segments + * @param fillColor optional fill colour + * @param fillPaint optional gradient fill + * @param stroke optional outline stroke + * @param strokePaint optional gradient stroke paint + * @param padding inner padding + * @param margin outer margin + * @param dashPattern dash pattern for the stroke + */ + public PathNode(String name, + double width, + double height, + List segments, + DocumentColor fillColor, + DocumentPaint fillPaint, + DocumentStroke stroke, + DocumentPaint strokePaint, + DocumentInsets padding, + DocumentInsets margin, + DocumentDashPattern dashPattern) { + this(name, width, height, segments, fillColor, fillPaint, stroke, strokePaint, + padding, margin, dashPattern, null, null); } @Override diff --git a/src/main/java/com/demcha/compose/document/style/DocumentLineCap.java b/src/main/java/com/demcha/compose/document/style/DocumentLineCap.java new file mode 100644 index 00000000..3b6dbd77 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentLineCap.java @@ -0,0 +1,32 @@ +package com.demcha.compose.document.style; + +/** + * Line-cap style for open stroke ends, mirroring the PDF cap vocabulary + * (and SVG's {@code stroke-linecap}). + * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public enum DocumentLineCap { + /** Squared-off end exactly at the endpoint (PDF cap 0, the default). */ + BUTT(0), + /** Semicircular end centred on the endpoint (PDF cap 1). */ + ROUND(1), + /** Squared-off end projecting half a line width past the endpoint (PDF cap 2). */ + SQUARE(2); + + private final int pdfCode; + + DocumentLineCap(int pdfCode) { + this.pdfCode = pdfCode; + } + + /** + * Returns the PDF line-cap operator value. + * + * @return PDF cap style code (0–2) + */ + public int pdfCode() { + return pdfCode; + } +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentLineJoin.java b/src/main/java/com/demcha/compose/document/style/DocumentLineJoin.java new file mode 100644 index 00000000..ec24c883 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentLineJoin.java @@ -0,0 +1,32 @@ +package com.demcha.compose.document.style; + +/** + * Line-join style for stroke corners, mirroring the PDF join vocabulary + * (and SVG's {@code stroke-linejoin}). + * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public enum DocumentLineJoin { + /** Sharp mitred corner (PDF join 0, the default). */ + MITER(0), + /** Rounded corner (PDF join 1). */ + ROUND(1), + /** Flattened corner (PDF join 2). */ + BEVEL(2); + + private final int pdfCode; + + DocumentLineJoin(int pdfCode) { + this.pdfCode = pdfCode; + } + + /** + * Returns the PDF line-join operator value. + * + * @return PDF join style code (0–2) + */ + public int pdfCode() { + return pdfCode; + } +} diff --git a/src/main/java/com/demcha/compose/document/svg/SvgColors.java b/src/main/java/com/demcha/compose/document/svg/SvgColors.java new file mode 100644 index 00000000..91ab3f2b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/svg/SvgColors.java @@ -0,0 +1,86 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.document.style.DocumentColor; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * The CSS/SVG named-colour table (the 147 keywords every browser ships), + * resolved case-insensitively. Backing data is one compact + * {@code name:rrggbb} string per colour, parsed once into an immutable map. + */ +final class SvgColors { + + private static final Map BY_NAME = build(); + + private SvgColors() { + } + + /** + * Looks up a CSS named colour. + * + * @param name colour keyword in any case + * @return the colour, or {@code null} when the name is not a CSS keyword + */ + static DocumentColor named(String name) { + return BY_NAME.get(name.toLowerCase(Locale.ROOT)); + } + + private static Map build() { + String[] table = { + "aliceblue:f0f8ff", "antiquewhite:faebd7", "aqua:00ffff", "aquamarine:7fffd4", + "azure:f0ffff", "beige:f5f5dc", "bisque:ffe4c4", "black:000000", + "blanchedalmond:ffebcd", "blue:0000ff", "blueviolet:8a2be2", "brown:a52a2a", + "burlywood:deb887", "cadetblue:5f9ea0", "chartreuse:7fff00", "chocolate:d2691e", + "coral:ff7f50", "cornflowerblue:6495ed", "cornsilk:fff8dc", "crimson:dc143c", + "cyan:00ffff", "darkblue:00008b", "darkcyan:008b8b", "darkgoldenrod:b8860b", + "darkgray:a9a9a9", "darkgreen:006400", "darkgrey:a9a9a9", "darkkhaki:bdb76b", + "darkmagenta:8b008b", "darkolivegreen:556b2f", "darkorange:ff8c00", + "darkorchid:9932cc", "darkred:8b0000", "darksalmon:e9967a", + "darkseagreen:8fbc8f", "darkslateblue:483d8b", "darkslategray:2f4f4f", + "darkslategrey:2f4f4f", "darkturquoise:00ced1", "darkviolet:9400d3", + "deeppink:ff1493", "deepskyblue:00bfff", "dimgray:696969", "dimgrey:696969", + "dodgerblue:1e90ff", "firebrick:b22222", "floralwhite:fffaf0", + "forestgreen:228b22", "fuchsia:ff00ff", "gainsboro:dcdcdc", "ghostwhite:f8f8ff", + "gold:ffd700", "goldenrod:daa520", "gray:808080", "green:008000", + "greenyellow:adff2f", "grey:808080", "honeydew:f0fff0", "hotpink:ff69b4", + "indianred:cd5c5c", "indigo:4b0082", "ivory:fffff0", "khaki:f0e68c", + "lavender:e6e6fa", "lavenderblush:fff0f5", "lawngreen:7cfc00", + "lemonchiffon:fffacd", "lightblue:add8e6", "lightcoral:f08080", + "lightcyan:e0ffff", "lightgoldenrodyellow:fafad2", "lightgray:d3d3d3", + "lightgreen:90ee90", "lightgrey:d3d3d3", "lightpink:ffb6c1", + "lightsalmon:ffa07a", "lightseagreen:20b2aa", "lightskyblue:87cefa", + "lightslategray:778899", "lightslategrey:778899", "lightsteelblue:b0c4de", + "lightyellow:ffffe0", "lime:00ff00", "limegreen:32cd32", "linen:faf0e6", + "magenta:ff00ff", "maroon:800000", "mediumaquamarine:66cdaa", + "mediumblue:0000cd", "mediumorchid:ba55d3", "mediumpurple:9370db", + "mediumseagreen:3cb371", "mediumslateblue:7b68ee", + "mediumspringgreen:00fa9a", "mediumturquoise:48d1cc", + "mediumvioletred:c71585", "midnightblue:191970", "mintcream:f5fffa", + "mistyrose:ffe4e1", "moccasin:ffe4b5", "navajowhite:ffdead", "navy:000080", + "oldlace:fdf5e6", "olive:808000", "olivedrab:6b8e23", "orange:ffa500", + "orangered:ff4500", "orchid:da70d6", "palegoldenrod:eee8aa", + "palegreen:98fb98", "paleturquoise:afeeee", "palevioletred:db7093", + "papayawhip:ffefd5", "peachpuff:ffdab9", "peru:cd853f", "pink:ffc0cb", + "plum:dda0dd", "powderblue:b0e0e6", "purple:800080", "rebeccapurple:663399", + "red:ff0000", "rosybrown:bc8f8f", "royalblue:4169e1", "saddlebrown:8b4513", + "salmon:fa8072", "sandybrown:f4a460", "seagreen:2e8b57", "seashell:fff5ee", + "sienna:a0522d", "silver:c0c0c0", "skyblue:87ceeb", "slateblue:6a5acd", + "slategray:708090", "slategrey:708090", "snow:fffafa", "springgreen:00ff7f", + "steelblue:4682b4", "tan:d2b48c", "teal:008080", "thistle:d8bfd8", + "tomato:ff6347", "turquoise:40e0d0", "violet:ee82ee", "wheat:f5deb3", + "white:ffffff", "whitesmoke:f5f5f5", "yellow:ffff00", "yellowgreen:9acd32"}; + Map map = new HashMap<>(table.length * 2); + for (String entry : table) { + int colon = entry.indexOf(':'); + String hex = entry.substring(colon + 1); + map.put(entry.substring(0, colon), DocumentColor.rgb( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16))); + } + return Map.copyOf(map); + } +} diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java index a24d33db..a124707e 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java @@ -4,6 +4,9 @@ import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.style.DocumentColor; +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.DocumentStroke; @@ -149,9 +152,22 @@ public LayerStackNode node(double width) { throw new IllegalArgumentException("icon width must be finite and positive: " + width); } double height = width / aspectRatio(); + // Stroke widths and dash lengths live in SVG user units on the + // layers; they scale with the icon like every other dimension. + double scale = width / sourceWidth; List stack = new ArrayList<>(layers.size()); for (int i = 0; i < layers.size(); i++) { Layer layer = layers.get(i); + DocumentStroke stroke = layer.stroke() == null ? null + : DocumentStroke.of(layer.stroke().color(), layer.stroke().width() * scale); + DocumentDashPattern dash = null; + if (!layer.dashArray().isEmpty()) { + double[] scaled = new double[layer.dashArray().size()]; + for (int s = 0; s < scaled.length; s++) { + scaled[s] = layer.dashArray().get(s) * scale; + } + dash = DocumentDashPattern.of(scaled); + } stack.add(new LayerStackNode.Layer(new PathNode( "SvgLayer" + i, width, @@ -159,11 +175,13 @@ public LayerStackNode node(double width) { layer.geometry().segments(), layer.fill(), layer.fillPaint(), - layer.stroke(), + stroke, layer.strokePaint(), null, null, - null))); + dash, + layer.lineCap(), + layer.lineJoin()))); } return new LayerStackNode("SvgIcon", stack, null, null); } @@ -172,25 +190,36 @@ public LayerStackNode node(double width) { * One drawable layer: normalized geometry plus its resolved paint. * Gradient paints, when present, win over the flat colours; the flat * colours stay populated as the degradation target for backends that - * cannot render gradients. + * cannot render gradients. Stroke width (inside {@code stroke}) and the + * dash lengths are in SVG user units — {@link #node(double)} + * scales them to points together with the geometry. * * @param geometry normalized path geometry (shared icon frame) * @param fill fill colour, or {@code null} for no fill * @param fillPaint gradient fill, or {@code null} for flat / no fill - * @param stroke outline stroke, or {@code null} for no stroke + * @param stroke outline stroke (width in user units), or {@code null} * @param strokePaint gradient stroke paint, or {@code null} for flat + * @param lineCap stroke end-cap style; never {@code null} (BUTT default) + * @param lineJoin stroke corner style; never {@code null} (MITER default) + * @param dashArray stroke dash lengths in user units; empty for solid * @since 1.8.0 */ public record Layer(SvgPath geometry, DocumentColor fill, DocumentPaint fillPaint, DocumentStroke stroke, - DocumentPaint strokePaint) { + DocumentPaint strokePaint, + DocumentLineCap lineCap, + DocumentLineJoin lineJoin, + List dashArray) { /** - * Validates the geometry reference. + * Validates the geometry reference and normalizes style defaults. */ public Layer { Objects.requireNonNull(geometry, "geometry"); + lineCap = lineCap == null ? DocumentLineCap.BUTT : lineCap; + lineJoin = lineJoin == null ? DocumentLineJoin.MITER : lineJoin; + dashArray = dashArray == null ? List.of() : List.copyOf(dashArray); } /** @@ -201,7 +230,21 @@ public record Layer(SvgPath geometry, * @param stroke outline stroke, or {@code null} */ public Layer(SvgPath geometry, DocumentColor fill, DocumentStroke stroke) { - this(geometry, fill, null, stroke, null); + this(geometry, fill, null, stroke, null, null, null, null); + } + + /** + * Compatibility constructor with paints but default stroke styling. + * + * @param geometry normalized path geometry + * @param fill fill colour, or {@code null} + * @param fillPaint gradient fill, or {@code null} + * @param stroke outline stroke, or {@code null} + * @param strokePaint gradient stroke paint, or {@code null} + */ + public Layer(SvgPath geometry, DocumentColor fill, DocumentPaint fillPaint, + DocumentStroke stroke, DocumentPaint strokePaint) { + this(geometry, fill, fillPaint, stroke, strokePaint, null, null, null); } } } diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java index 0bc8b309..20029e4f 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -1,8 +1,12 @@ package com.demcha.compose.document.svg; import com.demcha.compose.document.style.DocumentColor; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -14,8 +18,8 @@ import java.io.StringReader; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Internal DOM walker behind {@link SvgIcon#parse(String)}: secure XML setup @@ -29,6 +33,17 @@ */ final class SvgIconReader { + private static final Logger LOG = LoggerFactory.getLogger(SvgIconReader.class); + + /** + * Shape elements that carry visible content this reader does not render — + * worth one warning per kind rather than a silent drop. Containers + * ({@code defs}, {@code g}, {@code symbol}, {@code metadata}…) are not + * here: they hold no direct geometry, so skipping them loses nothing. + */ + private static final Set DROPS_CONTENT = Set.of( + "text", "tspan", "textPath", "image", "use", "foreignObject"); + private SvgIconReader() { } @@ -41,12 +56,15 @@ static SvgIcon read(String svgXml) { Map gradients = SvgGradients.collect(root); List layers = new ArrayList<>(); + SkipTally skipped = new SkipTally(); walk(root, identity(), - new Paint(new PaintValue(DocumentColor.rgb(0, 0, 0), null), PaintValue.NONE, 1.0), - box, gradients, layers); + new Paint(new PaintValue(DocumentColor.rgb(0, 0, 0), null), PaintValue.NONE, 1.0, + DocumentLineCap.BUTT, DocumentLineJoin.MITER, List.of()), + box, gradients, skipped, layers); if (layers.isEmpty()) { throw new IllegalArgumentException("SVG document contains no drawable geometry"); } + skipped.flush(); return new SvgIcon(layers, box[2], box[3]); } @@ -110,25 +128,9 @@ private static void requirePositive(double width, double height, String source) // Tree walk // ------------------------------------------------------------------ - /** - * One inheritable paint slot: a flat colour, a gradient element awaiting - * geometry context, or nothing. - */ - private record PaintValue(DocumentColor color, Element gradient) { - static final PaintValue NONE = new PaintValue(null, null); - - boolean visible() { - return color != null || gradient != null; - } - } - - /** Inherited paint state: SVG fills default to black, strokes to none. */ - private record Paint(PaintValue fill, PaintValue stroke, double strokeWidth) { - } - private static void walk(Element element, double[] transform, Paint inherited, double[] box, Map gradients, - List out) { + SkipTally skipped, List out) { Paint paint = stylize(element, inherited, gradients); double[] matrix = compose(transform, element.getAttribute("transform")); @@ -174,8 +176,13 @@ private static void walk(Element element, double[] transform, Paint inherited, stroke = DocumentStroke.of(paint.stroke().color(), paint.strokeWidth()); } } - out.add(new SvgIcon.Layer(geometry, fillColor, fillPaint, stroke, strokePaint)); + out.add(new SvgIcon.Layer(geometry, fillColor, fillPaint, stroke, strokePaint, + paint.lineCap(), paint.lineJoin(), paint.dashArray())); } + } else if (DROPS_CONTENT.contains(name)) { + // A shape kind we deliberately don't render — count it so the icon + // surfaces one warning per kind instead of silently losing pixels. + skipped.note(name); } // Containers (svg, g, unknown wrappers) recurse; defs and metadata @@ -185,20 +192,19 @@ private static void walk(Element element, double[] transform, Paint inherited, for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); if (child instanceof Element childElement) { - walk(childElement, matrix, paint, box, gradients, out); + walk(childElement, matrix, paint, box, gradients, skipped, out); } } } } - // ------------------------------------------------------------------ - // Styling - // ------------------------------------------------------------------ - private static Paint stylize(Element element, Paint inherited, Map gradients) { PaintValue fill = inherited.fill(); PaintValue stroke = inherited.stroke(); double strokeWidth = inherited.strokeWidth(); + DocumentLineCap lineCap = inherited.lineCap(); + DocumentLineJoin lineJoin = inherited.lineJoin(); + List dashArray = inherited.dashArray(); String fillAttr = attrOrStyle(element, "fill"); if (fillAttr != null) { @@ -210,12 +216,30 @@ private static Paint stylize(Element element, Paint inherited, Map gradients) { String id = SvgGradients.urlId(value); @@ -232,6 +256,10 @@ private static PaintValue paintValue(String value, PaintValue current, return color == null ? PaintValue.NONE : new PaintValue(color, null); } + // ------------------------------------------------------------------ + // Styling + // ------------------------------------------------------------------ + private static String attrOrStyle(Element element, String property) { String attr = element.getAttribute(property).trim(); if (!attr.isEmpty()) { @@ -247,51 +275,16 @@ private static String attrOrStyle(Element element, String property) { return null; } + /** + * Resolves an SVG paint colour through the shared {@link SvgStyles} + * grammar (hex incl. alpha, {@code rgb()}/{@code rgba()}, CSS names, + * {@code none}, {@code currentColor}). Stays here as the package entry + * point {@link SvgGradients} also calls. + */ static DocumentColor color(String value, DocumentColor current) { - String v = value.trim().toLowerCase(Locale.ROOT); - if (v.equals("none")) { - return null; - } - if (v.equals("currentcolor") || v.equals("inherit")) { - return current; - } - if (v.startsWith("#")) { - String hex = v.substring(1); - if (hex.length() == 3) { - hex = "" + hex.charAt(0) + hex.charAt(0) - + hex.charAt(1) + hex.charAt(1) - + hex.charAt(2) + hex.charAt(2); - } - if (hex.length() == 6) { - return DocumentColor.rgb( - Integer.parseInt(hex.substring(0, 2), 16), - Integer.parseInt(hex.substring(2, 4), 16), - Integer.parseInt(hex.substring(4, 6), 16)); - } - } - if (v.startsWith("rgb(") && v.endsWith(")")) { - String[] parts = v.substring(4, v.length() - 1).split(","); - if (parts.length == 3) { - return DocumentColor.rgb( - Integer.parseInt(parts[0].trim()), - Integer.parseInt(parts[1].trim()), - Integer.parseInt(parts[2].trim())); - } - } - if (v.equals("black")) { - return DocumentColor.rgb(0, 0, 0); - } - if (v.equals("white")) { - return DocumentColor.rgb(255, 255, 255); - } - throw new IllegalArgumentException( - "unsupported SVG colour '" + value + "' — use #hex, rgb(r,g,b), none, or currentColor"); + return SvgStyles.color(value, current); } - // ------------------------------------------------------------------ - // Shape lowering (synthesized path data through the tested parser) - // ------------------------------------------------------------------ - private static String rectToPath(Element rect) { double x = num(rect, "x"); double y = num(rect, "y"); @@ -332,6 +325,10 @@ private static String ellipseToPath(double cx, double cy, double rx, double ry) + " Z"; } + // ------------------------------------------------------------------ + // Shape lowering (synthesized path data through the tested parser) + // ------------------------------------------------------------------ + private static String pointsToPath(String points, boolean close) { String trimmed = points == null ? "" : points.trim(); if (trimmed.isEmpty()) { @@ -345,15 +342,13 @@ private static double num(Element element, String attribute) { return value.isEmpty() ? 0.0 : Double.parseDouble(value); } - // ------------------------------------------------------------------ - // Transforms - // ------------------------------------------------------------------ - static double[] identity() { return new double[]{1, 0, 0, 1, 0, 0}; } - /** Composes {@code transform="…"} ops onto the parent matrix, left to right. */ + /** + * Composes {@code transform="…"} ops onto the parent matrix, left to right. + */ static double[] compose(double[] parent, String transformAttribute) { String attr = transformAttribute == null ? "" : transformAttribute.trim(); if (attr.isEmpty()) { @@ -382,6 +377,10 @@ static double[] compose(double[] parent, String transformAttribute) { return m; } + // ------------------------------------------------------------------ + // Transforms + // ------------------------------------------------------------------ + private static double[] transformOp(String op, String[] args, String source) { double[] v = new double[args.length]; for (int i = 0; i < args.length; i++) { @@ -408,7 +407,9 @@ private static double[] transformOp(String op, String[] args, String source) { }; } - /** SVG matrix composition: result = a × b (b applies first). */ + /** + * SVG matrix composition: result = a × b (b applies first). + */ private static double[] multiply(double[] a, double[] b) { return new double[]{ a[0] * b[0] + a[2] * b[1], @@ -418,4 +419,46 @@ private static double[] multiply(double[] a, double[] b) { a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]}; } + + /** + * One inheritable paint slot: a flat colour, a gradient element awaiting + * geometry context, or nothing. + */ + private record PaintValue(DocumentColor color, Element gradient) { + static final PaintValue NONE = new PaintValue(null, null); + + boolean visible() { + return color != null || gradient != null; + } + } + + /** + * Inherited paint state: SVG fills default to black, strokes to none. + * Stroke style (cap / join / dash) is inheritable too, so it rides here. + */ + private record Paint(PaintValue fill, PaintValue stroke, double strokeWidth, + DocumentLineCap lineCap, DocumentLineJoin lineJoin, + List dashArray) { + } + + /** + * One-warning-per-kind tally for shape elements we deliberately drop + * (text, images, embedded references). Emitted once after the walk so a + * busy icon doesn't flood the log. + */ + private static final class SkipTally { + private final Set kinds = new java.util.LinkedHashSet<>(); + + void note(String kind) { + kinds.add(kind); + } + + void flush() { + if (!kinds.isEmpty()) { + LOG.warn("SvgIcon: skipped unsupported element(s) {} — this icon reader renders " + + "vector geometry only (no text, images, , masks, clips or filters)", + kinds); + } + } + } } diff --git a/src/main/java/com/demcha/compose/document/svg/SvgStyles.java b/src/main/java/com/demcha/compose/document/svg/SvgStyles.java new file mode 100644 index 00000000..b204ced6 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/svg/SvgStyles.java @@ -0,0 +1,237 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentLineJoin; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Presentation-attribute parsing for the icon reader: the SVG colour + * grammar ({@code #rgb[a]} / {@code #rrggbb[aa]} hex, {@code rgb()} / + * {@code rgba()} with numbers or percentages, the CSS named-colour table, + * {@code none}, {@code currentColor}), absolute CSS lengths + * ({@code px} = user units, {@code pt}, {@code in}, {@code mm}, {@code cm}, + * {@code pc}), and the stroke style trio {@code stroke-linecap} / + * {@code stroke-linejoin} / {@code stroke-dasharray}. + * + *

Everything unsupported fails loudly with the supported alternatives + * listed — relative units ({@code em}, {@code %}, viewport units) have no + * deterministic meaning inside an icon frame.

+ */ +final class SvgStyles { + + private SvgStyles() { + } + + // ------------------------------------------------------------------ + // Colours + // ------------------------------------------------------------------ + + /** + * Parses an SVG paint colour. + * + * @param value raw attribute value + * @param current colour {@code currentColor} / {@code inherit} resolve to + * @return the colour, or {@code null} for {@code none} + * @throws IllegalArgumentException for paints outside the icon subset + */ + static DocumentColor color(String value, DocumentColor current) { + String v = value.trim().toLowerCase(Locale.ROOT); + if (v.equals("none")) { + return null; + } + if (v.equals("currentcolor") || v.equals("inherit")) { + return current; + } + if (v.startsWith("#")) { + DocumentColor hex = hexColor(v.substring(1)); + if (hex != null) { + return hex; + } + } + if ((v.startsWith("rgb(") || v.startsWith("rgba(")) && v.endsWith(")")) { + return rgbColor(v); + } + DocumentColor named = SvgColors.named(v); + if (named != null) { + return named; + } + throw new IllegalArgumentException( + "unsupported SVG colour '" + value + + "' — use #hex (3/4/6/8 digits), rgb()/rgba(), a CSS colour name, none, or currentColor"); + } + + private static DocumentColor hexColor(String hex) { + String expanded = switch (hex.length()) { + case 3, 4 -> { + StringBuilder sb = new StringBuilder(hex.length() * 2); + for (int i = 0; i < hex.length(); i++) { + sb.append(hex.charAt(i)).append(hex.charAt(i)); + } + yield sb.toString(); + } + case 6, 8 -> hex; + default -> null; + }; + if (expanded == null) { + return null; + } + DocumentColor color = DocumentColor.rgb( + Integer.parseInt(expanded.substring(0, 2), 16), + Integer.parseInt(expanded.substring(2, 4), 16), + Integer.parseInt(expanded.substring(4, 6), 16)); + if (expanded.length() == 8) { + color = color.withOpacity(Integer.parseInt(expanded.substring(6, 8), 16) / 255.0); + } + return color; + } + + private static DocumentColor rgbColor(String v) { + String inner = v.substring(v.indexOf('(') + 1, v.length() - 1); + String[] parts = inner.split("[,\\s/]+"); + if (parts.length != 3 && parts.length != 4) { + throw new IllegalArgumentException( + "rgb()/rgba() needs three colour channels (plus optional alpha): '" + v + "'"); + } + DocumentColor color = DocumentColor.rgb( + channel(parts[0]), channel(parts[1]), channel(parts[2])); + if (parts.length == 4) { + color = color.withOpacity(alpha(parts[3])); + } + return color; + } + + /** One rgb() channel: 0–255 number or percentage. */ + private static int channel(String value) { + String t = value.trim(); + if (t.endsWith("%")) { + double pct = Math.max(0, Math.min(100, Double.parseDouble(t.substring(0, t.length() - 1)))); + // pct/100*255 keeps 50% exact (127.5 → 128); pct*2.55 drifts to 127. + return (int) Math.round(pct / 100.0 * 255.0); + } + return Math.max(0, Math.min(255, (int) Math.round(Double.parseDouble(t)))); + } + + /** rgba() alpha: 0–1 number or percentage, clamped. */ + private static double alpha(String value) { + String t = value.trim(); + double a = t.endsWith("%") + ? Double.parseDouble(t.substring(0, t.length() - 1)) / 100.0 + : Double.parseDouble(t); + return Math.max(0.0, Math.min(1.0, a)); + } + + // ------------------------------------------------------------------ + // Lengths + // ------------------------------------------------------------------ + + /** + * Parses an absolute CSS length into SVG user units (1 user unit = 1px). + * + * @param value raw attribute value, e.g. {@code "7"}, {@code "2.5px"}, + * {@code "1pt"} + * @param context attribute name for the error message + * @return length in user units + * @throws IllegalArgumentException for relative units + */ + static double length(String value, String context) { + String v = value.trim().toLowerCase(Locale.ROOT); + int unitStart = v.length(); + while (unitStart > 0 && Character.isLetter(v.charAt(unitStart - 1))) { + unitStart--; + } + String unit = v.substring(unitStart); + double number = Double.parseDouble(v.substring(0, unitStart).trim()); + return switch (unit) { + case "", "px" -> number; + case "pt" -> number * (96.0 / 72.0); + case "pc" -> number * 16.0; + case "in" -> number * 96.0; + case "mm" -> number * (96.0 / 25.4); + case "cm" -> number * (96.0 / 2.54); + default -> throw new IllegalArgumentException( + "unsupported unit '" + unit + "' on " + context + "='" + value + + "' — use user units, px, pt, pc, in, mm or cm"); + }; + } + + // ------------------------------------------------------------------ + // Stroke style + // ------------------------------------------------------------------ + + /** + * Parses {@code stroke-linecap}. + * + * @param value raw attribute value + * @return the cap style + * @throws IllegalArgumentException for values outside the SVG enum + */ + static DocumentLineCap lineCap(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "butt" -> DocumentLineCap.BUTT; + case "round" -> DocumentLineCap.ROUND; + case "square" -> DocumentLineCap.SQUARE; + case "inherit" -> null; + default -> throw new IllegalArgumentException( + "unsupported stroke-linecap '" + value + "' — use butt, round, or square"); + }; + } + + /** + * Parses {@code stroke-linejoin}. The SVG2 {@code miter-clip} and + * {@code arcs} values fall back to plain mitres — they only differ + * beyond the mitre limit. + * + * @param value raw attribute value + * @return the join style + * @throws IllegalArgumentException for values outside the SVG enum + */ + static DocumentLineJoin lineJoin(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "miter", "miter-clip", "arcs" -> DocumentLineJoin.MITER; + case "round" -> DocumentLineJoin.ROUND; + case "bevel" -> DocumentLineJoin.BEVEL; + case "inherit" -> null; + default -> throw new IllegalArgumentException( + "unsupported stroke-linejoin '" + value + "' — use miter, round, or bevel"); + }; + } + + /** + * Parses {@code stroke-dasharray} into user-unit lengths. Per SVG, an + * odd-length list repeats doubled, {@code none} (and an all-zero list) + * mean solid, negative values are an error. + * + * @param value raw attribute value + * @return dash lengths in user units; empty list for solid + * @throws IllegalArgumentException for negative entries or bad units + */ + static List dashArray(String value) { + String v = value.trim(); + if (v.isEmpty() || v.equalsIgnoreCase("none")) { + return List.of(); + } + String[] parts = v.split("[\\s,]+"); + List lengths = new ArrayList<>(parts.length * 2); + boolean anyPositive = false; + for (String part : parts) { + double length = length(part, "stroke-dasharray"); + if (length < 0) { + throw new IllegalArgumentException( + "stroke-dasharray entries must be non-negative: '" + value + "'"); + } + anyPositive |= length > 0; + lengths.add(length); + } + if (!anyPositive) { + return List.of(); + } + if (lengths.size() % 2 != 0) { + lengths.addAll(new ArrayList<>(lengths)); + } + return List.copyOf(lengths); + } +} diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathStrokeStyleTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathStrokeStyleTest.java new file mode 100644 index 00000000..74bc1ae4 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathStrokeStyleTest.java @@ -0,0 +1,107 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PathBuilder; +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.DocumentStroke; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSNumber; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that path stroke cap / join styles reach the PDF as the + * {@code J} / {@code j} operators, and that the defaults emit neither — + * keeping default-styled output byte-identical with the pre-style backend. + */ +class PdfPathStrokeStyleTest { + + private static final DocumentColor INK = DocumentColor.rgb(20, 60, 120); + + @TempDir + Path tempDir; + + private Path render(String name, Consumer spec) throws Exception { + Path out = tempDir.resolve(name + ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(160, 100) + .margin(DocumentInsets.of(16)) + .create()) { + document.pageFlow().name("Flow").addPath(spec).build(); + document.buildPdf(); + } + return out; + } + + /** Collects every {@code (operandInt, operator)} pair in the page stream. */ + private static List operatorInts(Path pdf, String operatorName) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + PDPage page = doc.getPage(0); + PDFStreamParser parser = new PDFStreamParser(page); + List hits = new ArrayList<>(); + List operands = new ArrayList<>(); + for (Object token = parser.parseNextToken(); token != null; token = parser.parseNextToken()) { + if (token instanceof COSBase base) { + operands.add(base); + } else if (token instanceof Operator op) { + if (op.getName().equals(operatorName) && !operands.isEmpty() + && operands.get(operands.size() - 1) instanceof COSNumber n) { + hits.add(new int[]{n.intValue()}); + } + operands.clear(); + } + } + return hits; + } + } + + @Test + void roundCapAndJoinEmitTheCorrectOperators() throws Exception { + Path pdf = render("round", p -> p.size(120, 40) + .moveTo(0.0, 0.5).lineTo(0.5, 1.0).lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(INK, 6)) + .lineCap(DocumentLineCap.ROUND) + .lineJoin(DocumentLineJoin.ROUND)); + + assertThat(operatorInts(pdf, "J")).contains(new int[]{1}); + assertThat(operatorInts(pdf, "j")).contains(new int[]{1}); + } + + @Test + void squareCapAndBevelJoinEmitTheCorrectOperators() throws Exception { + Path pdf = render("square", p -> p.size(120, 40) + .moveTo(0.0, 0.5).lineTo(0.5, 1.0).lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(INK, 6)) + .lineCap(DocumentLineCap.SQUARE) + .lineJoin(DocumentLineJoin.BEVEL)); + + assertThat(operatorInts(pdf, "J")).contains(new int[]{2}); + assertThat(operatorInts(pdf, "j")).contains(new int[]{2}); + } + + @Test + void defaultButtMiterEmitNoStyleOperators() throws Exception { + Path pdf = render("default", p -> p.size(120, 40) + .moveTo(0.0, 0.5).lineTo(0.5, 1.0).lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(INK, 6))); + + assertThat(operatorInts(pdf, "J")).isEmpty(); + assertThat(operatorInts(pdf, "j")).isEmpty(); + } +} diff --git a/src/test/java/com/demcha/compose/document/backend/semantic/DocxGeometryDropTest.java b/src/test/java/com/demcha/compose/document/backend/semantic/DocxGeometryDropTest.java new file mode 100644 index 00000000..0bb8a906 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/semantic/DocxGeometryDropTest.java @@ -0,0 +1,75 @@ +package com.demcha.compose.document.backend.semantic; + +import com.demcha.compose.GraphCompose; +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.DocumentStroke; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * DOCX geometry-drop behaviour: overlay/positioned wrappers recurse so their + * semantic children (text, images) survive the Word export, while pure + * geometry leaves (paths, polygons) are dropped without throwing — the + * fixed-layout PDF backend is the path for pixel-perfect geometry. + */ +@DisabledIfSystemProperty(named = "no.poi", matches = "true", + disabledReason = "DocxSemanticBackend requires poi-ooxml") +class DocxGeometryDropTest { + + @Test + void layerStackRecursesSoChildTextSurvives() throws Exception { + List texts = exportTexts(flow -> flow.addLayerStack(stack -> stack + .name("Overlay") + .layer(new com.demcha.compose.document.dsl.PathBuilder() + .name("Backdrop").size(120, 40) + .moveTo(0, 0).lineTo(1, 0).lineTo(0.5, 1).closePath() + .fillColor(DocumentColor.rgb(20, 80, 95)) + .build()) + .layer(new com.demcha.compose.document.dsl.ParagraphBuilder() + .text("Caption over the shape") + .build()))); + + assertThat(texts).contains("Caption over the shape"); + } + + @Test + void standaloneGeometryIsDroppedWithoutThrowing() throws Exception { + // A bare path has no semantic Word analogue; the export completes and + // simply carries no paragraph for it. + List texts = exportTexts(flow -> flow.addPath(path -> path + .name("Wave").size(200, 40) + .moveTo(0, 0.5).curveTo(0.25, 1, 0.75, 0, 1, 0.5) + .stroke(DocumentStroke.of(DocumentColor.rgb(0, 0, 0), 2)))); + + assertThat(texts).allMatch(String::isBlank); + } + + private static List exportTexts( + Consumer author) throws Exception { + byte[] docxBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + var flow = session.dsl().pageFlow().name("Flow"); + author.accept(flow); + flow.build(); + docxBytes = session.export(new DocxSemanticBackend()); + } + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) { + return document.getParagraphs().stream() + .map(XWPFParagraph::getText) + .toList(); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java index 7e765eab..b38043bc 100644 --- a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java @@ -113,6 +113,34 @@ void gradientPaintsFlowThroughToTheNode() { assertThat(node.strokePaint()).isSameAs(axis); } + @Test + void lineCapAndJoinFlowThroughToTheNode() { + PathNode node = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 6.0)) + .lineCap(com.demcha.compose.document.style.DocumentLineCap.ROUND) + .lineJoin(com.demcha.compose.document.style.DocumentLineJoin.BEVEL) + .build(); + + assertThat(node.lineCap()).isEqualTo(com.demcha.compose.document.style.DocumentLineCap.ROUND); + assertThat(node.lineJoin()).isEqualTo(com.demcha.compose.document.style.DocumentLineJoin.BEVEL); + } + + @Test + void defaultCapAndJoinAreThePdfDefaults() { + PathNode node = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(DocumentColor.rgb(0, 0, 0), 2.0)) + .build(); + + assertThat(node.lineCap()).isEqualTo(com.demcha.compose.document.style.DocumentLineCap.BUTT); + assertThat(node.lineJoin()).isEqualTo(com.demcha.compose.document.style.DocumentLineJoin.MITER); + } + @Test void strokePaintWithoutAStrokeFailsAtBuild() { PathBuilder builder = new PathBuilder() diff --git a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java index 75ed817f..87620be6 100644 --- a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -381,4 +381,95 @@ void nodeFormPackagesLayersAtTheRequestedWidth() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("positive"); } + + // ------------------------------------------------------------------ + // Stroke style + scaling + // ------------------------------------------------------------------ + + @Test + void strokeStyleAttributesReachTheLayer() { + SvgIcon icon = SvgIcon.parse(""" + + + + """); + + SvgIcon.Layer layer = icon.layers().get(0); + assertThat(layer.lineCap()).isEqualTo(com.demcha.compose.document.style.DocumentLineCap.ROUND); + assertThat(layer.lineJoin()).isEqualTo(com.demcha.compose.document.style.DocumentLineJoin.BEVEL); + assertThat(layer.dashArray()).containsExactly(4.0, 2.0); + // Stroke width stays in user units on the layer. + assertThat(layer.stroke().width()).isEqualTo(3.0); + } + + @Test + void groupStrokeStyleInheritsToChildren() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + """); + + assertThat(icon.layers().get(0).lineCap()) + .isEqualTo(com.demcha.compose.document.style.DocumentLineCap.SQUARE); + } + + @Test + void nodeFormScalesStrokeWidthAndDashWithTheGeometry() { + // 100-unit frame rendered at 25 pt → 0.25× scale. + SvgIcon icon = SvgIcon.parse(""" + + + + """); + + var path = (com.demcha.compose.document.node.PathNode) icon.node(25).layers().get(0).node(); + assertThat(path.stroke().width()).isCloseTo(2.0, within(1e-9)); + assertThat(path.dashPattern().segments()).containsExactly(3.0, 1.0); + assertThat(path.lineCap()).isNotNull(); + } + + @Test + void pxAndPtStrokeWidthsParse() { + SvgIcon px = SvgIcon.parse(""" + + """); + assertThat(px.layers().get(0).stroke().width()).isEqualTo(2.0); + + SvgIcon pt = SvgIcon.parse(""" + + """); + assertThat(pt.layers().get(0).stroke().width()).isCloseTo(96.0, within(1e-9)); + } + + @Test + void namedAndRgbaColoursResolveOnShapes() { + SvgIcon icon = SvgIcon.parse(""" + + + + + """); + + assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(102, 51, 153)); + assertThat(icon.layers().get(1).fill().color().getAlpha()).isEqualTo(128); + } + + @Test + void unsupportedContentElementsAreSkippedButGeometrySurvives() { + // has no vector analogue; it is dropped (with a one-time log + // warning) while the path beside it still renders. + SvgIcon icon = SvgIcon.parse(""" + + Hi + + + """); + + assertThat(icon.layers()).hasSize(1); + assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(18, 52, 86)); + } } diff --git a/src/test/java/com/demcha/compose/document/svg/SvgStylesTest.java b/src/test/java/com/demcha/compose/document/svg/SvgStylesTest.java new file mode 100644 index 00000000..7a0f1642 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/svg/SvgStylesTest.java @@ -0,0 +1,105 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentLineJoin; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * Unit coverage for the SVG presentation-attribute grammar: the colour + * subset (hex incl. alpha, {@code rgb()}/{@code rgba()}, CSS names), absolute + * length units, and the stroke style trio. + */ +class SvgStylesTest { + + private static final DocumentColor INK = DocumentColor.rgb(0, 0, 0); + + @Test + void hexColoursCoverThreeFourSixAndEightDigits() { + assertThat(SvgStyles.color("#abc", INK).color()).isEqualTo(new Color(170, 187, 204)); + assertThat(SvgStyles.color("#A78BFA", INK).color()).isEqualTo(new Color(167, 139, 250)); + // 4- and 8-digit forms carry alpha in the low byte. + assertThat(SvgStyles.color("#0000", INK).color().getAlpha()).isZero(); + Color half = SvgStyles.color("#11223380", INK).color(); + assertThat(half.getRed()).isEqualTo(0x11); + assertThat(half.getAlpha()).isEqualTo(0x80); + } + + @Test + void rgbAndRgbaAcceptNumbersAndPercentages() { + assertThat(SvgStyles.color("rgb(196, 30, 58)", INK).color()).isEqualTo(new Color(196, 30, 58)); + assertThat(SvgStyles.color("rgb(100%, 0%, 50%)", INK).color()).isEqualTo(new Color(255, 0, 128)); + Color translucent = SvgStyles.color("rgba(20, 80, 95, 0.5)", INK).color(); + assertThat(translucent.getAlpha()).isEqualTo(128); + } + + @Test + void namedColoursResolveCaseInsensitively() { + assertThat(SvgStyles.color("RebeccaPurple", INK).color()).isEqualTo(new Color(102, 51, 153)); + assertThat(SvgStyles.color("tomato", INK).color()).isEqualTo(new Color(255, 99, 71)); + assertThat(SvgStyles.color("none", INK)).isNull(); + assertThat(SvgStyles.color("currentColor", DocumentColor.rgb(1, 2, 3)).color()) + .isEqualTo(new Color(1, 2, 3)); + } + + @Test + void unknownColourFailsWithGuidance() { + assertThatThrownBy(() -> SvgStyles.color("burlywoodish", INK)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CSS colour name"); + } + + @Test + void absoluteLengthUnitsConvertToUserUnits() { + assertThat(SvgStyles.length("7", "x")).isCloseTo(7.0, within(1e-9)); + assertThat(SvgStyles.length("7px", "x")).isCloseTo(7.0, within(1e-9)); + assertThat(SvgStyles.length("72pt", "x")).isCloseTo(96.0, within(1e-9)); + assertThat(SvgStyles.length("1in", "x")).isCloseTo(96.0, within(1e-9)); + assertThat(SvgStyles.length("25.4mm", "x")).isCloseTo(96.0, within(1e-6)); + } + + @Test + void relativeUnitsAreRefused() { + assertThatThrownBy(() -> SvgStyles.length("2em", "stroke-width")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unsupported unit"); + } + + @Test + void strokeStyleEnumsParse() { + assertThat(SvgStyles.lineCap("round")).isEqualTo(DocumentLineCap.ROUND); + assertThat(SvgStyles.lineCap("square")).isEqualTo(DocumentLineCap.SQUARE); + assertThat(SvgStyles.lineJoin("bevel")).isEqualTo(DocumentLineJoin.BEVEL); + // SVG2 miter variants degrade to plain MITER. + assertThat(SvgStyles.lineJoin("miter-clip")).isEqualTo(DocumentLineJoin.MITER); + assertThatThrownBy(() -> SvgStyles.lineCap("pointy")) + .hasMessageContaining("stroke-linecap"); + } + + @Test + void dashArrayDoublesOddLengthListsAndTreatsZeroAsSolid() { + assertThat(SvgStyles.dashArray("4 2")).containsExactly(4.0, 2.0); + // Odd count repeats per the SVG rule. + assertThat(SvgStyles.dashArray("5")).containsExactly(5.0, 5.0); + assertThat(SvgStyles.dashArray("none")).isEmpty(); + assertThat(SvgStyles.dashArray("0 0")).isEmpty(); + // Units inside the list resolve too. + assertThat(SvgStyles.dashArray("3pt")).hasSize(2); + assertThatThrownBy(() -> SvgStyles.dashArray("-3 2")) + .hasMessageContaining("non-negative"); + } + + @Test + void dashArrayReturnsImmutableList() { + List dashes = SvgStyles.dashArray("4 2"); + assertThatThrownBy(() -> dashes.add(1.0)) + .isInstanceOf(UnsupportedOperationException.class); + } +}