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 a820b1f4..da22e387 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineSvgRenderTest.java @@ -342,6 +342,42 @@ void inlineSvgIconSplitAcrossPagesRendersAndPaints() throws Exception { } } + @Test + void linkedInlineSvgOnAWrappedLineKeepsTheAnnotationSizedToTheIcon() throws Exception { + // The single-line linked case is covered above; here the linked icon lands + // on a wrapped line, exercising spanLinkRectangle's alignment/height + // geometry when the icon's line is not the paragraph's first line. + double iconSize = 8.0; + byte[] pdf; + int lineCount; + try (DocumentSession session = GraphCompose.document() + .pageSize(168, 200) + .margin(14, 14, 14, 14) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(p -> p + .inlineText("This label is intentionally long so the linked icon wraps onto a later line ") + .inlineSvgIcon(crimsonSquare(), iconSize, InlineImageAlignment.CENTER, + 0.0, new DocumentLinkOptions("https://example.com"))) + .build(); + lineCount = paragraphLines(session.layoutGraph()).size(); + pdf = session.toPdfBytes(); + } + + assertThat(lineCount).as("the linked icon sits on a wrapped paragraph").isGreaterThanOrEqualTo(2); + 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 wrapped inline SVG")); + assertThat((double) link.getRectangle().getHeight()) + .as("link rect still hugs the icon, not the line box, on a wrapped line") + .isCloseTo(iconSize, within(0.5)); + } + } + private static List paragraphLines(LayoutGraph graph) { return graph.fragments().stream() .map(PlacedFragment::payload) diff --git a/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java b/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java index 131a10f9..4081419b 100644 --- a/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java +++ b/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java @@ -3,8 +3,12 @@ import com.demcha.compose.document.svg.SvgIcon; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -70,4 +74,24 @@ void absentEmojiSetReportsUnavailableAndNamesTheArtifact() throws Exception { .hasMessageContaining("graph-compose-emoji"); } } + + @Test + void indexedGlyphThatCannotBeParsedResolvesEmptyAndRequireExplains(@TempDir Path classpathRoot) throws Exception { + // A set whose index points at a glyph the SVG parser rejects (here an svg + // with no drawable geometry). find() must stay lenient — empty, so callers + // fall back to literal text — and require()'s message must distinguish + // "indexed but unrenderable" from "unknown shortcode". + Path svgDir = Files.createDirectories(classpathRoot.resolve("emoji/svg")); + Files.writeString(classpathRoot.resolve("emoji/emoji-index.properties"), "broken=0bad1\n"); + Files.writeString(svgDir.resolve("0bad1.svg"), ""); + try (URLClassLoader loader = new URLClassLoader(new URL[]{classpathRoot.toUri().toURL()}, null)) { + EmojiLibrary lib = new EmojiLibrary(loader); + + assertThat(lib.isAvailable()).isTrue(); + assertThat(lib.find("broken")).isEmpty(); + assertThatThrownBy(() -> lib.require("broken")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("could not be rendered"); + } + } }