Skip to content

feat(api): in-PDF navigation — anchors and internal GoTo links#217

Merged
DemchaAV merged 22 commits into
developfrom
feature/internal-links-anchors
Jun 21, 2026
Merged

feat(api): in-PDF navigation — anchors and internal GoTo links#217
DemchaAV merged 22 commits into
developfrom
feature/internal-links-anchors

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A rendered PDF could only link to external URIs. Anything meant to jump
within the same document — a clickable table of contents, a #heading
link, a footnote reference — was dead text: DocumentLinkOptions rejects a
schemeless target like #intro, and the PDF link writer only ever emitted
PDActionURI, never PDActionGoTo.

What changed

  • Unified link carrier. New sealed DocumentLinkTarget
    ExternalLinkTarget (wraps DocumentLinkOptions) and InternalLinkTarget
    (an anchor name) — is now the link type carried through semantic nodes and
    resolved layout fragments. DocumentLinkOptions and every existing
    link(DocumentLinkOptions) / inline-link DSL method stay and wrap
    automatically, so authoring code is source-compatible; the inline-run
    linkOptions() accessor remains as a deprecated bridge returning the
    external options (or null for an internal link).
  • Anchors. 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 AnchorMarkerPayload fragment and recorded by
    PdfAnchorMarkerRenderHandler.
  • Internal links. RichText.linkTo(text, anchor),
    ParagraphBuilder.inlineLinkTo(...) / linkTo(...), and linkTo(...) on
    the leaf builders target an anchor. Inline graphics link too —
    RichText.imageLinkTo(...) / shapeLinkTo(...) and the matching
    ParagraphBuilder methods (a typed InternalLinkTarget resolves the
    canonical run constructor unambiguously, so no overload clash with the
    external link(DocumentLinkOptions) surface).
  • Two-pass resolution. Anchors register during the fragment render loop;
    internal links defer to a post-pass (PdfInternalLinkWriter) and resolve to
    PDActionGoTo(PDPageXYZDestination, zoom 0), so a link may target an anchor
    that 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. New InternalLinkAnchorTest
asserts via PDFBox: forward and backward references resolve to GoTo; the
destination 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-shape
and table anchors are all navigable; a duplicate anchor keeps the last
registration; an anchored paragraph that splits across pages emits a single
head destination. DocumentLinkTargetDslTest guards the DSL → node mapping
(including link(null) staying unambiguous). Example InPdfNavigationExample
renders a clickable table of contents plus a bidirectional footnote; recipe
docs/recipes/in-pdf-navigation.md documents the feature. CHANGELOG.md has
the v1.9.0 entry; new public API carries @since 1.9.0.

Lane: canonical (document.dsl / document.node) + shared-engine
(document.layout payloads/definitions, document.backend.fixed.pdf) — the
new public navigation API plus its layout-fragment and PDF-backend plumbing.

DemchaAV added 4 commits June 21, 2026 10:19
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");
DemchaAV added 18 commits June 21, 2026 13:34
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.
@DemchaAV DemchaAV force-pushed the feature/internal-links-anchors branch from 1deddb8 to da9f9af Compare June 21, 2026 23:16
@DemchaAV DemchaAV merged commit 8cce190 into develop Jun 21, 2026
11 checks passed
@DemchaAV DemchaAV deleted the feature/internal-links-anchors branch June 21, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants