diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bae67..65bca0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to **graph-compose-markdown** are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/), and the project follows [Semantic Versioning](https://semver.org/). +## v0.4.0 — 2026-08-05 + +Minor release: headings stop being stranded at the bottom of a page, and the PDF backend +finally draws the link underlines and strikethrough every theme was already asking for. +Both come from moving to the GraphCompose 2.x engine line (2.1.1). The only public-API +change is an added `PageTokens` component, which keeps a constructor overload, so existing +theme code compiles unchanged. + +### Upgrading — expect your documents to repaginate + +Both fixes are on by default, and the heading one moves content. Any document where a heading +sat at the bottom of a page now has that heading — and everything after it — one position +further down. This is not a rare shape: rendering this repository's own six-page manual, two +headings were stranded (`Ways in, ways out` and `Tables`), and fixing them redrew four of the +six pages by 22–39 % of their pixels. The page *count* did not change there, but it can. + +If you compare rendered PDFs against committed baselines, regenerate them with this release. +To keep the old flow, build `PageTokens` with the fourth argument `false`; to keep links +un-underlined, set `ShapeTokens.underlineLinks` to `false`. + +### Public API +- **A heading is no longer stranded at the bottom of a page, apart from the content it + introduces.** `HeadingRenderer` now wraps its paragraph in a one-child section marked + `SectionBuilder.keepWithNext()` (the engine's opt-in orphan rule, new in GraphCompose + 2.1.0), so a heading that fits at a page bottom but whose body's first line does not + moves down to join it. The flag has to sit on a section because it is a node-level + pagination property and a paragraph cannot carry one; the bookmark and the anchor stay + on the paragraph, so outline entries and `#slug` link destinations resolve to the + heading's own position exactly as before. Documents with no orphan render unchanged. +- **`PageTokens` gains `keepHeadingWithNext`** (default `true`), so a theme can restore the + plain flow. The previous three-argument constructor is kept as an overload that defaults + the flag on, so existing theme code compiles and behaves identically apart from the fix. + + This covers every block kind, including the five this library marks `keepTogether()` — a + code block, a blockquote, a GitHub alert, a custom callout and an unsupported-block panel. + Those needed the engine fix that shipped in GraphCompose 2.1.1: on 2.1.0 the lookahead + measured the first *line* of the following block, but a keep-together block has no line to + break after — it relocates entire — so the heading was left behind. Rendering + `samples/kitchen-sink.md` from the `gp-markdown-view` viewer, the one orphan across all its + samples (`## Blockquote`) survives the wrapping alone and disappears on 2.1.1. + +### Build +- **GraphCompose engine bumped 2.0.0 → 2.1.1.** Brings `SectionBuilder.keepWithNext()` (used + above), the 2.1.1 fix that lets its lookahead see `keepTogether()` — without which the + heading rule misses every panel-shaped block this library builds — and two rendering fixes + this library benefits from with no source change: the PDF + backend now actually draws `UNDERLINE` and `STRIKETHROUGH`, so the link underlines every theme + already asks for through `ShapeTokens.underlineLinks` and `~~strikethrough~~` stop rendering as + plain text, and `DocumentColor.rgba` alpha is honoured on text, lines, borders and table fills. + Metrics are unchanged — the marks are painted at measured positions — so nothing reflows. + PDFBox moves 3.0.7 → 3.0.8 transitively. 193 library tests pass on 2.1.1. +- **GraphCompose engine bumped 1.9.1 → 2.0.0.** The 2.0 release splits the monolithic engine + into per-concern modules, but `io.github.demchaav:graph-compose` stays the drop-in default — + it is now a thin aggregator over `graph-compose-core` + `graph-compose-render-pdf`, so this + library keeps rendering PDF with **no dependency change and no change to `MarkdownComposer` + or any public type**. The optional `graph-compose-fonts` / `graph-compose-emoji` add-ons keep + their own version lines and are unchanged. The only source touched was a test: 2.0 removed the + deprecated `linkOptions()` read-accessor on the engine's inline runs, so `InlineRendererTest` + now reads the surrounding link through `linkTarget()` / `ExternalLinkTarget.options().uri()` + (the value it asserts is unchanged). 188 library tests + the CLI suite pass on 2.0.0. +- **GraphCompose engine bumped 1.9.0 → 1.9.1.** Long inline-code tokens with no spaces + (package coordinates, fully-qualified class names, URLs) now break *within* their table + cell or paragraph — at `.` `:` `/` `-` seams, char-splitting only as a last resort — instead + of overflowing the neighbouring column or the right page margin; `auto()` engine table + columns also grow to fit composed cell content. No public API change. (Also shipped as the + `0.3.1` patch on the 0.3.x line.) + ## v0.3.1 — 2026-07-06 Patch release: picks up the GraphCompose 1.9.1 engine, which stops long inline-code from diff --git a/README.md b/README.md index 1f3ad92..66c12c1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Markdown ──Flexmark──▶ Flexmark AST ──mapper──▶ Semantic mod types downstream) ``` -> Status: `0.3.1` released. The API may still change before `1.0.0`. +> Status: `0.4.0` released. The API may still change before `1.0.0`. ## Showcase @@ -83,7 +83,7 @@ Maven (once released): io.github.demchaav graph-compose-markdown - 0.3.1 + 0.4.0 ``` @@ -100,7 +100,7 @@ import java.nio.file.Path; String md = """ # Release notes - GraphCompose **1.9** ships *themeable* Markdown rendering. + GraphCompose **2.0** ships *themeable* Markdown rendering. - Headings, lists and `inline code` - Syntax-highlighted code blocks diff --git a/assets/readme/manual-dark.pdf b/assets/readme/manual-dark.pdf index 58fac93..9b992c6 100644 Binary files a/assets/readme/manual-dark.pdf and b/assets/readme/manual-dark.pdf differ diff --git a/assets/readme/manual.pdf b/assets/readme/manual.pdf index 6d0db0c..333b97d 100644 Binary files a/assets/readme/manual.pdf and b/assets/readme/manual.pdf differ diff --git a/cli/pom.xml b/cli/pom.xml index df76919..6fea27f 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-markdown-cli - 0.3.1 + 0.4.0 jar graph-compose-markdown CLI @@ -28,7 +28,7 @@ 17 UTF-8 - 0.3.1 + 0.4.0 4.7.6 6.1.1 3.27.7 diff --git a/docs/theming.md b/docs/theming.md index 5d4b1c7..8631120 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -26,7 +26,7 @@ Pure cosmetic values, grouped: | `TypographyTokens` | body / heading / code font families, body & code sizes, line spacing, the six heading sizes | | `SpacingTokens` | block gaps, paddings, table cell padding | | `ShapeTokens` | corner radii, border/line weights | -| `PageTokens` | page size, margins, content width | +| `PageTokens` | page size, margins, content width, `keepHeadingWithNext` | | `SyntaxColors` | code highlight colors (keyword, string, comment, number, annotation, function) | Swap a token group to reskin everything that derives from it. Tokens are immutable @@ -38,6 +38,16 @@ MarkdownTokens tokens = base.tokens() .withSyntax(SyntaxColors.defaultDark()); ``` +`PageTokens.keepHeadingWithNext` (default `true`) is the one non-cosmetic token: it +decides whether a heading may be left stranded as the last block on a page, apart +from the content it introduces. Set it to `false` for the plain flow: + +```java +PageTokens page = base.tokens().page(); +MarkdownTokens tokens = base.tokens().withPage( + new PageTokens(page.pageSize(), page.margin(), page.contentWidth(), false)); +``` + ## Layer 2 — component styles (`MarkdownStyles`) `MarkdownStyles` derives per-element styles (`CodeBlockStyle`, `QuoteStyle`, diff --git a/examples/README.md b/examples/README.md index 02a15ff..4f62b98 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,7 +11,7 @@ From the repository root: ./mvnw -B -ntp -DskipTests install ``` -This installs `io.github.demchaav:graph-compose-markdown:0.3.1` into your +This installs `io.github.demchaav:graph-compose-markdown:0.4.0` into your local Maven repository, which these examples depend on. ## 2. Run an example diff --git a/examples/pom.xml b/examples/pom.xml index 5fe91eb..3291845 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -15,7 +15,7 @@ --> io.github.demchaav graph-compose-markdown-examples - 0.3.1 + 0.4.0 jar graph-compose-markdown examples @@ -26,7 +26,7 @@ UTF-8 - 0.3.1 + 0.4.0 true true diff --git a/pom.xml b/pom.xml index 1f62e95..1fdf68d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-markdown - 0.3.1 + 0.4.0 GraphCompose Markdown A themeable Markdown document composer powered by the GraphCompose layout engine. @@ -46,7 +46,7 @@ 17 - 1.9.1 + 2.1.1 1.0.0 1.0.0 0.64.8 diff --git a/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java b/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java index 0a61dae..dc4064e 100644 --- a/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java +++ b/src/main/java/io/github/demchaav/markdown/render/BuiltinRenderers.java @@ -1,5 +1,6 @@ package io.github.demchaav.markdown.render; +import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.RichText; import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.image.DocumentImageFitMode; @@ -29,10 +30,12 @@ import io.github.demchaav.markdown.theme.style.InlineStyle; import io.github.demchaav.markdown.theme.style.MarkdownStyles; import io.github.demchaav.markdown.theme.tokens.AlertColors; +import io.github.demchaav.markdown.theme.tokens.PageTokens; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; /** * The default {@link NodeRenderer} registration plus the smaller built-in renderers. @@ -76,6 +79,15 @@ public static void registerDefaults(RendererRegistry registry) { * bookmark (outline entry) at its level so the rendered document gets a navigable * heading tree in the viewer's outline pane, and declares a GitHub-style anchor so * {@code [text](#heading)} links can jump to it. + * + *

Unless the theme turns {@link PageTokens#keepHeadingWithNext()} off, the + * paragraph is wrapped in a one-child section marked + * {@link SectionBuilder#keepWithNext()}, so a heading that would otherwise be + * stranded at the bottom of a page — with nothing or a single line of its body + * under it — moves down to join the block it introduces. The flag lives on the + * section because it is a node-level pagination property and a paragraph cannot + * carry it; the bookmark and the anchor stay on the paragraph, so outline and + * link destinations resolve to the heading's own position exactly as before.

*/ public static final class HeadingRenderer implements NodeRenderer { @Override @@ -88,12 +100,17 @@ public void render(HeadingNode node, SectionBuilder host, RenderContext ctx) { // falling back to an on-the-fly slug if this heading was somehow not planned. String planned = ctx.headingSlug(node); String anchor = planned != null ? planned : ctx.headingAnchor(title); - host.addParagraph(p -> { + Consumer paragraph = p -> { p.rich(rich).margin(new DocumentInsets(above, 0, 0, 0)).anchor(anchor); if (!title.isEmpty()) { p.bookmark(new DocumentBookmarkOptions(title, node.level())); } - }); + }; + if (ctx.tokens().page().keepHeadingWithNext()) { + host.addSection(s -> s.keepWithNext().addParagraph(paragraph)); + } else { + host.addParagraph(paragraph); + } } } diff --git a/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java b/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java index 026f29a..415ea55 100644 --- a/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java +++ b/src/main/java/io/github/demchaav/markdown/theme/tokens/PageTokens.java @@ -6,17 +6,31 @@ import java.util.Objects; /** - * Page geometry tokens. + * Page geometry and pagination tokens. * - * @param pageSize the page size - * @param margin the page margin (top, right, bottom, left) - * @param contentWidth the usable content width in points (page width minus left/right margin) + * @param pageSize the page size + * @param margin the page margin (top, right, bottom, left) + * @param contentWidth the usable content width in points (page width minus left/right margin) + * @param keepHeadingWithNext whether a heading is kept with the block it introduces across a page + * break (a theme can set {@code false} to restore the plain flow) */ -public record PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth) { +public record PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth, + boolean keepHeadingWithNext) { /** Validates the page size and margin are present. */ public PageTokens { Objects.requireNonNull(pageSize, "pageSize"); Objects.requireNonNull(margin, "margin"); } + + /** + * Creates page tokens that keep headings with their content (the default). + * + * @param pageSize the page size + * @param margin the page margin (top, right, bottom, left) + * @param contentWidth the usable content width in points + */ + public PageTokens(DocumentPageSize pageSize, DocumentInsets margin, double contentWidth) { + this(pageSize, margin, contentWidth, true); + } } diff --git a/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java b/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java new file mode 100644 index 0000000..8d139e0 --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/HeadingKeepWithNextTest.java @@ -0,0 +1,213 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import io.github.demchaav.markdown.theme.MarkdownTheme; +import io.github.demchaav.markdown.theme.tokens.MarkdownTokens; +import io.github.demchaav.markdown.theme.tokens.PageTokens; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDNamedDestination; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A heading is never left stranded as the last block on a page, apart from the + * content it introduces. The renderer wraps the heading paragraph in a one-child + * section marked {@code keepWithNext()} — the engine's opt-in orphan rule — so a + * heading that cannot share its page with the first line of the following block + * moves down to join it. + * + *

The filler count is tuned so the heading lands exactly in the window where + * the heading itself still fits but its body's first line does not: that is the + * only situation the rule acts on, and the {@code false} case pins the untreated + * behaviour so the test would fail if the wrapping silently stopped applying.

+ */ +class HeadingKeepWithNextTest { + + /** The filler count that puts the heading at the bottom of page 1 with no room for its body. */ + private static final int ORPHANING_FILLER = 32; + + private static final String HEADING = "Target heading"; + private static final String BODY_OPENING = "Body sentence that belongs"; + + private static String markdown(int fillerParagraphs) { + StringBuilder sb = new StringBuilder("# Doc\n\n"); + for (int i = 0; i < fillerParagraphs; i++) { + sb.append("Filler paragraph number ").append(i) + .append(" with enough words to occupy a full line of the page body text.\n\n"); + } + return sb.append("## ").append(HEADING).append("\n\n") + .append(BODY_OPENING).append(" under the target heading and must not be torn from it.\n\n") + .append("More body so the section is not trivially short and keeps flowing after the break.\n") + .toString(); + } + + private static MarkdownTheme theme(boolean keepHeadingWithNext) { + MarkdownTheme base = DefaultMarkdownTheme.light(); + MarkdownTokens tokens = base.tokens(); + PageTokens page = tokens.page(); + return MarkdownTheme.builder(base) + .tokens(tokens.withPage(new PageTokens( + page.pageSize(), page.margin(), page.contentWidth(), keepHeadingWithNext))) + .build(); + } + + /** + * @return the 1-based page carrying {@code needle} + * @throws AssertionError when no page does — otherwise a broken render would let + * two "not found" results compare equal and pass silently + */ + private static int pageOf(byte[] pdf, String needle) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdf)) { + for (int page = 1; page <= doc.getNumberOfPages(); page++) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(page); + stripper.setEndPage(page); + if (stripper.getText(doc).contains(needle)) { + return page; + } + } + throw new AssertionError("'" + needle + "' is on no page of the rendered document"); + } + } + + private static byte[] render(String markdown, boolean keepHeadingWithNext) { + return MarkdownComposer.builder() + .theme(theme(keepHeadingWithNext)) + .build() + .render(markdown) + .toPdfBytes(); + } + + @Test + void defaultThemeKeepsHeadingsWithTheirContent() { + assertThat(DefaultMarkdownTheme.light().tokens().page().keepHeadingWithNext()).isTrue(); + assertThat(DefaultMarkdownTheme.dark().tokens().page().keepHeadingWithNext()).isTrue(); + } + + @Test + void headingMovesDownRatherThanStrandingAtThePageBottom() throws Exception { + byte[] pdf = render(markdown(ORPHANING_FILLER), true); + + assertThat(pageOf(pdf, HEADING)) + .as("the heading shares a page with the body it introduces") + .isEqualTo(pageOf(pdf, BODY_OPENING)); + } + + @Test + void withoutTheTokenTheHeadingStrandsAtThePageBottom() throws Exception { + byte[] pdf = render(markdown(ORPHANING_FILLER), false); + + assertThat(pageOf(pdf, HEADING)) + .as("the untreated flow leaves the heading behind on the previous page") + .isLessThan(pageOf(pdf, BODY_OPENING)); + } + + /** + * The rule only acts in the narrow window where the heading strands; a heading + * with room for its body is placed exactly as before, on both settings. + */ + @Test + void headingWithRoomForItsBodyIsUnaffected() throws Exception { + String md = markdown(10); + + assertThat(pageOf(render(md, true), HEADING)).isEqualTo(pageOf(render(md, false), HEADING)); + } + + /** + * The outline entry points at the page the heading landed on after relocating, + * rather than the one it left. The wrapper carries the whole heading and moves as + * a unit, so this holds whichever node the bookmark rides — what it guards is the + * relocation itself staying in step with the outline, not the choice of node. + * (That choice is visible only in the destination's Y: a bookmark on the wrapper + * would point at the top of the heading's space-above margin instead of at the + * text, ~8 pt higher for an H2 in the default theme.) + */ + @Test + void theBookmarkFollowsTheRelocatedHeading() throws Exception { + byte[] pdf = render(markdown(ORPHANING_FILLER), true); + int headingPage = pageOf(pdf, HEADING); + assertThat(headingPage).as("the sample really does relocate the heading").isEqualTo(2); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + PDOutlineItem bookmark = doc.getDocumentCatalog().getDocumentOutline().getFirstChild(); + while (bookmark != null && !HEADING.equals(bookmark.getTitle())) { + bookmark = bookmark.getFirstChild() != null && HEADING.equals(bookmark.getFirstChild().getTitle()) + ? bookmark.getFirstChild() + : bookmark.getNextSibling(); + } + assertThat(bookmark).as("the heading has an outline entry").isNotNull(); + assertThat(destinationPage(doc, bookmark)) + .as("the outline entry points at the page the heading landed on") + .isEqualTo(headingPage); + } + } + + /** + * A {@code [text](#slug)} link to the relocated heading resolves to the page it + * landed on. The anchor rides the same paragraph as the bookmark, but it travels + * as a named destination rather than an outline entry, so it is a distinct path. + */ + @Test + void anAnchorLinkFollowsTheRelocatedHeading() throws Exception { + String md = "[jump](#target-heading)\n\n" + markdown(ORPHANING_FILLER); + byte[] pdf = render(md, true); + int headingPage = pageOf(pdf, HEADING); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + List targets = new ArrayList<>(); + for (PDPage page : doc.getPages()) { + for (PDAnnotation annotation : page.getAnnotations()) { + if (annotation instanceof PDAnnotationLink link) { + PDDestination destination = link.getDestination(); + if (destination == null && link.getAction() instanceof PDActionGoTo goTo) { + destination = goTo.getDestination(); + } + if (destination != null) { + targets.add(resolvePage(doc, destination)); + } + } + } + } + assertThat(targets).as("the anchor link resolves to the heading's page") + .containsExactly(headingPage); + } + } + + /** @return the 1-based page an outline item points at */ + private static int destinationPage(PDDocument doc, PDOutlineItem item) throws Exception { + PDDestination destination = item.getDestination(); + if (destination == null && item.getAction() instanceof PDActionGoTo goTo) { + destination = goTo.getDestination(); + } + return resolvePage(doc, destination); + } + + /** @return the 1-based page a destination resolves to, or -1 when it cannot be resolved */ + private static int resolvePage(PDDocument doc, PDDestination destination) throws Exception { + PDDestination resolved = destination; + if (resolved instanceof PDNamedDestination named) { + resolved = doc.getDocumentCatalog().findNamedDestinationPage(named); + } + if (resolved instanceof PDPageDestination pageDestination) { + int number = pageDestination.retrievePageNumber(); + return number >= 0 + ? number + 1 + : doc.getPages().indexOf(pageDestination.getPage()) + 1; + } + return -1; + } +} diff --git a/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java b/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java index 7875bf6..a7fccd2 100644 --- a/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java +++ b/src/test/java/io/github/demchaav/markdown/render/InlineRendererTest.java @@ -6,6 +6,7 @@ import com.demcha.compose.document.node.InlineRun; import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineTextRun; +import com.demcha.compose.document.node.ExternalLinkTarget; import com.demcha.compose.document.node.InternalLinkTarget; import io.github.demchaav.markdown.extension.ImageResolver; import io.github.demchaav.markdown.model.inline.CodeRun; @@ -85,7 +86,10 @@ void inlineImageInsideALinkCarriesTheLinkAnnotation() { InlineImageRun image = (InlineImageRun) rich.runs().stream() .filter(InlineImageRun.class::isInstance).findFirst().orElseThrow(); - assertThat(image.linkOptions()).isNotNull(); // the surrounding link is threaded onto the image + // the surrounding link is threaded onto the image as an external-URI target + assertThat(image.linkTarget()).isInstanceOf(ExternalLinkTarget.class); + assertThat(((ExternalLinkTarget) image.linkTarget()).options().uri()) + .isEqualTo("https://example.com"); } @Test @@ -162,7 +166,11 @@ void externalLinkStillCarriesAUriTarget() { List.of(new LinkRun("https://example.com", null, List.of(new TextRun("site")))), BASE); InlineTextRun run = (InlineTextRun) rich.runs().get(0); - assertThat(run.linkOptions()).as("external link keeps its URI options").isNotNull(); + assertThat(run.linkTarget()) + .as("external link keeps its URI target") + .isInstanceOf(ExternalLinkTarget.class); + assertThat(((ExternalLinkTarget) run.linkTarget()).options().uri()) + .isEqualTo("https://example.com"); } private static byte[] png(int width, int height) {