v1.9.0 — 2026-06-29
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
-
DocumentSession.pageMargins(List<PageMarginRule>)(@since 1.9.0). Overrides
the page margin for ranges of pages, so one document can mix a full-bleed cover
(PageMarginRule.page(1, DocumentInsets.zero())) with book margins on the body
(PageMarginRule.from(2, …)) — both horizontally and vertically. Pages are 1-based;
rules apply in list order, last-covering-rule wins. Each top-level block is laid out
at the content width of the page it begins on. A document that sets no rules is laid
out exactly as before. -
chrome().viewerPreferences(...)+DocumentViewerPreferences/
DocumentPageMode/DocumentPageLayout(@since 1.9.0). Controls how a PDF
reader presents the document on open — the page mode (USE_OUTLINESopens the
bookmark panel, pairing withbookmark(...)), the page layout (single / one-column
/ two-column / two-page), and the window flags (displayDocTitle,hideToolbar,
hideMenubar,fitWindow,centerWindow). Written to the PDF document catalog;
DocumentViewerPreferences.openOutline()is a one-line preset. PDF-only — other
backends ignore it. A document that sets none is unchanged. -
Container
bookmark(...)(@since 1.9.0).bookmark(DocumentBookmarkOptions)
on any container flow builder (Section/Container/ page flow) — previously
only the seven leaf builders carried a bookmark — adds a PDF outline entry pointing
at that container's start page, making a structured document navigable through the
reader's bookmark panel. Emitted via its own non-visual marker fragment, so it works
even on an unstyled container, and a container without a bookmark is unaffected. -
**
RowBuilder.flexSpacer()/pushRight()/arrangement(...)+RowArrangementSpacerBuilder.grow(...)** (@since 1.9.0). Main-axis (justify-content) layout
for a row. AflexSpacer()(orpushRight()) is an invisible spring that absorbs
the row's leftover width — a title stays left while a badge sits flush right; a
spacer'sgrow(...)factor sets its share.arrangement(START / CENTER / END / SPACE_BETWEEN / SPACE_AROUND / SPACE_EVENLY)justifies content-sized children
instead. Flex is mutually exclusive withweights/columns. The default
(START, no grow) is byte-for-byte unchanged, so existing rows are unaffected.
-
RowBuilder.verticalAlign(...)+RowVerticalAlign(@since 1.9.0). Seats a
row's children on the cross axis within the row band, whose height is that of the
tallest child:TOP(the default),CENTER, orBOTTOM— thealign-items
analogue for a horizontal row, without manual coordinates. The measure phase is
unchanged andTOProws render byte-for-byte as before, so existing documents are
unaffected. -
GraphCompose.documents()+MultiSectionDocumentBuilder/MultiSectionDocument
(@since 1.9.0). Concatenates several independently authoredDocumentSession
sections — each with its own page size, margins, fonts, and footer numbering —
into one PDF inside the engine, with no external PDF merge. Anchors, internal
links, and the bookmark outline resolve across section boundaries against the
combined document, and each section is numbered from its own first page, so a
full-bleed cover of one page size can precede a margined, page-numbered body of
another. Document-level metadata and protection are taken from the first section
that declares them. Single-section output is unchanged.MultiSectionDocument
isAutoCloseableand owns its sections. -
addTableOfContents(...)+TocBuilder/DocumentLeader(@since 1.9.0).
A native, clickable table of contents: eachentry(label, anchor)becomes a row
whose label links to the chapter (linkTo), a dotted or dashed leader fills the
gap, and the page number is resolved automatically from the laid-out document —
no manual two-pass. Built entirely from the existing primitives (auto/weight
columns,line().fill(),addPageReference) and added to the flow, so a long
contents paginates across pages. -
addPageReference(anchor)+PageReferenceNode(@since 1.9.0). Prints the
page a declaredanchor(...)lands on — a native "see page N" cross-reference —
in a single authoring pass. A document that contains a page reference is laid
out twice: the first pass resolves every anchor's page, the second renders the
references with the resolved numbers; the reference reserves only its content
width in both passes, so its own footprint does not shift the pages it reports.
Available on flows (addPageReference(anchor)) and inside rows (the number
column of a table-of-contents row). Documents without a page reference are
unaffected (single pass, byte-identical).pageIndex()remains for programmatic
access. -
RowBuilder.columns(...)+DocumentRowColumn(@since 1.9.0). Size each row
column explicitly:DocumentRowColumn.fixed(pt),auto()(intrinsic content
width), orweight(w)(a share of the space left after the fixed and intrinsic
columns). Mix them freely —columns(auto(), weight(1), auto())with a
line().fill()in the middle is a dot-leader table-of-contents row, with the
label and page number sized to their content.weights(...)stays as sugar for
the even / weighted split (and a weight-only column list resolves identically),
so existing rows are byte-identical. -
LineBuilder.fill()(@since 1.9.0). A line stretches to the width
available where it is placed — its column inside a row, or the content width at
flow level — instead of its authored fixed width. Paired with a dotted stroke
(dashed(0.1, 4).lineCap(ROUND)) it is the flex leader behind a
table-of-contents row, drawn without measuring the gap by hand. A non-fill line
is unchanged, so existing line output stays byte-identical. -
Negative-margin handling (
@since 1.9.0). A negative page margin
(DocumentSession.margin(...)or the builder'smargin(...)) is now rejected
with anIllegalArgumentException— it would make the content area larger than
the sheet, silently overflowing it; use a node'sbleed(...)to reach the page
edge instead. Separately, a negative node bottom margin now pulls the
following content up — symmetric with a negative top margin, which was already
honoured (the vertical flow previously dropped it). Existing documents are
unaffected, since neither shape was usable before. -
DocumentSession.pageIndex()+PageIndex/PageReference(@since 1.9.0).
Resolves every declaredanchor(...)to its final page in a single,
backend-neutral pass over the laid-out document —pageNumberOf("intro")for a
"see page N" cross-reference,forAnchor(...)for the fullPageReference.
Computed from the resolved layout graph (not from rendered PDF bytes) and cached
per layout revision alongsidelayoutSnapshot(). The read-side foundation for
clickable tables of contents and cross-references. A duplicate anchor resolves to
its last registration — the same destination alinkTo(anchor)jumps to. -
DocumentPageNumbering/DocumentPageNumberStyle(@since 1.9.0). Header
and footer{page}/{pages}tokens can now offset, restart, restyle, and
suppress-on-first-page numbering per zone via
DocumentHeaderFooter.builder().numbering(...).DocumentPageNumberingcarries
startAt(printed value on the first counted page),countFrom(physical page
where counting begins),showOnFirstPage, and aDocumentPageNumberStyle
(DECIMAL,LOWER_ROMAN,UPPER_ROMAN,LOWER_ALPHA,UPPER_ALPHA) — e.g.
lower-roman or alphabetic numbering, an uncounted cover, or an offset/restarted
count (one style per zone; switching style mid-document — roman front matter then
arabic body — is a per-section concern). Under
an offset,{pages}expands to the counted total
(startAt + (totalPages - countFrom)), not the physical page count. The default
(DocumentPageNumbering.DEFAULT) is decimal, no offset, shown on every page, so
existing header/footer output is byte-identical. -
LineBuilder.lineCap(DocumentLineCap)(@since 1.9.0). Lines gain the
round / square end-capsPathBuilderalready exposed. PairingROUNDwith a
short dash draws a dotted line —line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)
renders round dots (the standard table-of-contents leader / separator style).
TheBUTTdefault emits no cap operator, so existing line output is
byte-identical. -
Content bleed:
DocumentBleed/DocumentEdge(@since 1.9.0). Flow
builders gainbleed(DocumentBleed)andbleedToEdge(DocumentEdge...), so a
section's background fill extends to the trimmed physical page edge on the
declared sides — a full-bleed masthead band or an edge-to-edge colour panel —
while the section's children stay inside the content margin (text never runs
off the page). It is the content-side twin ofPageBackgroundFilland the
intent-revealing replacement for the hand-computed negative-margin idiom,
resolved against the active page margin at layout time. Nodes that do not bleed
render byte-identically to before. -
In-PDF navigation: anchors + internal links (
@since 1.9.0). Every flow
and leaf builder gainsanchor(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 viaRichText.linkTo(text, anchor)/
linkTo(text, style, anchor),ParagraphBuilder.inlineLinkTo(text, anchor)/
linkTo(anchor), andlinkTo(anchor)on the leaf builders. Inline graphics
inside a paragraph jump to anchors too viaRichText.imageLinkTo(...)/
shapeLinkTo(...)(and the matchingParagraphBuilder.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(wrappingDocumentLinkOptions)
andInternalLinkTarget(an anchor name) — is now the link type carried
through semantic nodes and resolved layout fragments.DocumentLinkOptionsis
unchanged and still accepted by every existinglink(DocumentLinkOptions)and
inline-link DSL method (wrapped into anExternalLinkTargetautomatically), so
authoring code is source-compatible. The link accessor on the inline-run
records (InlineTextRun/InlineImageRun/InlineShapeRun) is now
linkTarget(); the formerlinkOptions()remains as a deprecated bridge that
returns the external options (ornullfor an internal link). -
Inline SVG-icon runs (
@since 1.9.0). A parsedSvgIconcan now sit on
the text baseline inside a paragraph viaRichText.svgIcon(icon, size)and
ParagraphBuilder.inlineSvgIcon(icon, size)(withalignment/baselineOffset/
link overloads, plus a clickable form).sizeis 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 sealedInlineRunvariant (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. -
Inline highlight chips (
@since 1.9.0). An inline run can now sit on a
rounded, padded background fill — the GitHub inlinecodelook and inline
status badges.RichText.highlight(text, style, bg, radius, padding)is the
primitive;code(text)ships engine defaults (a monospace font, a muted ink
and a light chip) andchip(text, fg, bg)colours a badge — with the matching
ParagraphBuilder.inlineHighlight/inlineCode/inlineChip. Ahighlight
overload takesDocumentLinkOptions, so a chip can also be a link. The fill is
a newInlineBackground(fill, cornerRadius, padding)carried by a new sealed
InlineRunvariant,InlineHighlightRun; horizontal padding widens the run's
advance, vertical padding overflows the line box without changing line metrics.
A multi-word chip wraps with the surrounding line, painting one continuous
rounded fill per visual-line fragment (its horizontal padding sits on the run's
outer edges, so a wrapped fragment is open on the inner break). Text-only
backends (DOCX) keep the text and drop the fill. -
Colour emoji by shortcode (
@since 1.9.0).RichText.emoji(":star:", size)
andParagraphBuilder.inlineEmoji(...)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 newEmojiLibrary
(com.demcha.compose.document.emoji): data-driven from the classpath layout
emoji/emoji-index.properties(shortcode=codepoint) +emoji/svg/<codepoint>.svg,
withfind(...)(lenientOptional),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-versionedgraph-compose-emoji
companion module (mirroring thegraph-compose-fontssplit): 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-pathanddisplay:nonesupport (@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 affectedSvgIcon.Layerand 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 SVGviewBoxsemantics. -
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:LayerStackNodegains an opt-inclipToBounds(@since 1.9.0,
default off so existing stacks stay byte-identical) andSvgIcon.node(...)
sets it. It reuses theShapeContainerclip pipeline — one paired
begin/end marker per icon — so it matches the inline fix above. The same
flag is exposed to the DSL asLayerStackBuilder.clipToBounds()— the
overflow: hiddenof a stacking box for any layer stack. -
Render a document straight to images (
@since 1.9.0).DocumentSession
gainstoImages(int dpi)→List<BufferedImage>(one per page) and
toImage(int pageIndex, int dpi)→BufferedImage, plustransparent
overloads (toImages(dpi, transparent)/toImage(pageIndex, dpi, transparent))
that return ARGB instead of opaque white. These rasterize the in-memory document
directly, skipping the previoustoPdfBytes()→ reparse round-trip needed to get
a preview or thumbnail. The return type is the JDKjava.awt.image.BufferedImage,
so the public surface stays renderer-agnostic; the PDFBoxPDFRenderercall lives
in the PDF backend.PdfVisualRegressionalso gains directrenderPages(session)/
assertMatchesBaseline(name, session)overloads on the same path.
Deprecations
templates.api.CoverLetterTemplatemarked@Deprecated(forRemoval = true).
Nothing implements it — the layered cover-letter presets implement the generic
DocumentTemplate<CoverLetterDocumentSpec>seam instead. Removed in 2.0.cv.v2.components.HeadlineRenderer/ContactRenderer/BannerRenderer
(already-deprecated pre-widgets shims) are nowforRemoval— use the
cv.v2.widgetsHeadline/ContactLine/SectionHeaderwidgets instead.
Removed in 2.0.
Documentation
- New runnable flagship example
examples/src/main/java/com/demcha/examples/features/title/BookTemplateExample.java
— a full novel front (full-bleed wave cover, clickable dotted-leader table of
contents with live page numbers, chapters) assembled in oneDocumentSession
using the v1.9 book primitives, with no external PDF merge or two-pass probe. - 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-emojimodule bundling the Noto Emoji SVG set (OFL 1.1) with
emoji/OFL.txt,emoji/NOTICE.mdand theemoji/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
— aShortcode | SVG (vector) | PNG (raster)comparison table, drawing each
starter glyph down both inline paths (RichText.svgIconvsRichText.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).
Build
- The README hero banner is now version-stamped and re-rendered on release.
EngineDeckExamplereads its version and codename from a filtered
banner.properties(@project.version@) instead of hardcoded constants, and
the newReadmeBannerRendererwrites
assets/readme/repository_showcase_render.pngstraight from the engine via
DocumentSession.toImage(...)— no PDF-rasterize round-trip.
cut-release.ps1re-renders and stages the hero on every tag, and
VersionConsistencyGuardTestfails the build if the banner version is ever
hardcoded again.
Tests
InternalLinkAnchorTest(PDFBox assertions): forward and backward references
resolve toGoTo; 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 emitURI; 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) andInlineSvgRenderTest
(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, andsvgIconsizes by aspect ratio).InlineSvgRenderTest
also rasterizes off-canvas geometry to prove the inline glyph-box clip, and the
newBlockSvgRenderTestdoes the same for the block path — off-canvas art does
not bleed, in-box art still paints, the layer stack emits a balanced
CLIP_BOUNDSbegin/end pair, and a plain (non-icon) stack emits none.EmojiLibraryTest(resolves shortcodes case-insensitively with/without colons,
unknown → empty,requirethrows, an absent set reports unavailable and names
thegraph-compose-emojiartifact) andEmojiRenderTest(a known shortcode
rasterizes a colour glyph, a gradient emoji paints its shading, an unknown
shortcode falls back to literal text, andRichText.emojiyields an
InlineSvgRunor a text run accordingly).DocumentSessionImageTest(direct render-to-image):toImages(dpi)returns one
image per page sized to the page at that DPI; dimensions scale with DPI; rendered
pages contain painted (non-background) pixels;transparentyields an ARGB image
with a fully-transparent margin while the default is opaque RGB;toImage(pageIndex, dpi)returns the requested page and is pixel-identical to the matchingtoImages
entry; a post-processed watermark also lands in the raster; the direct render is
pixel-identical to thetoPdfBytes()round-trip (PdfVisualRegression/ImageDiff,
budget 0); anddpi <= 0, an out-of-range page, and an empty document are rejected.