v2.2.1 — 2026-08-25
Public API
- The SVG surface graduates from
@Betato Stable.SvgPath,SvgIcon,
PathBuilder.svg(svgPath), bothaddSvgIcon(...)flow adders,
ShapeContainerBuilder.path(w, h, svgPath), and the gradient paints the
reader emits (DocumentPaint.LinearAxis/RadialCircle) drop the
annotation and join the Stable tier — additive-only changes in minors from
here on. The surface shipped in 1.8.0 marked@Beta"while it hardens
against real-world exporter output"; that hardening is done — the stroke,
colour and unit work, per-element error context, the clip-path support
added in 1.9.0, and the opacity-family + warning pass in this release —
and the API shape itself has not moved since 1.9.0, four minors of real
use. The annotation drop also closes an inconsistency:inlineSvgIcon/
RichText.svgIconand the emoji pipeline were built on@BetaSvgIcon
without carrying the marker themselves; now no SVG entry point does. No
binary or source change for callers — the remaining@Betacarriers are
theNodeDefinitionExtension SPI seam and the PPTX backend, exactly as
docs/api-stability.md lists them.
Fixed
-
A PDF now carries the words it draws. Text set in a bundled TrueType family lost
letters from its text layer:Platformextracted asPla orm,certificationas
cer fica on. The page looked right, so nothing showed it — but the text layer is what
a search box, a copy-and-paste, a screen reader and an applicant tracking system all
read, so a CV rendered through one of these families quietly failed to contain the
words printed on it.PDFBox applies a font's
GSUBsubstitutions itself whenever a face carrying them is
made current on a content stream, and most of the bundled families define ligatures
over the commonest English letter pairs —ti,tf,ft. Each pair was drawn as a
single glyph, and the map that says what a glyph stands for is built by reading the
font's character map backwards, where a ligature is reachable from no character at all.
The entry was therefore absent and both letters were lost. The families whose ligatures
happen to have code points of their own (fi,fl) survived, which is why the damage
looked arbitrary.A Latin face is now handed to PDFBox with nothing to substitute. That is also what the
engine already assumed: layout measures a string ligature-blind, so a line drawn with
ligatures was slightly narrower than the box measured for it, and the DOCX and PPTX
backends never substituted. Non-Latin faces are untouched — PDFBox shapes Devanagari,
Bengali and Gujarati through the same mechanism, and there the substitutions are how
the script renders rather than a flourish on top of it.Visible consequence: text set in a bundled family no longer forms ligatures, so
fi
andflare drawn as two letters. PDFBox appliesccmp,ligaandcligtogether
and offers no way to keep one without the others, but in the bundled families the
Latinccmpchanges nothing — decomposed combining sequences and precomposed letters
are drawn exactly as before. The committed visual baselines for the layered CV and
cover-letter presets moved by the ligatures alone and were re-recorded. -
A composite node inside a composed table cell renders its children.
DocumentTableCell.node(...)holding aSectionNode,ContainerNode,
RowNodeorLayerStackNodemeasured the child, reserved its full height,
and then drew nothing inside it — a correctly-sized blank hole in the table.
A composite leaves its children to the compiler and emits only its own
decoration fromemitFragments, so dispatching a composed cell straight at
the child'semitFragmentspicked up the section background and dropped
every paragraph under it. The cell now lays the child's whole sub-tree out
inside the cell box, with the same column / row / stack layout the sub-tree
gets anywhere else on the page. Leaf children (paragraph, list) and nested
tables already worked and are unchanged. The row stays atomic: a composed
cell still does not split across a page break. -
A row nested in a fixed rectangle keeps its horizontal band. A
RowNode
inside aLayerStackNodelayer — allowed since 1.6.2 — stacked its children
downwards instead of seating them side by side, and because the band was
measured as one row tall, every child after the first spilled out of the
layer. The fixed-rectangle walk had no horizontal branch at all: it is a
vertical y-cursor, right for a section or a container and wrong for a row.
It now resolves slot widths through the sameRowSlotspath the page-level
row band uses, so a nested row honours weights, fixed columns, flex
arrangement and vertical alignment identically. Vertical composites in a
fixed rectangle are unchanged. Found while composing a row into a table
cell, which is the second rectangle this walk fills.Behaviour note: a row nested directly inside another row in a fixed
rectangle now raises the sameIllegalStateExceptionthe page-level row
band has always raised ("cannot contain a nested horizontal row", which
names the fix: wrap the inner row in its own layer). It previously
produced a layout instead — but not a usable one: with a two-child inner
row inside a two-child outer row in a layer, two of the three leaves
landed on the same point, 17pt below the layer's own bottom edge. Wrapping
the inner row in its ownLayerStackNodelayer lays it out correctly. -
The SVG reader honours the opacity family.
opacity,fill-opacityand
stroke-opacity— attribute orstyle="", number or percentage, with SVG's
inheritance for the paint slots and composition for groupopacity— now
multiply into each layer's flat paint alpha, on top of any alpha the colour
already carries from an 8-digit hex orrgba(). They were read from nowhere
before: a translucent logo landed on the page fully opaque, and the only fix
was editing the SVG. A slot whose product reaches zero is treated like
fill="none", so anopacity="0"guide layer no longer paints at all. A
partial opacity cannot reach a gradient slot — theDocumentPaintcontract
refuses translucent stops because shadings carry no alpha — so a gradient
underfill-opacity="0.5"still paints opaque, now with a one-line warning
naming the approximation. Group opacity is the per-layer approximation of SVG's
offscreen compositing — overlapping siblings inside one translucent group
darken where a browser would flatten them first — which is the same trade
every lightweight icon reader makes. -
What the SVG reader cannot honour now says so.
fill-rule="evenodd"was
read from nowhere and filled with non-zero winding — a donut whose hole is cut
by a same-direction subpath came out solid, silently; it still renders the
same way, but the icon now logs one warning naming the approximation (a
fill-rulevalue outside nonzero / evenodd / inherit, previously unread, is
now refused like any other bad presentation value). Amask="url(#id)"or
filter="url(#id)"attribute — whose definition sits in<defs>, where the
walk never looks — paints unmasked / unfiltered as before, but the
referencing attribute is now read and warned about, because that attribute is
the only place the divergence is observable. Element kinds outside the
reader's vocabulary (mask,pattern,marker,<style>CSS,<a>
wrappers…) joined the skip tally that previously only counted
text / image /use— one warning per kind, and a blank icon's "no drawable
geometry" error names them. The DOCX export gained the inline mirror of its
block-level drop warning: image / shape / SVG runs (emoji included) vanish
from a paragraph by contract, and now say so once per export instead of only
the block path warning. -
SVG reader errors keep their house style at the edges. A malformed hex
colour (#zzz), a non-numericrgb()channel orrgba()alpha, and a
unit-only length (stroke-width="px") leaked the raw
NumberFormatException("For input string: …") past the reader's
name-the-field-and-the-element convention; each now reports what was being
parsed and the offending input, with the JDK detail chained as the cause.
Documentation
-
DocumentTableCell.text("a\nb")is one line, and now says so. The
advanced-tables recipe demonstrated a multi-line cell by putting\ninside
text(...), which renders as a single line — the newline is whitespace
between two words there. The recipe and theDocumentTableCellJavadoc now
name the three cell shapes explicitly:text(...)for one line,
lines(...)for several,node(...)for any registered node (and
ParagraphNodedoes honour\nas a hard break, inside a cell as
anywhere else). The two examples that showed the misleading form were
switched tolines(...), and the composed-cell showcase gained a section
and a row inside table cells. -
The SVG Javadoc stopped describing a younger reader.
SvgGradientsclaimed
focal radials andstop-opacityare "loudly refused" — both degrade
deliberately (centred-radial approximation, opaque stops, alpha-only overlay
layers dropped) and the class doc now says what actually happens.SvgIcon
still listed clip paths as out of scope and the skip-tally warning said "no
clips" —clip-path:url(#id)has rendered since 1.9.0, so the supported list
gained it (with its innermost-wins nesting rule) and the out-of-scope list,
the warning, and the empty-icon error text shrank to what is actually
dropped.
Build
-
The weekly benchmark run builds the modules it measures. The JMH workflow
installed the engine from source and then let Maven resolve the rest from Central,
and one of them is not there to resolve: the benchmarks read their document fixtures
out of thetests-classifier jar ofgraph-compose-templates, which the templates
release profile unbinds precisely so it is never published. The job died at
dependency resolution before a single benchmark ran: five of the six weekly runs
since the 2.0 module split reached the default branch failed that way, on four
different versions, each looking for the jar under the version the release before it
had just published. It now installsrender-pdfandtemplatesfrom source the way
the two benchmark jobs in the main pipeline already do.BenchmarkDependencyInstallGuardTestreads every workflow and fails when a job
buildsbenchmarks/pom.xmlwithout first installing each first-party dependency the
benchmarks pom declares. It takes that list from the pom rather than a copy of it, so
a new sibling dependency is guarded the day it is added, and it runs in the guard job
on every pull request — which is what the workflow itself cannot do, running only
from the default branch on a schedule.