-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathInlineSvgRenderTest.java
More file actions
370 lines (341 loc) · 16.7 KB
/
Copy pathInlineSvgRenderTest.java
File metadata and controls
370 lines (341 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
package com.demcha.compose.document.dsl;
import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphSvgSpan;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineSvgRun;
import com.demcha.compose.document.svg.SvgIcon;
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.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* End-to-end coverage for inline SVG-icon runs: the measure → tokenize → span →
* PDF render pipeline must paint vector glyphs (colour emoji, marks) on the text
* baseline without dropping them or substituting font glyphs.
*/
class InlineSvgRenderTest {
/** A solid crimson square — distinctive against black text on a white page. */
private static SvgIcon crimsonSquare() {
return SvgIcon.parse("""
<svg viewBox="0 0 24 24">
<path d="M2 2 H22 V22 H2 Z" fill="rgb(196, 30, 58)"/>
</svg>
""");
}
/** A gradient-filled square — exercises the inline gradient paint path. */
private static SvgIcon gradientSquare() {
return SvgIcon.parse("""
<svg viewBox="0 0 10 10">
<defs>
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="10" y2="10">
<stop offset="0" stop-color="#A78BFA"/>
<stop offset="1" stop-color="#6128D9"/>
</linearGradient>
</defs>
<path d="M0 0 H10 V10 Z" fill="url(#g)"/>
</svg>
""");
}
@Test
void inlineSvgRendersEndToEndKeepingTextWithoutGlyphSubstitution() throws Exception {
byte[] pdf = renderIconRow(crimsonSquare());
assertThat(pdf).isNotEmpty();
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("Ship it");
assertThat(text).doesNotContain("?");
}
}
@Test
void inlineSvgPaintsItsFillColor() throws Exception {
try (PDDocument document = Loader.loadPDF(renderIconRow(crimsonSquare()))) {
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
// The crimson fill only enters the page through the inline icon — the
// text is default black and the background white — so finding crimson
// pixels proves the vector layer was drawn inline, not dropped.
assertThat(containsColorNear(image, 196, 30, 58, 45))
.as("inline SVG icon must paint its fill colour")
.isTrue();
}
}
@Test
void gradientInlineSvgRendersAndPaints() throws Exception {
try (PDDocument document = Loader.loadPDF(renderIconRow(gradientSquare()))) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
// The violet gradient stops only reach the page through the inline
// icon's shading, so a violet pixel proves the gradient paint path
// runs inline as well as for block paths.
assertThat(containsColorNear(image, 129, 80, 224, 60))
.as("inline gradient SVG must paint its shading")
.isTrue();
}
}
@Test
void offCanvasSvgGeometryIsClippedToTheViewBox() throws Exception {
// A crimson square drawn entirely OUTSIDE the 10×10 viewBox (x 30..40 →
// normalized x 3..4). SVG viewBox semantics clip it away; without the
// glyph-box clip it would bleed several glyph-widths to the right and
// smear onto neighbouring content (the :package: duplicate-box bug, where
// Noto's working file parks off-canvas copies outside the viewBox).
SvgIcon offCanvas = SvgIcon.parse("""
<svg viewBox="0 0 10 10">
<path d="M30 0 H40 V10 H30 Z" fill="rgb(196, 30, 58)"/>
</svg>
""");
try (PDDocument document = Loader.loadPDF(renderIconRow(offCanvas))) {
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
assertThat(containsColorNear(image, 196, 30, 58, 45))
.as("off-canvas SVG geometry must be clipped to the viewBox, not bleed onto the page")
.isFalse();
}
}
@Test
void linkedInlineSvgEmitsClickableAnnotationSizedToTheIconBox() throws Exception {
double iconSize = 6.0;
byte[] pdf;
try (DocumentSession session = GraphCompose.document()
.pageSize(220, 120)
.margin(14, 14, 14, 14)
.create()) {
session.dsl()
.pageFlow()
.name("Flow")
.addParagraph(paragraph -> paragraph
.inlineText("Home ")
.inlineSvgIcon(crimsonSquare(), iconSize, InlineImageAlignment.CENTER,
0.0, new DocumentLinkOptions("https://example.com")))
.build();
pdf = session.toPdfBytes();
}
try (PDDocument document = Loader.loadPDF(pdf)) {
PDAnnotationLink link = (PDAnnotationLink) document.getPage(0).getAnnotations().stream()
.filter(PDAnnotationLink.class::isInstance)
.findFirst()
.orElseThrow(() -> new AssertionError("no link annotation for the inline SVG"));
// The clickable box must hug the icon (height == iconSize), not the
// taller text line box — i.e. the SVG span takes the inline-graphic
// rectangle path, not the text fallback.
assertThat((double) link.getRectangle().getHeight())
.as("link rect hugs the icon, not the full line box")
.isCloseTo(iconSize, within(0.5));
}
}
@Test
void richTextSvgIconSizesByAspectRatio() {
// A 20×10 viewBox is twice as wide as tall, so a size-of-10 icon measures
// 20 wide × 10 tall — the run carries the aspect-correct box.
SvgIcon wide = SvgIcon.parse("""
<svg viewBox="0 0 20 10">
<path d="M0 0 H20 V10 H0 Z" fill="#c41e3a"/>
</svg>
""");
InlineSvgRun run = onlySvgRun(RichText.text("x").svgIcon(wide, 10.0));
assertThat(run.icon()).isSameAs(wide);
assertThat(run.width()).isEqualTo(20.0, within(1e-6));
assertThat(run.height()).isEqualTo(10.0, within(1e-6));
}
@Test
void autoSizeReservesWidthForTheInlineSvgIcon() throws Exception {
// Same auto-sized paragraph, with and without a wide (100×10) inline
// icon. The icon must eat horizontal room, so the fitted text size is
// strictly smaller with the icon present — proving the auto-size width
// estimate counts inline SVG runs (not only text / image / shape).
double withoutIcon = maxTextFontSize(renderAutoSized(false));
double withIcon = maxTextFontSize(renderAutoSized(true));
assertThat(withoutIcon).as("text alone fits at a readable size").isGreaterThan(0.0);
assertThat(withIcon)
.as("the inline icon shrinks the auto-sized text")
.isLessThan(withoutIcon);
}
private static byte[] renderAutoSized(boolean withIcon) throws Exception {
SvgIcon wideBar = SvgIcon.parse(
"<svg viewBox='0 0 100 10'><path d='M0 0 H100 V10 H0 Z' fill='#c41e3a'/></svg>");
try (DocumentSession session = GraphCompose.document()
.pageSize(182, 120)
.margin(16, 16, 16, 16)
.create()) {
session.dsl()
.pageFlow()
.name("Flow")
.addParagraph(p -> {
p.inlineText("Status complete now");
if (withIcon) {
p.inlineSvgIcon(wideBar, 10);
}
p.autoSize(24, 5);
})
.build();
return session.toPdfBytes();
}
}
/** Largest font size given to a {@code Tf} operator on page 0. */
private static double maxTextFontSize(byte[] pdf) throws Exception {
try (PDDocument document = Loader.loadPDF(pdf)) {
PDFStreamParser parser = new PDFStreamParser(document.getPage(0));
double max = 0.0;
List<COSBase> 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("Tf") && !operands.isEmpty()
&& operands.get(operands.size() - 1) instanceof COSNumber size) {
max = Math.max(max, size.floatValue());
}
operands.clear();
}
}
return max;
}
}
private static InlineSvgRun onlySvgRun(RichText rich) {
return rich.runs().stream()
.filter(InlineSvgRun.class::isInstance)
.map(InlineSvgRun.class::cast)
.findFirst()
.orElseThrow(() -> new AssertionError("no InlineSvgRun in " + rich.runs()));
}
private static byte[] renderIconRow(SvgIcon icon) throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(320, 160)
.margin(16, 16, 16, 16)
.create()) {
session.dsl()
.pageFlow()
.name("Flow")
.addParagraph(paragraph -> paragraph
.name("IconRow")
.inlineText("Ship it ")
.inlineSvgIcon(icon, 12)
.inlineText(" now"))
.build();
return session.toPdfBytes();
}
}
@Test
void inlineSvgIconWrapsAcrossLinesAndDrivesLineHeight() throws Exception {
// A tall (28pt) icon mid-paragraph on a narrow column: the paragraph must
// wrap to several lines, the icon's line must carry a ParagraphSvgSpan, and
// that line's height must be driven up by the icon (lineHeight > the plain
// text-line height) — exercising the wrap + per-line max-graphic-height path
// that the single-line tests above never reach.
SvgIcon icon = crimsonSquare();
try (DocumentSession session = GraphCompose.document()
.pageSize(170, 240)
.margin(14, 14, 14, 14)
.create()) {
session.dsl()
.pageFlow()
.name("Flow")
.addParagraph(p -> p
.name("WrappingIconParagraph")
.inlineText("This sentence is intentionally long so that it wraps onto more "
+ "than one line before it reaches the inline ")
.inlineSvgIcon(icon, 28)
.inlineText(" icon and then continues with yet more trailing text"))
.build();
List<ParagraphLine> lines = paragraphLines(session.layoutGraph());
assertThat(lines).as("the paragraph wraps to multiple lines").hasSizeGreaterThanOrEqualTo(2);
ParagraphLine iconLine = lines.stream()
.filter(line -> line.spans().stream().anyMatch(ParagraphSvgSpan.class::isInstance))
.findFirst()
.orElseThrow(() -> new AssertionError("no wrapped line carries the inline SVG span"));
ParagraphSvgSpan span = (ParagraphSvgSpan) iconLine.spans().stream()
.filter(ParagraphSvgSpan.class::isInstance)
.findFirst()
.orElseThrow();
assertThat(iconLine.lineHeight())
.as("the icon's line grows to fit the icon")
.isGreaterThanOrEqualTo(span.height());
assertThat(iconLine.lineHeight())
.as("the tall icon, not the text, drives its line's height")
.isGreaterThan(iconLine.textLineHeight());
byte[] pdf = session.toPdfBytes();
try (PDDocument document = Loader.loadPDF(pdf)) {
BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
assertThat(containsColorNear(image, 196, 30, 58, 45))
.as("the wrapped inline SVG icon still paints its fill colour")
.isTrue();
assertThat(new PDFTextStripper().getText(document)).doesNotContain("?");
}
}
}
@Test
void inlineSvgIconSplitAcrossPagesRendersAndPaints() throws Exception {
// The icon sits near the start of a paragraph whose body is long enough to
// paginate. The split/continuation flow must keep the icon (it lands on the
// head page) rather than drop or duplicate it across the page break.
SvgIcon icon = crimsonSquare();
StringBuilder body = new StringBuilder();
for (int i = 0; i < 50; i++) {
body.append("Filler sentence ").append(i).append(" that pads the paragraph. ");
}
try (DocumentSession session = GraphCompose.document()
.pageSize(220, 130)
.margin(12, 12, 12, 12)
.create()) {
session.dsl()
.pageFlow()
.name("Flow")
.addParagraph(p -> p
.inlineText("Status ")
.inlineSvgIcon(icon, 12)
.inlineText(" then a long body that must paginate: " + body))
.build();
byte[] pdf = session.toPdfBytes();
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages())
.as("the paragraph splits across a page break")
.isGreaterThanOrEqualTo(2);
BufferedImage head = new PDFRenderer(document).renderImageWithDPI(0, 144);
assertThat(containsColorNear(head, 196, 30, 58, 45))
.as("the inline SVG icon paints on its (head) page within a paginating paragraph")
.isTrue();
assertThat(new PDFTextStripper().getText(document)).doesNotContain("?");
}
}
}
private static List<ParagraphLine> paragraphLines(LayoutGraph graph) {
return graph.fragments().stream()
.map(PlacedFragment::payload)
.filter(ParagraphFragmentPayload.class::isInstance)
.map(ParagraphFragmentPayload.class::cast)
.flatMap(payload -> payload.lines().stream())
.toList();
}
private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) {
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
int rgb = image.getRGB(x, y);
int rr = (rgb >> 16) & 0xFF;
int gg = (rgb >> 8) & 0xFF;
int bb = rgb & 0xFF;
if (Math.abs(rr - r) <= tolerance
&& Math.abs(gg - g) <= tolerance
&& Math.abs(bb - b) <= tolerance) {
return true;
}
}
}
return false;
}
}