feat(api): in-PDF navigation — anchors and internal GoTo links#217
Merged
Conversation
Rendered PDFs only supported external-URI links, so anything meant to jump within the same document (a table of contents, a #heading link, a footnote) was dead text. Add named anchors and internal links that the PDF backend emits as native GoTo actions. What changed: - Unify the link carrier into a sealed DocumentLinkTarget (ExternalLinkTarget wrapping DocumentLinkOptions, InternalLinkTarget carrying an anchor name) across semantic nodes and resolved fragments. DocumentLinkOptions and every existing link(...) DSL method stay and wrap automatically, so authoring code is source-compatible; the inline-run linkOptions() accessor is kept as a deprecated bridge. - anchor(String) on every flow and leaf builder declares a named destination at the element's top-left. RichText.linkTo(...), ParagraphBuilder.inlineLinkTo(...)/linkTo(...), and linkTo(...) on the leaf builders target an anchor instead of a URI. - Anchors emit a non-visual AnchorMarkerPayload; internal links defer to a post-pass (PdfInternalLinkWriter) so a link may target an anchor that appears later in the document (forward reference). Unknown anchor renders as styled text and warns; a wrapped link emits one annotation per line fragment; a duplicate anchor name keeps the last registration. Verification: ./mvnw verify -pl . green (1416 tests). New coverage in InternalLinkAnchorTest (PDFBox GoTo assertions) and DocumentLinkTargetDslTest. Example: features/navigation/InPdfNavigationExample.
Block image/shape builders could already linkTo(anchor), but an inline icon/figure/image inside a paragraph could only carry an external URL. Add RichText.imageLinkTo(...) and shapeLinkTo(...) so inline graphics jump to a named anchor too. No engine change was needed: inline spans already carry the unified DocumentLinkTarget and the backend already branches external/internal per span, so passing a typed InternalLinkTarget to the canonical run constructor resolves unambiguously (it is not a DocumentLinkOptions). Verification: ./mvnw verify -pl . green (1419 tests). New coverage — inline image + inline shape GoTo assertions in InternalLinkAnchorTest and a shapeLinkTo unit guard in DocumentLinkTargetDslTest. The navigation example gains an inline dot that links to an anchor.
Match the RichText surface on the lower-level ParagraphBuilder: it already had inlineLinkTo (internal text) and inlineImage/shape (external graphics), leaving internal graphics as the only missing quadrant. Add inlineImageLinkTo(...) and shapeLinkTo(...) so an inline image/shape jumps to a named anchor through ParagraphBuilder too, building the run with a typed InternalLinkTarget exactly as RichText does. Verification: ./mvnw verify -pl . green (1420 tests); shapeLinkTo unit guard added in DocumentLinkTargetDslTest.
The feature shipped with a CHANGELOG entry, Javadoc, and a runnable example, but the task-oriented recipes did not cover it — and the recipe index promises one page per shipped feature. Add docs/recipes/in-pdf-navigation.md (named anchors, internal linkTo links, clickable table of contents, bidirectional footnotes, inline-graphic links), list it in both recipe indexes, and point the rich-text recipe's links section at it. Canonical-surface snippets only.
| assertThat(run.linkTarget()).isInstanceOf(InternalLinkTarget.class); | ||
| assertThat(((InternalLinkTarget) run.linkTarget()).anchor()).isEqualTo("intro"); | ||
| // Deprecated external-only bridge returns null for an internal link. | ||
| assertThat(run.linkOptions()).isNull(); |
| void richTextLinkProducesExternalTarget() { | ||
| InlineTextRun run = (InlineTextRun) RichText.text("see ").link("site", "https://example.com").runs().get(1); | ||
| assertThat(run.linkTarget()).isInstanceOf(ExternalLinkTarget.class); | ||
| assertThat(run.linkOptions().uri()).isEqualTo("https://example.com"); |
GitHub-style colour emoji could not render in PDF: the engine had no inline vector path — only inline raster images and primitive shapes — and PDFBox draws colour-emoji fonts blank. RichText.svgIcon(icon, size) and ParagraphBuilder.svgIcon(...) now place a parsed SvgIcon on the text baseline as crisp vector layers (gradients included), independent of the active font's glyph coverage. A new sealed InlineRun variant (InlineSvgRun) flows through measurement (InlineSvgToken), span layout (ParagraphSvgSpan / ResolvedSvgLayer) and render. The render reuses the SVG paint pipeline via a shared PdfPathPainter extracted from the block path handler, so flat-colour block output stays byte-identical. Auto-size width and inline-link rectangles account for the new span. Tests: InlineSvgRunTest (run validation) and InlineSvgRenderTest (fill/gradient rasterise, link rect hugs the icon, aspect sizing, auto-size reserves width); full suite + verify green. Example: InlineSvgIconExample.
Builds on inline SVG-icon runs to render GitHub-style colour emoji in PDF.
RichText.emoji(":star:", size) and ParagraphBuilder.emoji(...) resolve a
shortcode to an inline vector glyph; resolution is lenient — an unknown
shortcode (or no emoji set on the classpath) renders as the literal text,
the way GitHub treats an unrecognised :code:.
The resolver is the new EmojiLibrary (com.demcha.compose.document.emoji):
data-driven from the classpath layout emoji/emoji-index.properties +
emoji/svg/<codepoint>.svg, with lenient find() / strict require(),
isAvailable() and per-codepoint caching. The glyphs ship in a new,
independently-versioned graph-compose-emoji companion module mirroring the
graph-compose-fonts split — the engine carries no emoji art and has no
Maven dependency on it (tests read the module's resources via
<testResources>). The module bundles a small original starter set; the
full jdecked/twemoji set (CC-BY 4.0) is a documented drop-in.
Tests: EmojiLibraryTest (resolution, lenient/strict, absent-set message)
and EmojiRenderTest (shortcode rasterises a colour glyph, gradient paints,
unknown falls back to text); full suite + verify green.
Adds EmojiShortcodeExample rendering :shortcode: colour emoji inline with text — hero, status row, the starter-set legend, the unknown-shortcode text fallback, and a sizing row. The examples module declares the graph-compose-emoji companion artifact (like graph-compose-fonts) so the resolver finds the glyphs on its classpath. Output: examples/target/generated-pdfs/features/text/emoji-shortcodes.pdf
…bility The in-PDF navigation work migrated the inline-run records and shape builders to the unified DocumentLinkTarget, which removed the public linkOptions() accessor (and ShapeBuilder's linkOptions field) — a binary-incompatible removal that japicmp rejects. Re-add linkOptions() as a deprecated bridge on BarcodeNode, EllipseNode, ImageNode, LineNode, ParagraphNode, ShapeNode and TableNode, and keep ShapeBuilder's linkOptions field in sync with linkTarget. Existing bytecode keeps resolving (returning the external options, or null for an internal anchor) while callers migrate to linkTarget(); mirrors the bridges already shipped on InlineImageRun / InlineShapeRun.
Adds EmojiSvgVsPngExample: a Shortcode | SVG (vector) | PNG (raster) table that draws each starter glyph down both inline paths — RichText.svgIcon (native vector) and RichText.image (the glyph rasterised and embedded) — so the two rendering routes sit side by side. Output: examples/target/generated-pdfs/features/text/emoji-svg-vs-png.pdf
SVG exporters (e.g. Adobe Illustrator, as used by Noto Emoji) lean on translucent gradient stops for soft highlights/shadows and on focal radials (fx/fy). The importer rejected both, failing the whole icon. SvgGradients now degrades a gradient with any stop-opacity < 1 to a flat fill (its first stop) and approximates a focal radial as a plain radial about the centre. Fully-opaque, non-focal gradients are unchanged byte for byte. Test: SvgIconTest.translucentAndFocalGradientsDegradeInsteadOfFailing.
Replaces the 8-glyph starter set with the full googlefonts/noto-emoji SVG set (~3.7k glyphs, SIL OFL 1.1) and a ~1.6k-shortcode index generated from github/gemoji, both built by emoji/tools/build-emoji-set.py. RichText.emoji now resolves the standard GitHub shortcodes (:rocket:, :fire:, :heart:, ...) to real colour glyphs; with best-effort gradient import ~99.9% of the set renders. EmojiLibrary treats an unparseable glyph as unresolved (text fallback) and require() reports an accurate reason. EmojiShortcodeExample and EmojiSvgVsPngExample are refreshed to real emoji; EmojiRenderTest asserts colour glyphs paint and unknown codes fall back to text.
Adds EmojiGalleryExample: a paginated grid of every indexed glyph the graph-compose-emoji artifact ships (~1.6k), loaded from the classpath and drawn inline via RichText.svgIcon — a visual catalogue of the whole set. Output: examples/target/generated-pdfs/features/text/emoji-gallery.pdf
…ening Best-effort gradient import first flattened any gradient with a translucent stop to a flat fill (its first stop). That turned gradient "scene" emoji (:framed_picture:, :city_sunrise:, :sunrise:, :milky_way:) into flat blobs. Ignore stop-opacity instead and keep the gradient with opaque stops — the scenes render as scenes and the faces are unaffected. Fully-opaque gradients stay byte-identical. SvgIconTest now asserts the translucent gradient resolves to a LinearAxis.
The Adobe-Illustrator <use> + clipPath idiom (used by ~12% of Noto Emoji, e.g. 🦵, 🦸) has no representation in the flat layer model, and rendering the clipped content unclipped paints overflow/garbage (the visible cross-hatch). Reject a clip-path element loudly so callers fall back (emoji -> literal text) rather than render broken; the clip-free shortcodes render cleanly. Test: SvgIconTest.clipPathIsRejectedSoCallersCanFallBack.
The previous guard rejected any clip-path glyph — too blunt. Of the ~83 Noto glyphs that use clip-path only a few (e.g. 🦵, 🦶) overflow into garbage; the rest (all the hand gestures, 🌪️, 🌷, 🎃, teacher/firefighter variants) read fine with the clip simply ignored. So ignore clip-path and render unclipped — those ~78 glyphs resolve again. Adds EmojiClipPathReportExample: a Codepoint | Glyph | Shortcode table that renders each clip-path glyph as-is, so the few that overflow stand out.
Real-world emoji art (Noto) clips shadow/detail layers to the icon silhouette via the Illustrator <use>+clipPath idiom. Ignoring it painted the larger detail paths unclipped, leaving halos/overflow around hands, feet, the cane, etc. SvgIconReader now resolves clip-path:url(#id) — incl. a clipPath that <use>s a <defs> shape — to a clip region carried on each SvgIcon.Layer, and the inline SVG renderer (PdfPathPainter) confines the paint to it. display:none subtrees (Illustrator guide layers of registration hatching, e.g. in the probing-cane glyph) are now skipped. The inline path now lowers SvgIcon.Layer directly (scaling stroke/dash to points) rather than via PathNode, so the clip travels through. Tests: SvgIconTest gains clip-path-onto-layer and display:none-skipped cases; the ~83 previously-broken clip-path emoji now render cleanly.
…ainting them opaque A gradient whose stops are all the same RGB with at least one translucent stop carries no colour — it is a pure alpha overlay (a soft shadow or edge highlight). The backend has no shading-alpha, so painting it opaque covered the art beneath: the vampire glyphs' hair-edge highlight is a full-head #6D4C41 fade from opacity 0 to 1, which blotted the entire face into a solid hair blob. SvgIconReader now drops such a fill-only layer (SvgGradients gains isAlphaOnlyOverlay). Multi-colour gradients stay structural and keep rendering as gradients, so scenes (:framed_picture:, :sunrise:, :city_sunset:) are unaffected. Tests: SvgIconTest gains monochrome-overlay-dropped and multi-colour-still-renders cases.
Real-world icon art parks geometry outside the viewBox — Noto's working file for 📦 keeps off-canvas box copies (74 of 113 layers fall outside the unit box, spanning normalized x -2.3..4.9). A browser clips to the viewBox; the inline renderer was painting them, so the package smeared duplicate boxes across its neighbours (outbox/inbox/e-mail showed a stray cardboard box behind them). renderSvg now clips each icon to its glyph box before drawing its layers, matching SVG viewBox semantics. Tests: InlineSvgRenderTest gains an off-canvas-geometry-is-clipped case.
isAlphaOnlyOverlay required two or more stops, so a one-stop translucent same-colour gradient — a flat alpha fill, which paint() expands to an opaque flat fill — still blotted the art beneath it. Treat a single translucent stop as an overlay too. Also document that nested clip-paths take the innermost shape (no intersection): this is exact for the Noto set (no glyph nests a different clip) and any residual overflow stays bounded by the inline viewBox clip.
Block-rendered SVG icons (addSvgIcon / SvgIcon.node) had no viewBox clip, so an icon whose art extends past its viewBox — real-world exporter output such as Noto's working files, which park off-canvas geometry outside the unit box — bled past its layout box onto neighbouring content. The inline path already clips to the glyph box; the block path did not. SvgIcon.node now packages the icon as a LayerStackNode with clipToBounds set, and LayerStackDefinition emits a paired ShapeClipBegin/End (CLIP_BOUNDS) around the layers, reusing the ShapeContainer clip pipeline. clipToBounds is an opt-in LayerStackNode flag defaulting off, so existing stacks stay byte-identical; the begin and end markers share one predicate so the graphics-state save/restore pair always balances. BlockSvgRenderTest covers it: off-canvas geometry is clipped away (raster), in-box art still paints, the layer stack emits a balanced clip pair, and a plain stack emits none.
LayerStackNode.clipToBounds — added with the block SVG viewBox clip — was only reachable by constructing the node directly. LayerStackBuilder now offers clipToBounds() / clipToBounds(boolean) so DSL-built stacks can opt into the same behaviour: the overflow: hidden of a stacking box. The flag defaults off, so existing stacks are unchanged. LayerStackBuilderTest covers both: clipToBounds() emits a balanced clip pair around the layers, and the default (and explicit false) emits none.
The examples module depends on io.github.demchaav:graph-compose-emoji:1.0.0, a standalone artifact (like graph-compose-fonts) that is not on Maven Central. The Examples Generation Smoke Test job installed fonts and the root artifact but not emoji, so "Compile examples module" failed to resolve the dependency. Install the emoji module into the local repo first, exactly as fonts already is.
1deddb8 to
da9f9af
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A rendered PDF could only link to external URIs. Anything meant to jump
within the same document — a clickable table of contents, a
#headinglink, a footnote reference — was dead text:
DocumentLinkOptionsrejects aschemeless target like
#intro, and the PDF link writer only ever emittedPDActionURI, neverPDActionGoTo.What changed
DocumentLinkTarget—ExternalLinkTarget(wrapsDocumentLinkOptions) andInternalLinkTarget(an anchor name) — is now the link type carried through semantic nodes and
resolved layout fragments.
DocumentLinkOptionsand every existinglink(DocumentLinkOptions)/ inline-link DSL method stay and wrapautomatically, so authoring code is source-compatible; the inline-run
linkOptions()accessor remains as a deprecated bridge returning theexternal options (or
nullfor an internal link).anchor(String)on every flow builder (section,module,pageFlow) and leaf builder (paragraph, image, shape, ellipse, line,barcode, table) declares a named destination at the element's top-left,
emitted as a non-visual
AnchorMarkerPayloadfragment and recorded byPdfAnchorMarkerRenderHandler.RichText.linkTo(text, anchor),ParagraphBuilder.inlineLinkTo(...)/linkTo(...), andlinkTo(...)onthe leaf builders target an anchor. Inline graphics link too —
RichText.imageLinkTo(...)/shapeLinkTo(...)and the matchingParagraphBuildermethods (a typedInternalLinkTargetresolves thecanonical run constructor unambiguously, so no overload clash with the
external
link(DocumentLinkOptions)surface).internal links defer to a post-pass (
PdfInternalLinkWriter) and resolve toPDActionGoTo(PDPageXYZDestination, zoom 0), so a link may target an anchorthat appears later in the document (a forward reference). Unknown anchor →
ordinary styled text and a warning (never throws); a wrapped link → one
annotation per line fragment; a duplicate anchor name → last registration
wins. Backends without in-document navigation (the semantic DOCX export)
render an internal link as plain text.
Verification
./mvnw verify -pl .→ BUILD SUCCESS, 1420 tests. NewInternalLinkAnchorTestasserts via PDFBox: forward and backward references resolve to
GoTo; thedestination points at the correct page across a page break; an unknown anchor
produces no annotation and no crash; a wrapped link emits one annotation per
line; external links still emit
URI; section, inline-image/shape, block-shapeand table anchors are all navigable; a duplicate anchor keeps the last
registration; an anchored paragraph that splits across pages emits a single
head destination.
DocumentLinkTargetDslTestguards the DSL → node mapping(including
link(null)staying unambiguous). ExampleInPdfNavigationExamplerenders a clickable table of contents plus a bidirectional footnote; recipe
docs/recipes/in-pdf-navigation.mddocuments the feature.CHANGELOG.mdhasthe
v1.9.0entry; new public API carries@since 1.9.0.Lane: canonical (
document.dsl/document.node) + shared-engine(
document.layoutpayloads/definitions,document.backend.fixed.pdf) — thenew public navigation API plus its layout-fragment and PDF-backend plumbing.