|
| 1 | +package com.demcha.compose.document.dsl; |
| 2 | + |
| 3 | +import com.demcha.compose.GraphCompose; |
| 4 | +import com.demcha.compose.document.api.DocumentSession; |
| 5 | +import com.demcha.compose.document.layout.PlacedFragment; |
| 6 | +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; |
| 7 | +import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; |
| 8 | +import com.demcha.compose.document.node.LayerStackNode; |
| 9 | +import com.demcha.compose.document.node.SpacerNode; |
| 10 | +import com.demcha.compose.document.style.ClipPolicy; |
| 11 | +import com.demcha.compose.document.style.DocumentInsets; |
| 12 | +import com.demcha.compose.document.svg.SvgIcon; |
| 13 | +import com.demcha.testing.VisualTestOutputs; |
| 14 | +import org.apache.pdfbox.Loader; |
| 15 | +import org.apache.pdfbox.pdmodel.PDDocument; |
| 16 | +import org.apache.pdfbox.rendering.PDFRenderer; |
| 17 | +import org.junit.jupiter.api.Test; |
| 18 | + |
| 19 | +import java.awt.image.BufferedImage; |
| 20 | +import java.nio.charset.StandardCharsets; |
| 21 | +import java.nio.file.Files; |
| 22 | +import java.nio.file.Path; |
| 23 | +import java.util.List; |
| 24 | + |
| 25 | +import static org.assertj.core.api.Assertions.assertThat; |
| 26 | +import static org.assertj.core.api.Assertions.within; |
| 27 | + |
| 28 | +/** |
| 29 | + * Render coverage for block SVG icons ({@code addSvgIcon} → {@link SvgIcon#node} |
| 30 | + * → {@code LayerStackNode}): the block path must clip its layers to the icon box |
| 31 | + * the way the inline path clips to the glyph box, so off-{@code viewBox} icon |
| 32 | + * art does not bleed onto the page. The fragment-level half of the contract |
| 33 | + * (the paired clip markers) is asserted directly; the pixel-level half is |
| 34 | + * proved by rasterizing the page. |
| 35 | + * |
| 36 | + * @see InlineSvgRenderTest#offCanvasSvgGeometryIsClippedToTheViewBox the inline |
| 37 | + * sibling this mirrors |
| 38 | + */ |
| 39 | +class BlockSvgRenderTest { |
| 40 | + |
| 41 | + /** A crimson square inside the 24×24 viewBox — paints within the icon box. */ |
| 42 | + private static SvgIcon inBoxCrimson() { |
| 43 | + return SvgIcon.parse(""" |
| 44 | + <svg viewBox="0 0 24 24"> |
| 45 | + <path d="M2 2 H22 V22 H2 Z" fill="rgb(196, 30, 58)"/> |
| 46 | + </svg> |
| 47 | + """); |
| 48 | + } |
| 49 | + |
| 50 | + /** |
| 51 | + * A crimson square drawn entirely OUTSIDE the 10×10 viewBox (x 30..40 → |
| 52 | + * three-to-four icon-widths to the right). A browser clips it away at the |
| 53 | + * viewBox; unclipped it would smear across the page — the block-path twin |
| 54 | + * of the inline :package: duplicate-box bug (Noto's working file parks |
| 55 | + * off-canvas copies outside the viewBox). |
| 56 | + */ |
| 57 | + private static SvgIcon offCanvasCrimson() { |
| 58 | + return SvgIcon.parse(""" |
| 59 | + <svg viewBox="0 0 10 10"> |
| 60 | + <path d="M30 0 H40 V10 H30 Z" fill="rgb(196, 30, 58)"/> |
| 61 | + </svg> |
| 62 | + """); |
| 63 | + } |
| 64 | + |
| 65 | + @Test |
| 66 | + void inBoxBlockSvgPaintsItsFillColor() throws Exception { |
| 67 | + try (PDDocument document = Loader.loadPDF(renderBlockIcon(inBoxCrimson(), 24))) { |
| 68 | + assertThat(document.getNumberOfPages()).isEqualTo(1); |
| 69 | + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); |
| 70 | + // The crimson only reaches the page through the block icon, so a |
| 71 | + // crimson pixel proves the vector layer was drawn (positive control |
| 72 | + // for the clip test below: the clip must not eat in-box art). |
| 73 | + assertThat(containsColorNear(image, 196, 30, 58, 45)) |
| 74 | + .as("in-box block SVG must paint its fill colour") |
| 75 | + .isTrue(); |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + @Test |
| 80 | + void offCanvasBlockSvgGeometryIsClippedToTheViewBox() throws Exception { |
| 81 | + byte[] pdf = renderBlockIcon(offCanvasCrimson(), 24); |
| 82 | + try (PDDocument document = Loader.loadPDF(pdf)) { |
| 83 | + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); |
| 84 | + // The off-canvas square would land well within the page if unclipped |
| 85 | + // (≈3× the icon width to the right of the box). Finding no crimson |
| 86 | + // proves the block layer stack clips to the icon box. |
| 87 | + assertThat(containsColorNear(image, 196, 30, 58, 45)) |
| 88 | + .as("off-canvas block SVG geometry must be clipped to the viewBox, not bleed onto the page") |
| 89 | + .isFalse(); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + @Test |
| 94 | + void blockSvgIconEmitsBalancedViewBoxClipPair() throws Exception { |
| 95 | + try (DocumentSession session = GraphCompose.document() |
| 96 | + .pageSize(200, 200) |
| 97 | + .margin(DocumentInsets.of(16)) |
| 98 | + .create()) { |
| 99 | + session.pageFlow(page -> page.addSvgIcon(inBoxCrimson(), 48)); |
| 100 | + |
| 101 | + List<PlacedFragment> fragments = session.layoutGraph().fragments(); |
| 102 | + List<PlacedFragment> begins = fragments.stream() |
| 103 | + .filter(f -> f.payload() instanceof ShapeClipBeginPayload) |
| 104 | + .toList(); |
| 105 | + List<PlacedFragment> ends = fragments.stream() |
| 106 | + .filter(f -> f.payload() instanceof ShapeClipEndPayload) |
| 107 | + .toList(); |
| 108 | + |
| 109 | + assertThat(begins).as("one viewBox clip opens around the icon").hasSize(1); |
| 110 | + assertThat(ends).as("one viewBox clip closes around the icon").hasSize(1); |
| 111 | + |
| 112 | + PlacedFragment begin = begins.get(0); |
| 113 | + PlacedFragment end = ends.get(0); |
| 114 | + ShapeClipBeginPayload beginPayload = (ShapeClipBeginPayload) begin.payload(); |
| 115 | + ShapeClipEndPayload endPayload = (ShapeClipEndPayload) end.payload(); |
| 116 | + |
| 117 | + assertThat(beginPayload.policy()).isEqualTo(ClipPolicy.CLIP_BOUNDS); |
| 118 | + assertThat(begin.path()).contains("SvgIcon"); |
| 119 | + assertThat(beginPayload.ownerPath()).isEqualTo(endPayload.ownerPath()); |
| 120 | + assertThat(begin.pageIndex()) |
| 121 | + .as("the clip pair must restore on the same page it saves") |
| 122 | + .isEqualTo(end.pageIndex()); |
| 123 | + assertThat(fragments.indexOf(begin)) |
| 124 | + .as("clip-begin must precede the layers and the clip-end") |
| 125 | + .isLessThan(fragments.indexOf(end)); |
| 126 | + |
| 127 | + // The clip rectangle is exactly the icon box (square 24×24 viewBox |
| 128 | + // at width 48 → 48×48), so it clips to the viewBox, nothing wider. |
| 129 | + assertThat(begin.width()).isCloseTo(48.0, within(1e-6)); |
| 130 | + assertThat(begin.height()).isCloseTo(48.0, within(1e-6)); |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + @Test |
| 135 | + void plainLayerStackDoesNotClip() throws Exception { |
| 136 | + // A hand-built stack defaults to clipToBounds=false (the historical |
| 137 | + // four-arg shape), so it must emit no clip markers — the SVG opt-in |
| 138 | + // does not leak onto every layer stack. |
| 139 | + LayerStackNode plain = new LayerStackNode( |
| 140 | + "PlainStack", |
| 141 | + List.of(new LayerStackNode.Layer( |
| 142 | + new SpacerNode("Inner", 20, 20, DocumentInsets.zero(), DocumentInsets.zero()))), |
| 143 | + null, |
| 144 | + null); |
| 145 | + |
| 146 | + try (DocumentSession session = GraphCompose.document() |
| 147 | + .pageSize(200, 200) |
| 148 | + .margin(DocumentInsets.of(16)) |
| 149 | + .create()) { |
| 150 | + session.add(plain); |
| 151 | + |
| 152 | + assertThat(plain.clipToBounds()).isFalse(); |
| 153 | + assertThat(session.layoutGraph().fragments()) |
| 154 | + .as("a non-clipping stack emits no clip markers") |
| 155 | + .noneMatch(f -> f.payload() instanceof ShapeClipBeginPayload |
| 156 | + || f.payload() instanceof ShapeClipEndPayload); |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + @Test |
| 161 | + void writesVisualArtifact() throws Exception { |
| 162 | + // An off-canvas icon next to an in-box icon, for eyeballing: neither |
| 163 | + // the off-canvas art nor the off-canvas icon's empty box should bleed. |
| 164 | + try (DocumentSession session = GraphCompose.document() |
| 165 | + .pageSize(280, 160) |
| 166 | + .margin(DocumentInsets.of(20)) |
| 167 | + .create()) { |
| 168 | + session.pageFlow(page -> { |
| 169 | + page.addParagraph(p -> p.text("Block SVG viewBox clip")); |
| 170 | + page.addSvgIcon(inBoxCrimson(), 48); |
| 171 | + page.addSvgIcon(offCanvasCrimson(), 48); |
| 172 | + }); |
| 173 | + byte[] pdf = session.toPdfBytes(); |
| 174 | + Path out = VisualTestOutputs.preparePdf("block-svg-viewbox-clip", "svg"); |
| 175 | + Files.write(out, pdf); |
| 176 | + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); |
| 177 | + assertThat(Files.size(out)).isPositive(); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + private static byte[] renderBlockIcon(SvgIcon icon, double width) throws Exception { |
| 182 | + try (DocumentSession session = GraphCompose.document() |
| 183 | + .pageSize(320, 160) |
| 184 | + .margin(DocumentInsets.of(16)) |
| 185 | + .create()) { |
| 186 | + session.pageFlow(page -> page.addSvgIcon(icon, width)); |
| 187 | + return session.toPdfBytes(); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) { |
| 192 | + for (int y = 0; y < image.getHeight(); y++) { |
| 193 | + for (int x = 0; x < image.getWidth(); x++) { |
| 194 | + int rgb = image.getRGB(x, y); |
| 195 | + int rr = (rgb >> 16) & 0xFF; |
| 196 | + int gg = (rgb >> 8) & 0xFF; |
| 197 | + int bb = rgb & 0xFF; |
| 198 | + if (Math.abs(rr - r) <= tolerance |
| 199 | + && Math.abs(gg - g) <= tolerance |
| 200 | + && Math.abs(bb - b) <= tolerance) { |
| 201 | + return true; |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + return false; |
| 206 | + } |
| 207 | +} |
0 commit comments