Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
111bfd4
feat(api): in-PDF navigation — anchors and internal GoTo links
DemchaAV Jun 21, 2026
d8db151
feat(api): inline graphic internal links in RichText
DemchaAV Jun 21, 2026
46f5f83
feat(api): inline graphic internal links on ParagraphBuilder
DemchaAV Jun 21, 2026
7670d49
docs(recipes): in-PDF navigation recipe for anchors and internal links
DemchaAV Jun 21, 2026
6710470
feat(api): inline SVG-icon runs on the text baseline
DemchaAV Jun 21, 2026
8c6f6e4
feat(api): colour emoji by shortcode + graph-compose-emoji module
DemchaAV Jun 21, 2026
0791d3c
docs(examples): runnable colour-emoji shortcode showcase
DemchaAV Jun 21, 2026
5eeb2ce
fix(api): restore deprecated linkOptions() bridges for binary compati…
DemchaAV Jun 21, 2026
662cfd9
docs(examples): SVG-vs-PNG emoji comparison table
DemchaAV Jun 21, 2026
1a45e91
feat(svg): best-effort gradient import for real-world artwork
DemchaAV Jun 21, 2026
1f42687
feat(emoji): bundle the full Noto Emoji set via a gemoji shortcode index
DemchaAV Jun 21, 2026
2594a91
docs(examples): full-set emoji gallery catalogue
DemchaAV Jun 21, 2026
6b01739
feat(svg): keep translucent gradients (opaque stops) instead of flatt…
DemchaAV Jun 21, 2026
2764552
fix(svg): reject clip-path instead of painting unclipped overflow
DemchaAV Jun 21, 2026
9e0f59e
fix(svg): ignore clip-path instead of rejecting the whole icon
DemchaAV Jun 21, 2026
6dadee0
feat(svg): support clip-path and skip display:none subtrees
DemchaAV Jun 21, 2026
d12cdcf
fix(svg): drop same-colour translucent gradient overlays instead of p…
DemchaAV Jun 21, 2026
5669dc9
fix(svg): clip inline SVG icons to their viewBox
DemchaAV Jun 21, 2026
c04a34e
fix(svg): also drop single-stop translucent gradient overlays
DemchaAV Jun 21, 2026
3862687
fix(svg): clip block SVG icons to their viewBox
DemchaAV Jun 21, 2026
0431f63
feat(api): expose clipToBounds on the layer-stack DSL
DemchaAV Jun 21, 2026
da9f9af
ci: install graph-compose-emoji before building the examples module
DemchaAV Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ jobs:
# install graph-compose-fonts into the local repo before building them.
run: ./mvnw -B -ntp -f fonts/pom.xml -DskipTests install

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

- name: Install root artifact
run: ./mvnw -B -ntp -DskipTests install -pl .

Expand Down
151 changes: 151 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,157 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.

## v1.9.0 — unreleased

In-document navigation. Rendered PDFs can now declare named **anchors** and
**internal links** that jump to them — clickable tables of contents,
`[text](#heading)`-style links, and bidirectional footnotes — emitted as native
PDF `GoTo` actions. External links are unchanged.

### Public API

- **In-PDF navigation: anchors + internal links** (`@since 1.9.0`). Every flow
and leaf builder gains `anchor(String)`, declaring a named destination at the
element's top-left — `section.anchor("intro")`, `paragraph.anchor("fn-1")`, and
the same on image / shape / ellipse / line / barcode / table builders. A link
targets an anchor instead of a URI via `RichText.linkTo(text, anchor)` /
`linkTo(text, style, anchor)`, `ParagraphBuilder.inlineLinkTo(text, anchor)` /
`linkTo(anchor)`, and `linkTo(anchor)` on the leaf builders. Inline graphics
inside a paragraph jump to anchors too via `RichText.imageLinkTo(...)` /
`shapeLinkTo(...)` (and the matching `ParagraphBuilder.inlineImageLinkTo(...)` /
`shapeLinkTo(...)`). Anchor resolution
is deferred to the end of the render pass, so a link may target an anchor that
appears later in the document (a forward reference). An unknown anchor renders
as ordinary styled text (no annotation) and logs a warning; a link whose text
wraps produces one annotation per line fragment; a duplicate anchor name keeps
the last registration. Backends without in-document navigation (DOCX) render an
internal link as plain text.
- **Unified `DocumentLinkTarget`** (`@since 1.9.0`). A new sealed
`DocumentLinkTarget` — `ExternalLinkTarget` (wrapping `DocumentLinkOptions`)
and `InternalLinkTarget` (an anchor name) — is now the link type carried
through semantic nodes and resolved layout fragments. `DocumentLinkOptions` is
unchanged and still accepted by every existing `link(DocumentLinkOptions)` and
inline-link DSL method (wrapped into an `ExternalLinkTarget` automatically), so
authoring code is source-compatible. The link accessor on the inline-run
records (`InlineTextRun` / `InlineImageRun` / `InlineShapeRun`) is now
`linkTarget()`; the former `linkOptions()` remains as a deprecated bridge that
returns the external options (or `null` for an internal link).
- **Inline SVG-icon runs** (`@since 1.9.0`). A parsed `SvgIcon` can now sit on
the text baseline inside a paragraph via `RichText.svgIcon(icon, size)` and
`ParagraphBuilder.svgIcon(icon, size)` (with `alignment` / `baselineOffset` /
link overloads, plus a clickable form). `size` is the glyph's height in points;
the width follows the icon's aspect ratio. The icon is drawn as crisp vector
layers carrying their own colours — gradients included — so it renders
independently of the active font's glyph coverage. This is the engine path for
vector colour emoji (e.g. a Twemoji SVG dropped inline) and small vector marks.
A new sealed `InlineRun` variant (`InlineSvgRun`) joins text / image / shape;
the inline render reuses the existing SVG paint pipeline (shared with the block
path fragment), so flat-colour output stays byte-identical.
- **Colour emoji by shortcode** (`@since 1.9.0`). `RichText.emoji(":star:", size)`
and `ParagraphBuilder.emoji(...)` resolve a GitHub-style shortcode to an inline
vector colour glyph. Resolution is lenient — an unknown shortcode (or no emoji
set on the classpath) is rendered 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` (`shortcode=codepoint`) + `emoji/svg/<codepoint>.svg`,
with `find(...)` (lenient `Optional`), `require(...)` (strict), `isAvailable()`
and per-codepoint caching (a glyph using an SVG feature the parser rejects is
treated as unresolved, so it falls back to text rather than failing the render).
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. The module bundles the full
**Noto Emoji** SVG set (~3.7k glyphs, SIL OFL 1.1) with a GitHub-style shortcode
index (~1.6k shortcodes) generated from the gemoji database; both are rebuilt by
`emoji/tools/build-emoji-set.py`.
- **SVG gradient import is now best-effort** (`@since 1.9.0`). `stop-opacity`
(which has no opaque-PDF-shading analogue) is ignored — the gradient renders
with opaque stops — and a focal radial (`fx` / `fy`) approximates as a plain
radial about the centre, instead of failing the whole icon. This lets
real-world artwork import (keeps gradient scenes like `:framed_picture:` /
`:city_sunrise:` looking like scenes rather than flat blobs); fully-opaque
gradients are unchanged, byte for byte.
- **SVG `clip-path` and `display:none` support** (`@since 1.9.0`). A
`clip-path:url(#id)` (including the Adobe-Illustrator `<use>` + `clipPath`
idiom, where the clipPath references a `<defs>` shape) is resolved to a clip
region on each affected `SvgIcon.Layer` and honoured by the inline renderer, so
glyphs that clip detail to a silhouette — hand gestures, body parts, the
probing cane — render correctly instead of overflowing into halos. Hidden
subtrees (`display:none`, e.g. an Illustrator guide layer of registration
hatching) are skipped. Together these take the Noto Emoji set to essentially
the whole bundled set rendering cleanly.
- **Same-colour translucent gradients are dropped, not painted 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, e.g. the hair-edge darkening on the vampire glyphs). With no
shading-alpha in the backend, painting it opaque covered the art beneath (the
vampire's face rendered as a solid hair blob); such layers are now dropped.
Multi-colour gradients (real scenes — `:framed_picture:`, `:sunrise:`,
`:city_sunset:`) are structural and keep rendering as gradients.
- **Inline SVG icons are clipped to their viewBox.** Real-world icon art
(notably Noto's working files) parks geometry outside the viewBox — a browser
clips it to the viewBox, but the inline renderer was painting it, so an icon
could smear copies of itself across adjacent glyphs (`:package:` rendered as
several duplicated boxes overlapping its neighbours). The inline SVG render now
clips each icon to its glyph box, matching SVG `viewBox` semantics.
- **Block SVG icons are clipped to their viewBox too.** The same off-canvas art
bled past the box on the block path (`addSvgIcon(icon, w)` / `SvgIcon.node(w)`),
which had no viewBox clip. A block icon's layer stack now clips its layers to
the icon box: `LayerStackNode` gains an opt-in `clipToBounds` (`@since 1.9.0`,
default off so existing stacks stay byte-identical) and `SvgIcon.node(...)`
sets it. It reuses the `ShapeContainer` clip pipeline — one paired
begin/end marker per icon — so it matches the inline fix above. The same
flag is exposed to the DSL as `LayerStackBuilder.clipToBounds()` — the
`overflow: hidden` of a stacking box for any layer stack.

### Documentation

- New runnable example
`examples/src/main/java/com/demcha/examples/features/navigation/InPdfNavigationExample.java`
— a clickable table of contents plus a bidirectional footnote.
- New runnable example
`examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java`
— multi-colour vector glyphs (gold star, green check badge, violet gradient
orb, info / warning marks) flowing inline with text, at several sizes.
- New `graph-compose-emoji` module bundling the Noto Emoji SVG set (OFL 1.1) with
`emoji/OFL.txt`, `emoji/NOTICE.md` and the `emoji/tools/build-emoji-set.py`
generator that rebuilds the glyphs + shortcode index from noto-emoji + gemoji.
- New runnable example
`examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java`
— `:shortcode:` colour emoji flowing inline with text, the starter-set legend,
the unknown-shortcode text fallback, and several glyph sizes.
- New runnable example
`examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java`
— a `Shortcode | SVG (vector) | PNG (raster)` comparison table, drawing each
starter glyph down both inline paths (`RichText.svgIcon` vs `RichText.image`).
- New runnable example
`examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java`
— a paginated catalogue of the entire bundled emoji set (every indexed glyph,
drawn inline).

### Tests

- `InternalLinkAnchorTest` (PDFBox assertions): forward and backward references
resolve to `GoTo`; an unknown anchor produces no annotation and no crash; the
destination points at the correct page across a page break; a wrapped link
emits an annotation per line fragment; external links still emit `URI`; a
section anchor and a shape internal link are both navigable; a duplicate anchor
keeps the last registration; plus a visual artifact write.
- `InlineSvgRunTest` (run validation: null icon, non-finite / non-positive
dimensions, alignment default, external-link wrapping) and `InlineSvgRenderTest`
(PDFBox end-to-end: text preserved with no glyph substitution, the icon's fill
colour and an inline gradient both rasterize onto the page, a linked icon emits
a clickable annotation, and `svgIcon` sizes by aspect ratio). `InlineSvgRenderTest`
also rasterizes off-canvas geometry to prove the inline glyph-box clip, and the
new `BlockSvgRenderTest` does the same for the block path — off-canvas art does
not bleed, in-box art still paints, the layer stack emits a balanced
`CLIP_BOUNDS` begin/end pair, and a plain (non-icon) stack emits none.
- `EmojiLibraryTest` (resolves shortcodes case-insensitively with/without colons,
unknown → empty, `require` throws, an absent set reports unavailable and names
the `graph-compose-emoji` artifact) and `EmojiRenderTest` (a known shortcode
rasterizes a colour glyph, a gradient emoji paints its shading, an unknown
shortcode falls back to literal text, and `RichText.emoji` yields an
`InlineSvgRun` or a text run accordingly).

## v1.8.0 — 2026-06-18

Codenamed **"illustrative"**. Native vector charts (bar / line / pie, inline
Expand Down
7 changes: 7 additions & 0 deletions aggregator/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
of the engine line; bump only when the font set changes.
-->
<graphcompose.fonts.version>1.0.0</graphcompose.fonts.version>
<!--
Emoji-glyph version inherited by the examples module (which renders
the colour-emoji showcase). Independent of the engine line, like
fonts; bump only when the emoji set changes.
-->
<graphcompose.emoji.version>1.0.0</graphcompose.emoji.version>
</properties>

<!--
Expand All @@ -53,6 +59,7 @@
<modules>
<module>..</module>
<module>../fonts</module>
<module>../emoji</module>
<module>../bundle</module>
<module>../examples</module>
<module>../benchmarks</module>
Expand Down
1 change: 1 addition & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ authoring API; public application code should not import
| [Barcodes](recipes/barcodes.md) | QR / Code 128 / EAN / UPC and friends, tinting, quiet zone |
| [Images](recipes/images.md) | Sources, sizing precedence, fit modes, images in rows and cards |
| [PDF chrome](recipes/pdf-chrome.md) | Metadata, watermarks, running header/footer placeholders, protection, links, bookmarks |
| [In-PDF navigation](recipes/in-pdf-navigation.md) | Anchors + internal `linkTo` links: clickable contents, heading jumps, bidirectional footnotes, inline-graphic links |
| [Translucency](recipes/translucency.md) | `DocumentColor.rgba` / `withOpacity`, alpha coverage, layered tints |
| [DOCX export](recipes/docx-export.md) | Semantic export, node mapping, fallbacks and skipped kinds |
| [Snapshot testing](recipes/snapshot-testing.md) | Layout-snapshot regression testing in consumer projects |
Expand Down
1 change: 1 addition & 0 deletions docs/recipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ API, with copy-pasteable snippets verified against the current release.
| [barcodes.md](barcodes.md) | QR / Code 128 / Code 39 / EAN / UPC / PDF417 / DataMatrix, tinting, quiet zone, card centring |
| [images.md](images.md) | Sources (bytes/path), sizing precedence, STRETCH/CONTAIN/COVER fit modes, images in rows and cards |
| [pdf-chrome.md](pdf-chrome.md) | Metadata, watermarks, running header/footer with `{page}/{pages}/{date}`, protection, links and outline bookmarks |
| [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 |
| [translucency.md](translucency.md) | `DocumentColor.rgba` / `withOpacity`: which primitives honour alpha, byte-identity for opaque colours, layered tints |
| [docx-export.md](docx-export.md) | Semantic DOCX export: 1:1 node mapping, chart/shape-container fallbacks, skipped kinds |
| [snapshot-testing.md](snapshot-testing.md) | Layout-snapshot regression testing in consumer projects, baseline update flow |
Expand Down
104 changes: 104 additions & 0 deletions docs/recipes/in-pdf-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# In-PDF navigation: anchors and internal links

A rendered PDF can link **within the same document**, not just to external
URLs — a clickable table of contents, a `#heading`-style jump, or a
bidirectional footnote. Declare a named **anchor** on any element, then point
an **internal link** at it; the PDF backend resolves each one to a native
go-to action.

## Anchors — named destinations

`anchor(name)` marks an element's top-left as a named destination. It is
available on every flow builder (`pageFlow`, `section`, `module`) and on the
leaf builders (paragraph, image, shape, ellipse, line, barcode, table):

```java
flow.addSection("Introduction", s -> s
.anchor("introduction")
.addParagraph(p -> p.text("Introduction body")));

flow.addParagraph(p -> p.text("Method").anchor("method"));
```

Anchor names are unique per document; a duplicate keeps the last registration,
and a blank name clears the anchor.

## Internal links — jump to an anchor

`RichText.linkTo(text, anchor)` is the in-document counterpart of
`link(text, uri)`:

```java
flow.addRich(rich -> rich
.plain("See the ")
.linkTo("introduction", "introduction")
.plain(" for context."));
```

Resolution is deferred to the end of the render pass, so a link may target an
anchor that appears **later** in the document (a forward reference). An unknown
anchor renders as ordinary styled text with no annotation; a link whose text
wraps produces one clickable rectangle per line.

Paragraph-level and leaf-element links target an anchor the same way:

```java
flow.addParagraph(p -> p.text("Back to the top").linkTo("top"));
flow.addImage(i -> i.source(logo).size(48, 48).linkTo("cover"));
```

## A clickable table of contents

```java
flow.addRich(rich -> rich.plain("1. ").linkTo("Overview", "overview"));
flow.addRich(rich -> rich.plain("2. ").linkTo("Details", "details"));

flow.addSection("Overview", s -> s.anchor("overview")
.addParagraph(p -> p.text("Overview")));
flow.addSection("Details", s -> s.anchor("details")
.addParagraph(p -> p.text("Details")));
```

## Bidirectional footnotes

Anchor the body reference and the note, then link each way with the inline
internal link `inlineLinkTo(text, anchor)`:

```java
flow.addParagraph(p -> p
.anchor("fnref-1")
.inlineText("A claim that needs evidence")
.inlineLinkTo("[1]", "fn-1"));

flow.addParagraph(p -> p
.anchor("fn-1")
.inlineLinkTo("[1]", "fnref-1")
.inlineText(" Supporting evidence for the claim."));
```

Click `[1]` in the body to jump to the note; click `[1]` in the note to jump
back to the citation.

## Inline graphics as links

Inline icons, figures, and images jump to anchors too — `imageLinkTo` /
`shapeLinkTo` on `RichText` (and the matching `inlineImageLinkTo` /
`shapeLinkTo` on `ParagraphBuilder`):

```java
import com.demcha.compose.document.style.ShapeOutline;

flow.addRich(rich -> rich
.plain("Legend ")
.shapeLinkTo(ShapeOutline.circle(7), brand, "notes")
.plain(" — click the dot to jump to the notes."));
```

External links are unchanged: `link(text, uri)` still emits a URI action, and
backends without in-document navigation (the semantic DOCX export) render an
internal link as plain text.

Runnable showcase:
[InPdfNavigationExample](../../examples/src/main/java/com/demcha/examples/features/navigation/InPdfNavigationExample.java)
— a clickable table of contents, a bidirectional footnote, and an inline-shape
link on one page.
5 changes: 5 additions & 0 deletions docs/recipes/rich-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ annotation on supporting backends; `with(text, style, linkOptions)`
combines an explicit style with link metadata. On `ParagraphBuilder`,
`inlineLink(text, options)` is the equivalent low-level call.

For in-document navigation, `linkTo(text, anchor)` points at a named
`anchor(...)` elsewhere in the document instead of a URL, and inline images
and shapes can link to an anchor too (`imageLinkTo` / `shapeLinkTo`). See
[in-pdf-navigation.md](in-pdf-navigation.md).

## Inline images

```java
Expand Down
44 changes: 44 additions & 0 deletions emoji/NOTICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# graph-compose-emoji — NOTICE

## What ships here

The colour-emoji glyphs under `src/main/resources/emoji/svg/<codepoint>.svg` are
the **[Noto Emoji](https://github.com/googlefonts/noto-emoji)** SVG set
(© Google), licensed under the **SIL Open Font License 1.1** — see
[`OFL.txt`](OFL.txt). They are vector SVG (not the CBDT colour *font*, which
PDFBox renders blank), so the engine draws them as crisp inline vectors.

The shortcode index `src/main/resources/emoji/emoji-index.properties`
(`shortcode=codepoint`) maps GitHub-style shortcodes to glyphs, generated from
the **[github/gemoji](https://github.com/github/gemoji)** database (MIT).

The engine resolves these via `com.demcha.compose.document.emoji.EmojiLibrary`
and `RichText.emoji(":rocket:", size)` — it carries no emoji art and has no
Maven dependency on this module.

## Regenerating the set

`emoji/tools/build-emoji-set.py` rebuilds `svg/` + `emoji-index.properties` from
fresh sources — re-run it to track a newer Noto Emoji / gemoji, no engine change:

```bash
# 1) noto-emoji SVGs (sparse, shallow)
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/googlefonts/noto-emoji.git target/noto-emoji
(cd target/noto-emoji && git sparse-checkout set svg)

# 2) gemoji shortcode database
curl -fsSL https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json \
-o target/gemoji.json

# 3) generate the module resources
python emoji/tools/build-emoji-set.py \
--noto target/noto-emoji/svg --gemoji target/gemoji.json \
--out emoji/src/main/resources/emoji
```

The tool copies each `noto svg/emoji_u<cps>.svg` to `emoji/svg/<cps>.svg`
(`_`→`-`), and maps each gemoji alias to its codepoint (dropping the `FE0F`
variation selector, which Noto omits from filenames). Glyphs a real-world SVG
feature the engine's parser cannot handle are skipped at render time and fall
back to the literal shortcode text.
Loading
Loading