v2.2.2 — 2026-08-27
Public API
-
The layout snapshot can now say what the text became. It could already say that a
block moved; it could never say why the block is the size it is, because the thing that
decides that — which font, at what size, broken into how many lines — was measured
during layout and then discarded. A wrong font and a wrong padding produce the same
symptom on the page, and telling them apart by eye is exactly the guessing a measured
snapshot exists to end.It is opt-in, and
LayoutSnapshotdid not change shape. A diagnostic section that
appeared on its own would turn every consumer's snapshot suite red on an upgrade that
moved nothing in their document:LayoutSnapshot plain = document.layoutSnapshot(); // exactly as before LayoutDiagnosticSnapshot rich = document.layoutSnapshot( LayoutSnapshotOptions.builder().typography(true).build()); rich.layout().equals(plain); // true
The diagnostics live on a new
LayoutDiagnosticSnapshotthat wraps the layout
snapshot rather than onLayoutSnapshotitself. That distinction is the guarantee:
LayoutSnapshotstill has exactly the four components it had in 2.0, so its JSON, its
toString()and itsequalsare unchanged however you serialize it — through
LayoutSnapshotJson, through anObjectMapperof your own, or by hand. Every committed
baseline in this repo is unchanged, and nothing added here can reach one of yours.LayoutDiagnosticSnapshot.formatVersionversions the envelope independently of the
layout snapshot's2.0, so a section added later moves one number and not the other.
LayoutSnapshotOptionsis a builder rather than an overload so that next section costs
a method rather than a newlayoutSnapshot(...)signature.LayoutDiagnosticSnapshot.typography()is a list ofLayoutTypographySnapshot, one
entry per resolved paragraph fragment: the declared font, the resolved family, the
decoration, the size, the line count, the bounds of the laid-out line boxes, and a
LayoutTextLineSnapshotper line carrying its own bounds and baseline in absolute page
coordinates.It hangs off fragments rather than nodes because that is what text is — a paragraph
broken across a page boundary has one fragment per page, each with its own lines, and a
per-node projection would have to keep one and discard the other. Join it to
layout().nodes()onpath, one-to-many. Entries are ordered by path, then page, then
emission ordinal:
a split paragraph restarts its ordinal at zero on each page, so page has to be in the
key or the order falls back to whatever order pagination emitted fragments in.declaredFont,resolvedFamilyanddecorationare three fields because the face
needs all three. A standard-14 face such asHELVETICA_BOLDis an alias of its family
and contributes nothing on its own — the face comes from the decoration — so a style
that names the bold face and sets no decoration renders regular, silently.
fontSubstitutedreports that, and only that: naming the bold face and asking for
bold draws exactly what it named and is not flagged. Reporting the family alone could
not tell those two apart, norHelvetica + DEFAULTfromHelvetica + BOLD. The family
rule is reachable asFontLibrary.resolveFamily(FontName), pure and silent so a
diagnostic pass emits no warnings of its own. A font that is neither registered nor
aliased never reaches the snapshot: measurement fails first, loudly.resolvedFamily,decorationandfontSizedescribe the text the engine actually
measured — after anautoSizeshrink, and after a span-level override — so the reported
size always matches the line boxes beside it.declaredFontstays what the paragraph
asked for.The limits, stated rather than implied. A paragraph using a non-default
TextVerticalAlignhas its glyphs shifted by a correction read from the backend font's
cap height, which nothing renderer-neutral can compute; those lines carry
baselineExact = false, and because the shift moves the glyphs and not the line box the
whole entry is positional there rather than a bound on painted output. The bounds are
laid-out line boxes, not tight glyph ink — a code chip's fill extends past them by its
own padding. Text drawn outside the paragraph pipeline, such as a table cell written as
a plain string, produces no entry, so an empty list means "no paragraph text" rather
than "no text". Coordinates are the laid-out ones, so a transformed or clipped container
is not reflected. A paragraph whose spans mix fonts is described by its first span.The line's own text is deliberately not included. A snapshot excludes raw text payload,
the words are already in the document that produced it, and a line is identified by its
index within the fragment.The vertical line walk moved into
ParagraphLineGeometry(contentTop,nextLineTop,
baselineY) and the PDF handler now draws through it, so the snapshot and the page
cannot describe different lines. That helper already existed for the horizontal half,
for exactly this reason.No rendered output changed, and no layout, pagination or render behaviour changed.