diff --git a/CHANGELOG.md b/CHANGELOG.md index 770b14804..735b4b5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ PDF `GoTo` actions. External links are unchanged. returns the external options (or `null` for an internal link). - **Inline SVG-icon runs** (`@since 1.9.0`). A parsed `SvgIcon` can now sit on the text baseline inside a paragraph via `RichText.svgIcon(icon, size)` and - `ParagraphBuilder.svgIcon(icon, size)` (with `alignment` / `baselineOffset` / + `ParagraphBuilder.inlineSvgIcon(icon, size)` (with `alignment` / `baselineOffset` / link overloads, plus a clickable form). `size` is the glyph's height in points; the width follows the icon's aspect ratio. The icon is drawn as crisp vector layers carrying their own colours — gradients included — so it renders @@ -50,7 +50,7 @@ PDF `GoTo` actions. External links are unchanged. the inline render reuses the existing SVG paint pipeline (shared with the block path fragment), so flat-colour output stays byte-identical. - **Colour emoji by shortcode** (`@since 1.9.0`). `RichText.emoji(":star:", size)` - and `ParagraphBuilder.emoji(...)` resolve a GitHub-style shortcode to an inline + and `ParagraphBuilder.inlineEmoji(...)` resolve a GitHub-style shortcode to an inline vector colour glyph. Resolution is lenient — an unknown shortcode (or no emoji set on the classpath) is rendered as the literal text, the way GitHub treats an unrecognised `:code:`. The resolver is the new `EmojiLibrary` diff --git a/examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java b/examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java index 4d3637bc1..e87b7e9be 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java @@ -59,7 +59,7 @@ public static Path generate() throws Exception { List chunk = glyphs.subList(start, Math.min(start + PER_PARAGRAPH, glyphs.size())); flow.addParagraph(p -> { for (SvgIcon icon : chunk) { - p.svgIcon(icon, ICON_PT).inlineText(" "); + p.inlineSvgIcon(icon, ICON_PT).inlineText(" "); } }); } diff --git a/examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java b/examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java index 07a1671eb..d72aab13b 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java @@ -18,7 +18,7 @@ /** * Runnable showcase for colour emoji by shortcode ({@code @since 1.9.0}). * - *

{@code RichText.emoji(":star:", size)} / {@code ParagraphBuilder.emoji(...)} + *

{@code RichText.emoji(":star:", size)} / {@code ParagraphBuilder.inlineEmoji(...)} * resolve a GitHub-style shortcode to an inline vector colour glyph, drawn on the * text baseline — crisp at any zoom, no emoji font needed. Glyphs come from the * {@code graph-compose-emoji} companion artifact on the classpath (here, the diff --git a/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java b/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java index 396dd002e..03b094636 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java @@ -149,7 +149,7 @@ private static byte[] rasterise(SvgIcon icon) throws Exception { byte[] glyphPdf; try (DocumentSession g = GraphCompose.document().pageSize(box, box).margin(0, 0, 0, 0).create()) { g.dsl().pageFlow().name("g") - .addParagraph(p -> p.svgIcon(icon, box).margin(DocumentInsets.zero())) + .addParagraph(p -> p.inlineSvgIcon(icon, box).margin(DocumentInsets.zero())) .build(); glyphPdf = g.toPdfBytes(); } diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java index f79a1cc3b..3b5f3754e 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java @@ -20,7 +20,7 @@ * Runnable showcase for inline SVG-icon runs ({@code @since 1.9.0}). * *

Parsed {@link SvgIcon}s are placed on the text baseline with - * {@code RichText.svgIcon(icon, size)} / {@code ParagraphBuilder.svgIcon(...)}, + * {@code RichText.svgIcon(icon, size)} / {@code ParagraphBuilder.inlineSvgIcon(...)}, * so multi-colour vector glyphs flow inside a line of text — crisp at any zoom, * carrying their own colours, with no dependence on the active font's glyph * coverage. This is the engine path for vector colour emoji: a {@code :rocket:} diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 88522012a..7b23dcbf7 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -554,8 +554,8 @@ public ParagraphBuilder shape(ShapeOutline outline, * @return this builder * @since 1.9.0 */ - public ParagraphBuilder svgIcon(SvgIcon icon, double size) { - return svgIcon(icon, size, InlineImageAlignment.CENTER, 0.0, null); + public ParagraphBuilder inlineSvgIcon(SvgIcon icon, double size) { + return inlineSvgIcon(icon, size, InlineImageAlignment.CENTER, 0.0, null); } /** @@ -567,8 +567,8 @@ public ParagraphBuilder svgIcon(SvgIcon icon, double size) { * @return this builder * @since 1.9.0 */ - public ParagraphBuilder svgIcon(SvgIcon icon, double size, InlineImageAlignment alignment) { - return svgIcon(icon, size, alignment, 0.0, null); + public ParagraphBuilder inlineSvgIcon(SvgIcon icon, double size, InlineImageAlignment alignment) { + return inlineSvgIcon(icon, size, alignment, 0.0, null); } /** @@ -584,7 +584,7 @@ public ParagraphBuilder svgIcon(SvgIcon icon, double size, InlineImageAlignment * @return this builder * @since 1.9.0 */ - public ParagraphBuilder svgIcon(SvgIcon icon, + public ParagraphBuilder inlineSvgIcon(SvgIcon icon, double size, InlineImageAlignment alignment, double baselineOffset, @@ -616,12 +616,12 @@ public ParagraphBuilder svgIcon(SvgIcon icon, * @return this builder * @since 1.9.0 */ - public ParagraphBuilder emoji(String shortcode, double size) { - return emoji(shortcode, size, InlineImageAlignment.CENTER, 0.0, null); + public ParagraphBuilder inlineEmoji(String shortcode, double size) { + return inlineEmoji(shortcode, size, InlineImageAlignment.CENTER, 0.0, null); } /** - * Adds a colour emoji (see {@link #emoji(String, double)}) with explicit + * Adds a colour emoji (see {@link #inlineEmoji(String, double)}) with explicit * vertical alignment, baseline offset and optional link metadata. * * @param shortcode emoji shortcode, with or without surrounding colons @@ -632,14 +632,14 @@ public ParagraphBuilder emoji(String shortcode, double size) { * @return this builder * @since 1.9.0 */ - public ParagraphBuilder emoji(String shortcode, + public ParagraphBuilder inlineEmoji(String shortcode, double size, InlineImageAlignment alignment, double baselineOffset, DocumentLinkOptions linkOptions) { SvgIcon icon = EmojiLibrary.getDefault().find(shortcode).orElse(null); if (icon != null) { - return svgIcon(icon, size, alignment, baselineOffset, linkOptions); + return inlineSvgIcon(icon, size, alignment, baselineOffset, linkOptions); } return inlineText(shortcode); } diff --git a/src/main/java/com/demcha/compose/document/emoji/package-info.java b/src/main/java/com/demcha/compose/document/emoji/package-info.java index d5171eaa7..b00a27620 100644 --- a/src/main/java/com/demcha/compose/document/emoji/package-info.java +++ b/src/main/java/com/demcha/compose/document/emoji/package-info.java @@ -4,7 +4,7 @@ *

The entry point is {@link com.demcha.compose.document.emoji.EmojiLibrary}, * which maps GitHub-style shortcodes (e.g. {@code ":star:"}) to parsed * {@link com.demcha.compose.document.svg.SvgIcon} glyphs and backs the - * {@code RichText.emoji(...)} / {@code ParagraphBuilder.emoji(...)} DSL. It is + * {@code RichText.emoji(...)} / {@code ParagraphBuilder.inlineEmoji(...)} DSL. It is * data-driven from the classpath layout {@code emoji/emoji-index.properties} * + {@code emoji/svg/.svg} shipped by the independently-versioned * {@code graph-compose-emoji} companion artifact; the engine carries no emoji diff --git a/src/test/java/com/demcha/compose/document/dsl/EmojiRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/EmojiRenderTest.java index 09f60cea8..7691fb622 100644 --- a/src/test/java/com/demcha/compose/document/dsl/EmojiRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/EmojiRenderTest.java @@ -24,7 +24,7 @@ class EmojiRenderTest { @Test void knownShortcodeRendersAsInlineColourGlyph() throws Exception { - byte[] pdf = render(p -> p.inlineText("Done ").emoji(":white_check_mark:", 14)); + byte[] pdf = render(p -> p.inlineText("Done ").inlineEmoji(":white_check_mark:", 14)); try (PDDocument document = Loader.loadPDF(pdf)) { assertThat(new PDFTextStripper().getText(document)).contains("Done").doesNotContain("?"); BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); @@ -39,7 +39,7 @@ void knownShortcodeRendersAsInlineColourGlyph() throws Exception { @Test void secondColourEmojiAlsoResolvesAndPaints() throws Exception { - byte[] pdf = render(p -> p.inlineText("Launch ").emoji(":rocket:", 14)); + byte[] pdf = render(p -> p.inlineText("Launch ").inlineEmoji(":rocket:", 14)); try (PDDocument document = Loader.loadPDF(pdf)) { assertThat(new PDFTextStripper().getText(document)).contains("Launch").doesNotContain(":rocket:"); BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); @@ -51,7 +51,7 @@ void secondColourEmojiAlsoResolvesAndPaints() throws Exception { @Test void unknownShortcodeFallsBackToLiteralText() throws Exception { - byte[] pdf = render(p -> p.inlineText("Ping ").emoji(":not_a_real_emoji:", 14)); + byte[] pdf = render(p -> p.inlineText("Ping ").inlineEmoji(":not_a_real_emoji:", 14)); try (PDDocument document = Loader.loadPDF(pdf)) { assertThat(new PDFTextStripper().getText(document)).contains(":not_a_real_emoji:"); } diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java index e067d4acc..a820b1f40 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java @@ -2,6 +2,11 @@ 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; @@ -128,7 +133,7 @@ void linkedInlineSvgEmitsClickableAnnotationSizedToTheIconBox() throws Exception .name("Flow") .addParagraph(paragraph -> paragraph .inlineText("Home ") - .svgIcon(crimsonSquare(), iconSize, InlineImageAlignment.CENTER, + .inlineSvgIcon(crimsonSquare(), iconSize, InlineImageAlignment.CENTER, 0.0, new DocumentLinkOptions("https://example.com"))) .build(); pdf = session.toPdfBytes(); @@ -192,7 +197,7 @@ private static byte[] renderAutoSized(boolean withIcon) throws Exception { .addParagraph(p -> { p.inlineText("Status complete now"); if (withIcon) { - p.svgIcon(wideBar, 10); + p.inlineSvgIcon(wideBar, 10); } p.autoSize(24, 5); }) @@ -241,13 +246,111 @@ private static byte[] renderIconRow(SvgIcon icon) throws Exception { .addParagraph(paragraph -> paragraph .name("IconRow") .inlineText("Ship it ") - .svgIcon(icon, 12) + .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 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 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++) {