Skip to content

Commit 8cce190

Browse files
authored
feat(api): in-PDF navigation — anchors and internal GoTo links (#217)
* feat(api): in-PDF navigation — anchors and internal GoTo links 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. * feat(api): inline graphic internal links in RichText 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. * feat(api): inline graphic internal links on ParagraphBuilder 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. * docs(recipes): in-PDF navigation recipe for anchors and internal links 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. * feat(api): inline SVG-icon runs on the text baseline 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. * feat(api): colour emoji by shortcode + graph-compose-emoji module 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. * docs(examples): runnable colour-emoji shortcode showcase 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 * fix(api): restore deprecated linkOptions() bridges for binary compatibility 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. * docs(examples): SVG-vs-PNG emoji comparison table 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 * feat(svg): best-effort gradient import for real-world artwork 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. * feat(emoji): bundle the full Noto Emoji set via a gemoji shortcode index 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. * docs(examples): full-set emoji gallery catalogue 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 * feat(svg): keep translucent gradients (opaque stops) instead of flattening 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. * fix(svg): reject clip-path instead of painting unclipped overflow The Adobe-Illustrator <use> + clipPath idiom (used by ~12% of Noto Emoji, e.g. :leg:, :superhero:) 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. * fix(svg): ignore clip-path instead of rejecting the whole icon The previous guard rejected any clip-path glyph — too blunt. Of the ~83 Noto glyphs that use clip-path only a few (e.g. :leg:, :foot:) overflow into garbage; the rest (all the hand gestures, :tornado:, :tulip:, :jack_o_lantern:, 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. * feat(svg): support clip-path and skip display:none subtrees 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. * fix(svg): drop same-colour translucent gradient overlays instead of painting 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. * fix(svg): clip inline SVG icons to their viewBox Real-world icon art parks geometry outside the viewBox — Noto's working file for :package: 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. * fix(svg): also drop single-stop translucent gradient overlays 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. * fix(svg): clip block SVG icons to their viewBox 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. * feat(api): expose clipToBounds on the layer-stack DSL 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. * ci: install graph-compose-emoji before building the examples module 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.
1 parent dfd587a commit 8cce190

3,836 files changed

Lines changed: 415352 additions & 290 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ jobs:
105105
# install graph-compose-fonts into the local repo before building them.
106106
run: ./mvnw -B -ntp -f fonts/pom.xml -DskipTests install
107107

108+
- name: Install graph-compose-emoji (consumed by the examples module)
109+
# The emoji example renders colour emoji from the bundled Noto SVG set;
110+
# like graph-compose-fonts it is a standalone artifact not on Maven
111+
# Central, so install it into the local repo before building examples.
112+
run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install
113+
108114
- name: Install root artifact
109115
run: ./mvnw -B -ntp -DskipTests install -pl .
110116

CHANGELOG.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,157 @@
33
All notable changes to GraphCompose are documented here. Versions
44
follow semantic versioning; release dates are ISO 8601.
55

6+
## v1.9.0 — unreleased
7+
8+
In-document navigation. Rendered PDFs can now declare named **anchors** and
9+
**internal links** that jump to them — clickable tables of contents,
10+
`[text](#heading)`-style links, and bidirectional footnotes — emitted as native
11+
PDF `GoTo` actions. External links are unchanged.
12+
13+
### Public API
14+
15+
- **In-PDF navigation: anchors + internal links** (`@since 1.9.0`). Every flow
16+
and leaf builder gains `anchor(String)`, declaring a named destination at the
17+
element's top-left — `section.anchor("intro")`, `paragraph.anchor("fn-1")`, and
18+
the same on image / shape / ellipse / line / barcode / table builders. A link
19+
targets an anchor instead of a URI via `RichText.linkTo(text, anchor)` /
20+
`linkTo(text, style, anchor)`, `ParagraphBuilder.inlineLinkTo(text, anchor)` /
21+
`linkTo(anchor)`, and `linkTo(anchor)` on the leaf builders. Inline graphics
22+
inside a paragraph jump to anchors too via `RichText.imageLinkTo(...)` /
23+
`shapeLinkTo(...)` (and the matching `ParagraphBuilder.inlineImageLinkTo(...)` /
24+
`shapeLinkTo(...)`). Anchor resolution
25+
is deferred to the end of the render pass, so a link may target an anchor that
26+
appears later in the document (a forward reference). An unknown anchor renders
27+
as ordinary styled text (no annotation) and logs a warning; a link whose text
28+
wraps produces one annotation per line fragment; a duplicate anchor name keeps
29+
the last registration. Backends without in-document navigation (DOCX) render an
30+
internal link as plain text.
31+
- **Unified `DocumentLinkTarget`** (`@since 1.9.0`). A new sealed
32+
`DocumentLinkTarget``ExternalLinkTarget` (wrapping `DocumentLinkOptions`)
33+
and `InternalLinkTarget` (an anchor name) — is now the link type carried
34+
through semantic nodes and resolved layout fragments. `DocumentLinkOptions` is
35+
unchanged and still accepted by every existing `link(DocumentLinkOptions)` and
36+
inline-link DSL method (wrapped into an `ExternalLinkTarget` automatically), so
37+
authoring code is source-compatible. The link accessor on the inline-run
38+
records (`InlineTextRun` / `InlineImageRun` / `InlineShapeRun`) is now
39+
`linkTarget()`; the former `linkOptions()` remains as a deprecated bridge that
40+
returns the external options (or `null` for an internal link).
41+
- **Inline SVG-icon runs** (`@since 1.9.0`). A parsed `SvgIcon` can now sit on
42+
the text baseline inside a paragraph via `RichText.svgIcon(icon, size)` and
43+
`ParagraphBuilder.svgIcon(icon, size)` (with `alignment` / `baselineOffset` /
44+
link overloads, plus a clickable form). `size` is the glyph's height in points;
45+
the width follows the icon's aspect ratio. The icon is drawn as crisp vector
46+
layers carrying their own colours — gradients included — so it renders
47+
independently of the active font's glyph coverage. This is the engine path for
48+
vector colour emoji (e.g. a Twemoji SVG dropped inline) and small vector marks.
49+
A new sealed `InlineRun` variant (`InlineSvgRun`) joins text / image / shape;
50+
the inline render reuses the existing SVG paint pipeline (shared with the block
51+
path fragment), so flat-colour output stays byte-identical.
52+
- **Colour emoji by shortcode** (`@since 1.9.0`). `RichText.emoji(":star:", size)`
53+
and `ParagraphBuilder.emoji(...)` resolve a GitHub-style shortcode to an inline
54+
vector colour glyph. Resolution is lenient — an unknown shortcode (or no emoji
55+
set on the classpath) is rendered as the literal text, the way GitHub treats an
56+
unrecognised `:code:`. The resolver is the new `EmojiLibrary`
57+
(`com.demcha.compose.document.emoji`): data-driven from the classpath layout
58+
`emoji/emoji-index.properties` (`shortcode=codepoint`) + `emoji/svg/<codepoint>.svg`,
59+
with `find(...)` (lenient `Optional`), `require(...)` (strict), `isAvailable()`
60+
and per-codepoint caching (a glyph using an SVG feature the parser rejects is
61+
treated as unresolved, so it falls back to text rather than failing the render).
62+
The glyphs ship in a new, independently-versioned **`graph-compose-emoji`**
63+
companion module (mirroring the `graph-compose-fonts` split): the engine carries
64+
no emoji art and has no Maven dependency on it. The module bundles the full
65+
**Noto Emoji** SVG set (~3.7k glyphs, SIL OFL 1.1) with a GitHub-style shortcode
66+
index (~1.6k shortcodes) generated from the gemoji database; both are rebuilt by
67+
`emoji/tools/build-emoji-set.py`.
68+
- **SVG gradient import is now best-effort** (`@since 1.9.0`). `stop-opacity`
69+
(which has no opaque-PDF-shading analogue) is ignored — the gradient renders
70+
with opaque stops — and a focal radial (`fx` / `fy`) approximates as a plain
71+
radial about the centre, instead of failing the whole icon. This lets
72+
real-world artwork import (keeps gradient scenes like `:framed_picture:` /
73+
`:city_sunrise:` looking like scenes rather than flat blobs); fully-opaque
74+
gradients are unchanged, byte for byte.
75+
- **SVG `clip-path` and `display:none` support** (`@since 1.9.0`). A
76+
`clip-path:url(#id)` (including the Adobe-Illustrator `<use>` + `clipPath`
77+
idiom, where the clipPath references a `<defs>` shape) is resolved to a clip
78+
region on each affected `SvgIcon.Layer` and honoured by the inline renderer, so
79+
glyphs that clip detail to a silhouette — hand gestures, body parts, the
80+
probing cane — render correctly instead of overflowing into halos. Hidden
81+
subtrees (`display:none`, e.g. an Illustrator guide layer of registration
82+
hatching) are skipped. Together these take the Noto Emoji set to essentially
83+
the whole bundled set rendering cleanly.
84+
- **Same-colour translucent gradients are dropped, not painted opaque.** A
85+
gradient whose stops are all the same RGB with at least one translucent stop
86+
carries no colour — it is a pure alpha overlay (a soft shadow or edge
87+
highlight, e.g. the hair-edge darkening on the vampire glyphs). With no
88+
shading-alpha in the backend, painting it opaque covered the art beneath (the
89+
vampire's face rendered as a solid hair blob); such layers are now dropped.
90+
Multi-colour gradients (real scenes — `:framed_picture:`, `:sunrise:`,
91+
`:city_sunset:`) are structural and keep rendering as gradients.
92+
- **Inline SVG icons are clipped to their viewBox.** Real-world icon art
93+
(notably Noto's working files) parks geometry outside the viewBox — a browser
94+
clips it to the viewBox, but the inline renderer was painting it, so an icon
95+
could smear copies of itself across adjacent glyphs (`:package:` rendered as
96+
several duplicated boxes overlapping its neighbours). The inline SVG render now
97+
clips each icon to its glyph box, matching SVG `viewBox` semantics.
98+
- **Block SVG icons are clipped to their viewBox too.** The same off-canvas art
99+
bled past the box on the block path (`addSvgIcon(icon, w)` / `SvgIcon.node(w)`),
100+
which had no viewBox clip. A block icon's layer stack now clips its layers to
101+
the icon box: `LayerStackNode` gains an opt-in `clipToBounds` (`@since 1.9.0`,
102+
default off so existing stacks stay byte-identical) and `SvgIcon.node(...)`
103+
sets it. It reuses the `ShapeContainer` clip pipeline — one paired
104+
begin/end marker per icon — so it matches the inline fix above. The same
105+
flag is exposed to the DSL as `LayerStackBuilder.clipToBounds()` — the
106+
`overflow: hidden` of a stacking box for any layer stack.
107+
108+
### Documentation
109+
110+
- New runnable example
111+
`examples/src/main/java/com/demcha/examples/features/navigation/InPdfNavigationExample.java`
112+
— a clickable table of contents plus a bidirectional footnote.
113+
- New runnable example
114+
`examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java`
115+
— multi-colour vector glyphs (gold star, green check badge, violet gradient
116+
orb, info / warning marks) flowing inline with text, at several sizes.
117+
- New `graph-compose-emoji` module bundling the Noto Emoji SVG set (OFL 1.1) with
118+
`emoji/OFL.txt`, `emoji/NOTICE.md` and the `emoji/tools/build-emoji-set.py`
119+
generator that rebuilds the glyphs + shortcode index from noto-emoji + gemoji.
120+
- New runnable example
121+
`examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java`
122+
`:shortcode:` colour emoji flowing inline with text, the starter-set legend,
123+
the unknown-shortcode text fallback, and several glyph sizes.
124+
- New runnable example
125+
`examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java`
126+
— a `Shortcode | SVG (vector) | PNG (raster)` comparison table, drawing each
127+
starter glyph down both inline paths (`RichText.svgIcon` vs `RichText.image`).
128+
- New runnable example
129+
`examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java`
130+
— a paginated catalogue of the entire bundled emoji set (every indexed glyph,
131+
drawn inline).
132+
133+
### Tests
134+
135+
- `InternalLinkAnchorTest` (PDFBox assertions): forward and backward references
136+
resolve to `GoTo`; an unknown anchor produces no annotation and no crash; the
137+
destination points at the correct page across a page break; a wrapped link
138+
emits an annotation per line fragment; external links still emit `URI`; a
139+
section anchor and a shape internal link are both navigable; a duplicate anchor
140+
keeps the last registration; plus a visual artifact write.
141+
- `InlineSvgRunTest` (run validation: null icon, non-finite / non-positive
142+
dimensions, alignment default, external-link wrapping) and `InlineSvgRenderTest`
143+
(PDFBox end-to-end: text preserved with no glyph substitution, the icon's fill
144+
colour and an inline gradient both rasterize onto the page, a linked icon emits
145+
a clickable annotation, and `svgIcon` sizes by aspect ratio). `InlineSvgRenderTest`
146+
also rasterizes off-canvas geometry to prove the inline glyph-box clip, and the
147+
new `BlockSvgRenderTest` does the same for the block path — off-canvas art does
148+
not bleed, in-box art still paints, the layer stack emits a balanced
149+
`CLIP_BOUNDS` begin/end pair, and a plain (non-icon) stack emits none.
150+
- `EmojiLibraryTest` (resolves shortcodes case-insensitively with/without colons,
151+
unknown → empty, `require` throws, an absent set reports unavailable and names
152+
the `graph-compose-emoji` artifact) and `EmojiRenderTest` (a known shortcode
153+
rasterizes a colour glyph, a gradient emoji paints its shading, an unknown
154+
shortcode falls back to literal text, and `RichText.emoji` yields an
155+
`InlineSvgRun` or a text run accordingly).
156+
6157
## v1.8.0 — 2026-06-18
7158

8159
Codenamed **"illustrative"**. Native vector charts (bar / line / pie, inline

aggregator/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
of the engine line; bump only when the font set changes.
4040
-->
4141
<graphcompose.fonts.version>1.0.0</graphcompose.fonts.version>
42+
<!--
43+
Emoji-glyph version inherited by the examples module (which renders
44+
the colour-emoji showcase). Independent of the engine line, like
45+
fonts; bump only when the emoji set changes.
46+
-->
47+
<graphcompose.emoji.version>1.0.0</graphcompose.emoji.version>
4248
</properties>
4349

4450
<!--
@@ -53,6 +59,7 @@
5359
<modules>
5460
<module>..</module>
5561
<module>../fonts</module>
62+
<module>../emoji</module>
5663
<module>../bundle</module>
5764
<module>../examples</module>
5865
<module>../benchmarks</module>

docs/recipes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ authoring API; public application code should not import
2525
| [Barcodes](recipes/barcodes.md) | QR / Code 128 / EAN / UPC and friends, tinting, quiet zone |
2626
| [Images](recipes/images.md) | Sources, sizing precedence, fit modes, images in rows and cards |
2727
| [PDF chrome](recipes/pdf-chrome.md) | Metadata, watermarks, running header/footer placeholders, protection, links, bookmarks |
28+
| [In-PDF navigation](recipes/in-pdf-navigation.md) | Anchors + internal `linkTo` links: clickable contents, heading jumps, bidirectional footnotes, inline-graphic links |
2829
| [Translucency](recipes/translucency.md) | `DocumentColor.rgba` / `withOpacity`, alpha coverage, layered tints |
2930
| [DOCX export](recipes/docx-export.md) | Semantic export, node mapping, fallbacks and skipped kinds |
3031
| [Snapshot testing](recipes/snapshot-testing.md) | Layout-snapshot regression testing in consumer projects |

docs/recipes/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ API, with copy-pasteable snippets verified against the current release.
2323
| [barcodes.md](barcodes.md) | QR / Code 128 / Code 39 / EAN / UPC / PDF417 / DataMatrix, tinting, quiet zone, card centring |
2424
| [images.md](images.md) | Sources (bytes/path), sizing precedence, STRETCH/CONTAIN/COVER fit modes, images in rows and cards |
2525
| [pdf-chrome.md](pdf-chrome.md) | Metadata, watermarks, running header/footer with `{page}/{pages}/{date}`, protection, links and outline bookmarks |
26+
| [in-pdf-navigation.md](in-pdf-navigation.md) | Named `anchor(...)` destinations + internal `linkTo(...)` links: clickable tables of contents, `#heading`-style jumps, bidirectional footnotes, inline-graphic links — native PDF GoTo actions |
2627
| [translucency.md](translucency.md) | `DocumentColor.rgba` / `withOpacity`: which primitives honour alpha, byte-identity for opaque colours, layered tints |
2728
| [docx-export.md](docx-export.md) | Semantic DOCX export: 1:1 node mapping, chart/shape-container fallbacks, skipped kinds |
2829
| [snapshot-testing.md](snapshot-testing.md) | Layout-snapshot regression testing in consumer projects, baseline update flow |

docs/recipes/in-pdf-navigation.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# In-PDF navigation: anchors and internal links
2+
3+
A rendered PDF can link **within the same document**, not just to external
4+
URLs — a clickable table of contents, a `#heading`-style jump, or a
5+
bidirectional footnote. Declare a named **anchor** on any element, then point
6+
an **internal link** at it; the PDF backend resolves each one to a native
7+
go-to action.
8+
9+
## Anchors — named destinations
10+
11+
`anchor(name)` marks an element's top-left as a named destination. It is
12+
available on every flow builder (`pageFlow`, `section`, `module`) and on the
13+
leaf builders (paragraph, image, shape, ellipse, line, barcode, table):
14+
15+
```java
16+
flow.addSection("Introduction", s -> s
17+
.anchor("introduction")
18+
.addParagraph(p -> p.text("Introduction body")));
19+
20+
flow.addParagraph(p -> p.text("Method").anchor("method"));
21+
```
22+
23+
Anchor names are unique per document; a duplicate keeps the last registration,
24+
and a blank name clears the anchor.
25+
26+
## Internal links — jump to an anchor
27+
28+
`RichText.linkTo(text, anchor)` is the in-document counterpart of
29+
`link(text, uri)`:
30+
31+
```java
32+
flow.addRich(rich -> rich
33+
.plain("See the ")
34+
.linkTo("introduction", "introduction")
35+
.plain(" for context."));
36+
```
37+
38+
Resolution is deferred to the end of the render pass, so a link may target an
39+
anchor that appears **later** in the document (a forward reference). An unknown
40+
anchor renders as ordinary styled text with no annotation; a link whose text
41+
wraps produces one clickable rectangle per line.
42+
43+
Paragraph-level and leaf-element links target an anchor the same way:
44+
45+
```java
46+
flow.addParagraph(p -> p.text("Back to the top").linkTo("top"));
47+
flow.addImage(i -> i.source(logo).size(48, 48).linkTo("cover"));
48+
```
49+
50+
## A clickable table of contents
51+
52+
```java
53+
flow.addRich(rich -> rich.plain("1. ").linkTo("Overview", "overview"));
54+
flow.addRich(rich -> rich.plain("2. ").linkTo("Details", "details"));
55+
56+
flow.addSection("Overview", s -> s.anchor("overview")
57+
.addParagraph(p -> p.text("Overview")));
58+
flow.addSection("Details", s -> s.anchor("details")
59+
.addParagraph(p -> p.text("Details")));
60+
```
61+
62+
## Bidirectional footnotes
63+
64+
Anchor the body reference and the note, then link each way with the inline
65+
internal link `inlineLinkTo(text, anchor)`:
66+
67+
```java
68+
flow.addParagraph(p -> p
69+
.anchor("fnref-1")
70+
.inlineText("A claim that needs evidence")
71+
.inlineLinkTo("[1]", "fn-1"));
72+
73+
flow.addParagraph(p -> p
74+
.anchor("fn-1")
75+
.inlineLinkTo("[1]", "fnref-1")
76+
.inlineText(" Supporting evidence for the claim."));
77+
```
78+
79+
Click `[1]` in the body to jump to the note; click `[1]` in the note to jump
80+
back to the citation.
81+
82+
## Inline graphics as links
83+
84+
Inline icons, figures, and images jump to anchors too — `imageLinkTo` /
85+
`shapeLinkTo` on `RichText` (and the matching `inlineImageLinkTo` /
86+
`shapeLinkTo` on `ParagraphBuilder`):
87+
88+
```java
89+
import com.demcha.compose.document.style.ShapeOutline;
90+
91+
flow.addRich(rich -> rich
92+
.plain("Legend ")
93+
.shapeLinkTo(ShapeOutline.circle(7), brand, "notes")
94+
.plain(" — click the dot to jump to the notes."));
95+
```
96+
97+
External links are unchanged: `link(text, uri)` still emits a URI action, and
98+
backends without in-document navigation (the semantic DOCX export) render an
99+
internal link as plain text.
100+
101+
Runnable showcase:
102+
[InPdfNavigationExample](../../examples/src/main/java/com/demcha/examples/features/navigation/InPdfNavigationExample.java)
103+
— a clickable table of contents, a bidirectional footnote, and an inline-shape
104+
link on one page.

docs/recipes/rich-text.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ annotation on supporting backends; `with(text, style, linkOptions)`
7575
combines an explicit style with link metadata. On `ParagraphBuilder`,
7676
`inlineLink(text, options)` is the equivalent low-level call.
7777

78+
For in-document navigation, `linkTo(text, anchor)` points at a named
79+
`anchor(...)` elsewhere in the document instead of a URL, and inline images
80+
and shapes can link to an anchor too (`imageLinkTo` / `shapeLinkTo`). See
81+
[in-pdf-navigation.md](in-pdf-navigation.md).
82+
7883
## Inline images
7984

8085
```java

emoji/NOTICE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# graph-compose-emoji — NOTICE
2+
3+
## What ships here
4+
5+
The colour-emoji glyphs under `src/main/resources/emoji/svg/<codepoint>.svg` are
6+
the **[Noto Emoji](https://github.com/googlefonts/noto-emoji)** SVG set
7+
(© Google), licensed under the **SIL Open Font License 1.1** — see
8+
[`OFL.txt`](OFL.txt). They are vector SVG (not the CBDT colour *font*, which
9+
PDFBox renders blank), so the engine draws them as crisp inline vectors.
10+
11+
The shortcode index `src/main/resources/emoji/emoji-index.properties`
12+
(`shortcode=codepoint`) maps GitHub-style shortcodes to glyphs, generated from
13+
the **[github/gemoji](https://github.com/github/gemoji)** database (MIT).
14+
15+
The engine resolves these via `com.demcha.compose.document.emoji.EmojiLibrary`
16+
and `RichText.emoji(":rocket:", size)` — it carries no emoji art and has no
17+
Maven dependency on this module.
18+
19+
## Regenerating the set
20+
21+
`emoji/tools/build-emoji-set.py` rebuilds `svg/` + `emoji-index.properties` from
22+
fresh sources — re-run it to track a newer Noto Emoji / gemoji, no engine change:
23+
24+
```bash
25+
# 1) noto-emoji SVGs (sparse, shallow)
26+
git clone --depth 1 --filter=blob:none --sparse \
27+
https://github.com/googlefonts/noto-emoji.git target/noto-emoji
28+
(cd target/noto-emoji && git sparse-checkout set svg)
29+
30+
# 2) gemoji shortcode database
31+
curl -fsSL https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json \
32+
-o target/gemoji.json
33+
34+
# 3) generate the module resources
35+
python emoji/tools/build-emoji-set.py \
36+
--noto target/noto-emoji/svg --gemoji target/gemoji.json \
37+
--out emoji/src/main/resources/emoji
38+
```
39+
40+
The tool copies each `noto svg/emoji_u<cps>.svg` to `emoji/svg/<cps>.svg`
41+
(`_``-`), and maps each gemoji alias to its codepoint (dropping the `FE0F`
42+
variation selector, which Noto omits from filenames). Glyphs a real-world SVG
43+
feature the engine's parser cannot handle are skipped at render time and fall
44+
back to the literal shortcode text.

0 commit comments

Comments
 (0)