Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public static Path generate() throws Exception {
List<SvgIcon> 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(" ");
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/**
* Runnable showcase for colour emoji by shortcode ({@code @since 1.9.0}).
*
* <p>{@code RichText.emoji(":star:", size)} / {@code ParagraphBuilder.emoji(...)}
* <p>{@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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* Runnable showcase for inline SVG-icon runs ({@code @since 1.9.0}).
*
* <p>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:}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* <p>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/<codepoint>.svg} shipped by the independently-versioned
* {@code graph-compose-emoji} companion artifact; the engine carries no emoji
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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:");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
})
Expand Down Expand Up @@ -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<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++) {
Expand Down