Repository navigation
Releases: DemchaAV/GraphCompose
Release list
GraphCompose v2.4.1
v2.4.1 — 2026-09-21
Performance
- A barcode is drawn as vector shapes, not as an image, in PDF and PPTX.
PdfBarcodeFragmentRenderHandlerturned ZXing's bit matrix into a bitmap, onesetRGB
call per pixel, wrote it to PNG withImageIO— which, with its default cache, buffers a
write to a stream through a temporary file injava.io.tmpdir— and passed the PNG to
PDImageXObject.createFromByteArray, which decoded it and compressed the pixels again. The
handler now fills the background as one rectangle and the dark cells, merged row by row
into rectangles, as one path under a single transform to matrix cells: no bitmap, no
image stream, no file. The matrix is the one ZXing produced for the bitmap, stretched over
the fragment box the same way, so every symbology keeps its placement — rasterised on
screen, the modules land on the same pixels and only anti-aliased edges differ — while the
edges stay sharp at any zoom. Measured locally on the feature-rich benchmark document (a
QR code and a Code 128; interleaved A/B against the previous handler, three rounds):
median render 29 → 1.7 ms, allocation per document 7.0 → 0.7 MB, PDF 6.3 → 4.1 KB, and
outliers of up to 390 ms — the temporary-file writes — are gone. The committed barcode
showcase preview drops from 11.4 KB to 4.3 KB.
PptxBarcodeFragmentRenderHandlerplaced a bitmap of the same matrix as a picture, written
to PNG through the same temporary-file path. It now draws the same matrix as two freeform
shapes — the background over the box and the dark cells as one path — so a slide carries a
sharp barcode made of native shapes and no picture.
Colours composite as they did in the bitmap, where each cell is foreground or background
and never both: when the foreground is not opaque, the dark cells are cut out of the
background, so a translucent or transparent foreground lands on whatever lies under the
barcode rather than on the background. A translucent barcode rendered both ways matches at
every cell centre; only anti-aliased cell edges differ. No public API change.
Tests
PdfBarcodeRenderTestrasterises the page and reads the barcode back: each of the eight
formats decodes to its content, every cell of the matrix lands in place and in the chosen
colour for all eight, each translucent cell composites with the page rather than with the
other colour, a transparent background shows the page through, a transparent foreground
cuts the cells out of the background, and translucent colours stay inside the barcode.
BarcodeRunsTestholds the row merge to covering every dark cell exactly once. The
canonical features test now checks the QR code through its drawn rectangles rather than
through the presence of an image.PptxVectorFragmentsTestrenders the slide and scans
the QR code off it, with an opaque and with a transparent foreground, checks a translucent
foreground composites with the slide, and checks the background shape lands on the
fragment box.
Build
-
A CI artifact expires on a schedule that matches what it is for. No
actions/upload-artifactstep declaredretention-days, so all seven inherited the
repository default of 90 days — the ceiling. Artifact storage is billed and capped per
account rather than per repository, and this repository had grown to 10.7 GB across some
2968 live artifacts, which exhausted the shared quota; the failure surfaced where it could
not be diagnosed from, in another repository whose unrelated uploads began failing with
Artifact storage quota has been hit. Two families were nearly the whole bill —
examples-pdfsat 7.57 GB over 1009 artifacts andcoverage-core-aggregateat 2.66 GB
over 837 — and nothing downstream reads any of them: there is noactions/download-artifact
anywhere in.github/workflows/, so each exists for a person to open, and the right window
is however long a person plausibly wants it. Those two now keep 7 days, the span of a
review.japicmp-reportkeeps 30, because it answers "when did this signature move, and
against which baseline" during release prep rather than during the pull request.
benchmark-smokeandbenchmark-gate-reportskeep 14 days together, deliberately the same
number, since the gate verdict in one explains the numbers in the other and a shorter window
on either would leave an investigation holding half a pair. The two weekly trend series,
benchmark-fullandjmh-results, keep the full 90: at one run a week a short window holds
a point or two and shows no trend at all, and they cost tens of KB each. What proves a render
weeks later is the committed layout-snapshot and visual baselines, not a retained artifact. -
Build and test dependencies move with the
maven-minor-patchgroup.exec-maven-plugin
3.6.3 → 3.6.4 inbenchmarks/andexamples/,maven-install-pluginand
maven-deploy-plugin3.1.4 → 3.2.0, and the test-scopebyte-buddypin 1.18.13 → 1.18.14
incore/. Every one is build- or test-scope, so the dependency set a consumer inherits
from a published artifact is what it was in v2.4.0.
Documentation
-
The showcase site's menu and section links reach the gallery, and its pages link
only to files the site publishes. A category section is rendered only while its
filter is shown, so after picking Features the Templates menu link changed the
address and moved nothing. A gallery anchor now selects its filter, whether it is
followed from the menu, reached with Back or Forward, or opened directly, and the
filter pills keep the address in step. The no-JavaScript index linked to three PDFs
the site does not publish, and two featured ids named no card, so the featured strip
showed six of its eight tiles without a sign.ShowcaseSiteGuardTestfails the build
on a featured id that is not a card, a card file missing fromshowcase/, a page link
to a site file that does not exist, and an anchor or filter pill that names nothing
the page shows. The structured data said JVM 21+ where every module targets Java 17,
the page counted 16 CV presets where 26 ship, and the template-authoring links
pointed atdevelopinstead of the released docs onmain. -
The gallery shows each document's whole first page. A card cropped its preview to a
248-pixel band, so a page was judged by its header and a wide slide lost its sides.
The preview now shrinks into a fixed-height box at its own aspect ratio, featured
tiles use the same fit instead of an A4-shaped frame, and the image tags no longer
declare an A4 size that was wrong for 26 of the 117 previews. -
The gallery opens a viewer that pages through one family at a time. A card, a
featured tile or a family tile opens its family (CV, cover letters, invoices and so on)
in a viewer that shows the whole first page, moves with Previous, Next and the arrow
keys, shows where it is in the family, and links the PDF and source of the document
shown. A switch moves to the other families of the category, and each reopens on the
document it was left on. The address#/<category>/<family>/<id>reopens the same
document on a reload or from a shared link and follows Back and Forward. The viewer
replaces the zoom lightbox. A drag across the page moves between documents on a touch
screen, a strip under the page holds every document of the family and marks the one on
screen, and the pages either side are fetched before they are asked for. A reader who
has asked to save data gets neither: no strip of page-sized previews, and nothing fetched
ahead.ShowcaseSiteGuardTestnow also requires unique, address-safe card and family ids,
and holds any viewer address written into a page to a family and document that exist;
scripts/site/gallery-viewer.test.mjstests the addresses, the navigation and the dialog
in CI. -
The catalogue says what each document is, and the gallery stops jumping as it loads.
web/examples.jsoncarries aschemaVersion, and every card now carries the preset it
renders and the model that preset composes, the artifacts a reader needs to run it, the
path to its source, its page count, and the pixel size of its preview — so the image
reserves its slot at the right shape instead of appearing out of nothing, and no single
size stands in for previews that are not all A4. The viewer's strip and the family tiles
read thumbnails generated at 320px rather than whole pages: opening the CV family fetches
1.4 MiB of strip images where it fetched 5.2 MiB.ShowcasePresetRegistrationTestholds
each card's preset and model to the example that builds them — it found two feature cards
asking a reader for the engine alone while rendering a template preset — and
ShowcaseSiteGuardTestfails the build on a manifest without aschemaVersion, a card
whose measurements are not its preview's, a page count below one, a thumbnail that is not
published, or a preset count in the page copy the catalogue does not hold. -
The version the showcase shows is written down once, and a release moves every copy of
it. The published site stated the release in five places that inherit from no pom, and
the cut rewrote the first match of each — so a page carrying a second install snippet
kept it a release behind while every check passed, and a spot that stopped matching was
skipped in silence, leaving the cut to report success on a page still naming the previous
release. An inlinerelease-contextblock now holdsstableVersion,releaseTagand
javaMinimum. The JSON-LD, the Maven Central download link, the hero badge and the
install snippets still repeat the version, because a crawler and a reader with no
JavaScript both have to see the right release — but each is now a copy of that block, and
VersionConsistencyGuardTestholds every occurrence of all seven spots equal...
GraphCompose v2.4.0
v2.4.0 — 2026-09-14
2.4.0 leads with a much larger template line-up. Two
families join invoice, proposal, CV and cover letter: receipt, for payment
confirmations (ModernReceipt), and rota, a staff shift schedule (CobaltRota) on a
new templates.data.rota model that replaces the data.schedule records nothing rendered.
The invoice, proposal and CV families gain presets, and the structured invoice and proposal
models grow to carry what those designs print. Every CV preset is now either
ATS-friendly or design-first, a classification made by reading its showcase sample
with three resume parsers; the showcase marks the ATS-friendly ones.
The engine work under it is typographic. DocumentTextStyle carries real letter
spacing — PDF Tc, DrawingML spc, Word w:spacing — so spaced caps copy and search as
the word they are instead of letters padded with spaces, and the built-in CV and
cover-letter presets use it. A timeline's rail is one line resolved from where its markers
landed, and the timeline can put a column before its markers, size the marker column in
points and set the gap before a marker on its own. A list can hang its wrapped lines under
its text, style an item in pieces, and draw its markers in a colour of their own.
graph-compose-templates joins graph-compose-core under the binary-compatibility gate.
Public API
- A proposal block carries the paragraph that opens it.
- A proposal carries the header a one-page sales proposal has.
- An invoice line carries where it was delivered.
- A supplier carries the legal line it prints under its name.
- A billed party carries a printed registration.
- An invoice line carries a mark.
CvSkillcarries the level as the document words it.- The structured invoice model carries what a second sheet needs.
CvEntrycarries a link.CvEntrycarries a location and a mark, and gains a builder.CvIdentitycarries an optional portrait.- A structured invoice document model.
- A structured proposal document model.
- A rota document model, replacing
data.schedule. - One place turns a printed contact into a followable one:
core.identity.ContactUri. - Text style carries typographic tracking.
- The built-in CV and cover-letter presets now use real tracking, so their text is readable again.
- A list can hang its wrapped lines under its own text instead of under its marker.
- A list item can be styled in pieces.
- A list marker can be drawn, and can carry a colour of its own.
- A timeline's rail is one line, drawn from where its markers landed.
- A timeline's rail is one configuration.
- A timeline marker can be anything you can draw.
- A timeline's marker column can be given a width in points.
- A timeline can put a column before its markers — the
DATEofDATE | ● | CONTENT. - The gap before a timeline's marker is its own number.
- A timeline entry can fill its own content column.
- A vertical flow can pin its width and still grow with its content.
- A chip is sized like the text around it.
inlineStyledChip(text, textStyle, bg)styles a chip that is meant to differ from its paragraph.
Layout
- A resolved anchor on content that spans pages is now that page's slice of the box.
- A timeline's markers now report where they landed, and the marker column gains a level in the node tree.
- A built-in feature can now draw from geometry the layout has already resolved.
- A decorated root flow and its children can disagree under per-page margins.
- A composed table cell sits where its anchor says, and a spanning one stops falling to the foot of its span.
- A row child with a horizontal margin is measured and placed at the same width.
- A margin on composed table-cell content is honoured, and honoured by both passes.
Fixed
- The weekly-schedule board's foot is a footer.
- Five designs dialled a number they had not printed.
markerOnRail()no longer draws the rail through the entry's text.- A timeline marker is the box it declared.
- A timeline inside a card keeps its rail.
- A DOCX export no longer loses the content of a wrapper it cannot draw.
Templates
- Five CV presets name their sections with the headings resume parsers look for.
- Four sidebar CVs draw the name first, and the page looks the same.
- Professional Sidebar and Terracotta Rail carry a longer CV onto more pages.
- A stacked row's body now hangs under its name, not a couple of points to the left of it.
- A long degree title no longer draws over its own institution line on Professional Sidebar.
- Orange Ops' skills are a real list.
- Teal Pulse's dotted lines are a real list, so its one declared gap is the gap it draws.
- New
receiptfamily — payment confirmations. SvgGlyph.fromFile(Path). The classpath variant covers glyphs a template ships with; this covers the glyph a template is given — a receipt's issuer mark, a report's client logo — which arrives as a file beside the running application rather than repackaged into its jar.- The invoice presets set their page number in their own face.
- Seven CV presets make their telephone number dialable.
- A commerce invoice preset:
MerchantInvoice. - A dark invoice preset:
ObsidianInvoice. - A per-seat subscription invoice preset:
SubscriptionInvoice. - A region-billed platform invoice preset:
PlatformInvoice. - A metered-usage invoice preset:
MeteredInvoice. - A violet SaaS invoice preset:
WorkspaceInvoice. - A paginating invoice preset:
PaymentsInvoice. - A navy-plate CV preset:
MidnightNavy. - A two-column operations CV preset:
OrangeOps. - A banded single-column CV preset:
VioletGrid. - A masthead-and-rail CV preset:
SlateOrange. - A clinical CV preset in five bands:
TealPulse. - An architect's two-column CV preset:
TerracottaRail. - The first invoice preset that paginates what it ports:
LumaStudioInvoice. SerifHeadlinelinks its titles and lets its bands breathe.- A photographic CV preset:
CharcoalGold. - A two-column editorial CV preset:
SerifHeadline. - A portrait CV preset:
NavySidebar. - The first CV preset that owns its page:
ProfessionalSidebar. - A second structured proposal preset:
EditorialProposal. - A professional-services invoice preset:
ConsultingInvoice. - A second invoice preset:
ClassicInvoice. - The first structured proposal preset:
NorthlineProposal. - A one-page sales proposal preset:
IndigoProposal. - The first rota preset:
CobaltRota. - The rails seven CV presets drew by hand are timelines.
- Serif Headline's bullet gap is a measurement rather than a space.
- A Timeline Minimal marker that never drew is gone.
- The receipt and both structured proposals stop padding their spaced caps.
Tests
- Letter spacing carried in widths is held to the picture
Tcdraws. - The schedule fixtures no longer carry a real venue's staff.
- The CodeQL scope guard can no longer be emptied by rewriting a deploy command.
- The sidebar CV samples are held to the width of the column they are drawn in.
- Every promoted CV design is asked the same question about its telephone link.
- The timeline's finished visual model is pinned scene by scene.
- A timeline written before the rail moved is guarded against moving.
Deprecations
templates.data.schedule— every type.TextOrnaments.spacedUpper(String)is@Deprecated(since = "2.4.0", forRemoval = true).
Build
graph-compose-templatesis under the binary-compatibility gate.
Documentation
- The timeline recipe describes the finished model.
- Two engine seams are written down, and so is the difference between them.
- A row's width rule, and what
fill()does when there is no slot. - The stability document is guarded, and the pack records a beta package.
- Every CV preset is either ATS-friendly or design-first, and the showcase says which.
- The README leads with a first PDF, then routes by task, and every template family has a door.
Each entry above is the first line of its CHANGELOG entry. The full notes — what changed, why, and how it was verified — are too long for a GitHub Release body; read them in CHANGELOG.md at v2.4.0.
GraphCompose v2.3.0
v2.3.0 — 2026-08-31
Public API
-
A header or footer can name its font family.
DocumentHeaderFooter.fontName(...)
picks the family the zone is typeset in; the PDF and PPTX backends resolve it through
the document's own font library, the same way body text is resolved. Until now a zone
was drawn in standard-14 Helvetica and nothing else, so a footer outside WinAnsi — a
Cyrillic page counter, a Greek imprint — came out as a row of?. The default is
stillFontName.HELVETICAand resolves to the same standard-14 face the zone drew
before, so an existing header or footer renders unchanged down to its placement.
Naming a family is the whole mechanism: there is no automatic fallback here any more
than there is in the body, so a code point the chosen family cannot encode is still
substituted with?. -
DOCX exports a page zone as a real Word footer. The zone the PDF and PPTX backends
draw is now written into aw:ftr/w:hdrpart, which is the first header/footer support
the DOCX exporter has had. The band's children become runs on one Word line: a paragraph
contributes its runs, a flex spacer becomes the right tab stop. Page numbers are the part
that could not simply be copied over — a semantic export hands the node tree to Word and
Word paginates, so a number written as text would be right on one page and wrong on the
rest.PageContext.pageNumber()andpageTotal()return a node instead of anint:
resolved text on a fixed-layout export, a livePAGE/NUMPAGESfield in Word. The same
zone definition therefore serves both lanes, and the field's placeholder run carries the
node's text style, so a styled page number stays styled in Word rather than snapping to
the document default. ReadingPageContext.number()on a semantic
export raisesUnsupportedOperationExceptionnaming the alternative rather than returning
a plausible lie. Node kinds outside the mapped set are skipped and reported. A page
predicate (appliesTo) is fixed-layout capability — there is no page to test it against
when the definition is written — so a zone that carries one is exported to every page,
and the export logs what it could not honor. -
A page zone draws a node subtree instead of text slots.
DocumentPageZone, registered
throughdocument.chrome().zone(...), takes a content function called once per page with
that page'sPageContextand returns anyDocumentNode. The subtree is laid out against a
canvas the size of the band and spliced into the compiled layout graph, so every
fixed-layout backend draws it with no code of its own: a badge, a link annotation, an image,
a shape, a table, right-to-left text and any font the document has all work in a zone
because it is the body's machinery, not a second copy of it. Page numbers come from the
context —page.number() + " / " + page.total()— so the band needs no placeholder tokens,
and anything the tokens could not express (roman numerals, an offset, a different line on
the last page) is ordinary Java in the same lambda. A page reference inside a zone
resolves against the body's anchors, so a footer can say "appendix on page N" about
content it does not itself contain.appliesTo(page -> ...)decides which
pages carry the zone, which separates "is this page numbered" from "is the band drawn" —
the two thatDocumentPageNumberingconflates. The predicate decides painting only: a
reserving zone's band comes out of every page's content area either way, so hiding the
zone on the cover does not reflow the cover. A zone reserves its height by default and
does not paginate: content that needs more than the band raises
AtomicNodeTooLargeExceptionnaming the zone and its height rather than being dropped —
whether the overflow is atomic (a row too tall for the band) or splittable (a paragraph
that would have continued onto a second band).DocumentHeaderFooteris unchanged and
unaffected. -
A header or footer can reserve its height from the content area.
DocumentHeaderFooter.reserveSpace(true)insets the page's content area so the body
is never laid out into the band the zone paints. Until nowheightpositioned the
zone's text and nothing else — it was never subtracted from the content area, so
whether a footer collided with the last line of body text was decided by whichever
page margin the author happened to pick. The content area is inset to the larger of
the margin and the zone's height, not their sum, so a zone that already fits inside
the margin reserves nothing and reflows nothing.DocumentSession.availableHeight()
(andcanvas().innerHeight(), which it still aliases) report the reduced area, so a
composition that sizes itself against the page —TimelineMinimalsplits its columns
this way — sees the space it actually has. Off by default: turning it on can add pages
to a document that was relying on the overlap.
Tests
- Header and footer placement is now guarded.
ChromeGeometryGuardTestpins the
rules a zone is drawn by — left slot flush to the left margin, right slot flush to the
right, centre slot centred between them, one baseline per zone at a fixed offset from
the zone's edge, repeated identically on every page — and records that a zone's
heightreserves nothing from the content area. No template registers a header or
footer, so none of the committed visual baselines covered chrome: a change to how a
zone is positioned used to move text in every consumer's document while passing every
gate in the repository.
Templates
- Monogram Sidebar draws the employer. Its experience entries rendered the position,
the date and the description, and neverCvEntry.subtitle()— so every company name
was missing from the rendered CV while the education block, which does render its
subtitle, looked complete. The employer is now drawn between the position and the date,
in the shared theme entry-subtitle style.
Documentation
-
The presets that cap content say so.
MonogramSidebar,SidebarPortraitand
MintEditorialcompose a fixed amount of content: entries past a per-block cap are not
rendered, do not move to a continuation page, and are reported nowhere. Each preset's
class documentation now names its caps, explains that they are load-bearing — the
columns are one atomicaddRow, so an uncapped block raises
AtomicNodeTooLargeExceptioninstead of spilling onto a second page — and points at
TimelineMinimal, which splits its own columns.docs/templates/v2-layered/using-templates.md
carries the same table under Picking a preset. The caps themselves are unchanged. -
The PDF backend's engine packages carry the
@Internalmarker the policy already
promised.docs/api-stability.mdhas always put the whole
com.demcha.compose.enginetree in the Internal tier — removable in any release, no
deprecation window — and namesengine.render.pdf.*as the part of it that ships from
graph-compose-render-pdf. The annotation itself had stayed behind in the engine
artifact, so nothing in the publishedgraph-compose-render-pdfJavadoc said so.
engine.render.pdfandengine.render.pdf.helpersnow carry the marker at the package
level, the waydocument.layoutdoes, andPdfFont,GlyphFallbackLogger,
PdfHeaderFooterRendererandPdfWatermarkRenderercarry it on the type as well —
Javadoc renders a package annotation on the package page and nowhere else, so the
package marker alone leaves each class page reading as a plain public class.InternalEnginePackageMarkerTestenforces the package half module-locally, in
graph-compose-render-pdfandgraph-compose-render-pptx. A guard in the engine
cannot: it only sees the engine's own classpath. It reads its package list from the
source tree rather than a fixed probe set, so a newly added engine package fails the
build until it is marked. Annotation and documentation only; no signature or behaviour
changed.
GraphCompose v2.2.2
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.
GraphCompose v2.2.1
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 ...
GraphCompose v2.2.0
v2.2.0 — 2026-08-15
Public API
-
A paragraph can say which way it runs.
ParagraphBuilder.direction(...)takes
TextDirection.LTR,RTL, orAUTO, which reads the direction off the first strong
character. Hebrew and Arabic were previously laid out and drawn in logical order — the
order text is read in, not the order a page draws it — so every line came out reversed
in a document that otherwise looked finished.Direction is a separate choice from
TextAlign: alignment says where a line sits,
direction says which way it runs. They meet in one place, so a right-to-left paragraph
aligns right unless the caller chose an alignment of their own.Lines are resolved with the Unicode Bidirectional Algorithm, so a Latin word or a
number embedded in Hebrew keeps running forwards, and the paragraph direction only
decides what it is embedded in. A line with no right-to-left character resolves to
itself without the algorithm running at all, so existing documents take the path they
always took — held to that by the layout snapshots and visual baselines, none of which
moved.A paragraph is the unit this applies to; the sibling entry below carries it into a
table cell.All three wrap paths carry it: plain text, inline runs (what templates author
through), and markdown. Each backend does what it must and no more — the PDF backend
reverses a right-to-left run, because a PDF draws characters in the order it is given
them; PowerPoint and Word have their own bidirectional engines, so the text reaches
them in logical order rather than rewritten. Word is told the paragraph's base
direction withw:bidi, which is the only way it can lay out a line that opens on a
neutral character; PowerPoint is told the same thing per frame, because pinning a span
where the page put it settles the order across the line but not which side a neutral
falls on inside a frame.The bidirectional formatting characters (
U+200E,U+200F,U+061Cand the
embeddings and isolates) now survive control-character sanitizing until the algorithm
has read them. They are what an author uses to steer a neutral stretch of text, and
removing them with the rest of Unicode category C deleted the instruction before
anything could act on it. They draw nothing, so they are dropped again at the seam
that measures and draws — where substituting them with?would have put a visible
mark on the page and given a zero-width character a width.One shaping limit is worth knowing. Letters are given their contextual forms before
the line is wrapped, because wrapping measures widths and the forms are what carry
them. A word longer than the column is therefore broken with the forms it was given
while whole: the letters either side of the break keep their connecting strokes, as if
the word continued across the line boundary. Arabic does not break words, so this only
arises where the word cannot fit at all — the case every script degrades in — and
re-shaping the halves would change their widths, which is what the wrap already spent.One limit was worth knowing here, and this release closes it further down this page:
the content stream carries the visual order, and every reordered run now states its
text as written in anActualTextmarked-content section — see the entry below. The
DOCX export was never affected, since Word receives logical text. -
A table cell can say which way it runs.
DocumentTableStyle.direction(...)takes the
sameTextDirection.LTR,RTLorAUTOa paragraph does, and inherits down the cascade
a cell style already follows — the table's default, then the column's, then the row's,
then the cell's own — so one call turns a whole table round.A cell written as a plain string reached the page through the table's own layout rather
than the text pipeline, and so received neither of the two things that make Hebrew and
Arabic correct: the same string drew reversed in a cell while drawing properly in a
paragraph, and Arabic came out as isolated letters instead of joined. Both now happen on
the cell path, and a column is measured on the joined forms, so an auto column is sized
to the text the page actually draws rather than to a wider form that never appears.The cell is the unit
AUTOreads. Two cells side by side under one declared direction
answer it separately, and a cell's second line does not run the other way from its first
because it happens to open on Latin. Direction decides the edge only when nobody asked
for one — a right-to-left cell sits at its right edge unless it carries atextAnchorof
its own — which is the rule a paragraph already follows for alignment.Each backend does what it must and no more. The PDF is painted, so the engine shapes and
reorders the line itself and marks it with the text as written, which is what a reader
copies out. Word is told the direction twice —w:bidion the cell's paragraph and
w:rtlon its runs — and receives the text untouched, because it reorders and joins on
its own. PowerPoint is told the direction on the cell's frame and likewise receives the
text as written.The Unicode formatting controls reach whoever still has to read them. A cell's line keeps
its joining controls and direction marks through the backend's own sanitising, because
below that sit the shaper and the algorithm — and in a cell handed to PowerPoint neither
has run yet. They are dropped at the glyph seam, where a zero-width character has nothing
to draw. Removed earlier, with a space in their place, aZWNJbetween two Arabic letters
did not merely go missing: it became a word break, and the letters the author had
separated joined up anyway.That last one is the opposite of what a paragraph does, and the difference is the size of
what each hands over. A paragraph reaches PowerPoint as one frame per span, so no frame
holds a bracket pair for PowerPoint to resolve and the mirroring has to be done first. A
cell is one frame holding a whole line, which is the input PowerPoint's own algorithm is
complete for — mirroring it first swaps the brackets a second time, and(2026)closing
an Arabic cell was drawn as)2026(until this stopped. The upside is that a copy out of
a cell carries the brackets as typed, which the paragraph path cannot promise.A left-to-right cell is untouched, so a table of Latin content keeps the geometry and the
export it always had. A cell holding Hebrew or Arabic is not, and deliberately: what a
declaration settles is the direction a line is embedded in, while a script runs the way
it runs inside that whatever the base. So a cell that declares nothing is now shaped and
ordered too, and its auto column measured on the joined forms. Such a table moves —
because what it drew before was the word backwards. -
Arabic joins. Arabic letters change shape by position, and a font does that
through OpenTypeGSUB— which a PDF never executes:showTextwalks the font's
cmapand nothing else. The engine now shapes Arabic itself, mapping each letter to
its contextual presentation form (and lam-alef to its ligature) before measurement,
so what is measured is what is drawn. Vowel points and direction marks are
transparent to the join, as Unicode's joining rules say. PowerPoint gets the base
letters back — it shapes Arabic itself, and frozen forms would end up in a file
users search and copy from — and Word was never given forms to begin with. The
joining controls travel with them: they are the author's instruction about which
letters may connect, and PowerPoint's shaper is the reader they were written for, so
dropping them would have handed it a word it joins straight back up. A font
that carries the letters but not the forms (theGSUB-only families) now degrades
to unjoined base letters instead of?, which costs the joining rather than the
text. An annotation mark between two letters no longer breaks their join: which
characters are transparent to shaping is decided by Unicode's own rule — general
category — rather than by a list of ranges that covered the vowel points and missed
the rest. In right-to-left runs, paired punctuation is mirrored at the PDF seam
(UAX #9 L4), so a parenthesis in Hebrew faces what it encloses. The mirrored set is
the punctuation that occurs in documents — parentheses, brackets, braces, angle
brackets, guillemets — rather than the whole Unicode mirroring table, so a relational
or set operator such as≤or⊂passes through drawn as written. (The angle brackets
in that list are<and>, which Unicode also classes as mathematical, so they do
mirror.) -
A PDF now says the letters an author wrote, not the shapes they were drawn as. A
font'sToUnicodemap states what each glyph in the file means, and the subsetter
builds it from the characters shown — which, for Arabic, are the joined forms. So the
file stated that a glyph meant U+FE8E, the final form of alef, where the author had
typed U+0627. A reader that applies a compatibility normalisation recovered the letter,
which is why extraction usually looked right; one that handed the code point straight to
a search box did not, and a search for the ordinary spelling of a word found nothing
with no sign of why. The maps are now corrected once the subsetter has built them, so a
copied word is the word, and the lam-alef ligature — one glyph standing for two letters
— comes back out as both. Hebrew is untouched: it is reordered, never shaped, so its
glyphs already named their own letters.Only a document that drew a reordered run pays anything. The map has to exist before it
can be read and it is written during the save, so such a document is saved twice — the
first time into a null sink, so both saves stream and nothing is buffered regardless of
document size. Everyt...
GraphCompose v2.1.1
A maintenance release: no API changes, nothing to learn, nothing to migrate.
2.1.1 is a drop-in replacement for 2.1.0.
<dependency>
<groupId>io.github.demchaav</groupId>
<artifactId>graph-compose</artifactId>
<version>2.1.1</version>
</dependency>What changed is the confidence you can place in the rest of it. Three things that
were quietly wrong are no longer wrong, and each now has a test standing behind it:
- The API reference exists again. The
graph-composecoordinate carries no
sources of its own, so its Javadoc jar was built empty and shipped that way
through every 2.x release — javadoc.io kept serving 1.9.1. CI now opens the
jar before it is published and fails if the pages a reader lands on are missing. - The engine deck stopped calling a shipped backend planned. It listed PPTX as
Planned beside av2.1.0badge — the release that shipped it, and whose own
copy of that deck is published as a.pptx. lineSpacingis documented in the units it uses. The authoring cheatsheet
called it a leading multiple with a default of1.0. It is extra space in
points, defaulting to0— one sentence that had taught the repository to
write typographic multiples into a points field.
The rest is that same work applied to the gates themselves: the aggregate CI check
now watches the job it depends on, the Javadoc gate covers the entry point every
snippet starts from, and the documentation is held to its own links — all 1051 of
them, across 99 pages.
Full detail below.
Build
-
The cut installs a module after the things it needs. Step 4 installs each train
sibling on its own, so everything it depends on has to be in the local repository at
the version the bump just wrote — a version that exists in no reactor and not yet on
Central.render-pptxwas listed beforetestingwhile depending on it, and had been
since PPTX gained its text-fidelity suite. That stayed invisible: the cut only fails on
it when the local repository does not already holdgraph-compose-testingat the new
version, which is the normal state of a clean machine and not of one that has been
building all week. The 2.1.1 cut hit it and stopped at Step 4 — after the version bump
had rewritten thirty files, before any commit, tag or push.testingnow installs
second, andReleaseScriptInstallListGuardTestderives the required order from the
poms rather than restating it, so a new edge cannot be added without failing the build. -
CI opens the Javadoc jar it is about to publish. The existing step lints the
engine's sources, which says nothing about whether the artefact Maven Central serves
has anything in it — and that was the failure:graph-composecarries no sources of
its own, the javadoc goal found nothing to archive, and every 2.x release shipped an
artefact with no pages while javadoc.io went on rendering 1.9.1, the newest version
that carried a reference at all. Nothing was red for it. The configuration is guarded
byPublishedJavadocCoordinateGuardTest; CI now builds the jar the release profile
builds and looks inside, failing if the index,GraphComposeorDocumentSessionis
missing — the three pages a reader arrives at, standing in for the reference. -
The Javadoc gate lints the class readers open first. It ran with
subpackagesset tocom.demcha.compose.document, soGraphCompose— the entry
point every snippet in the README starts from — sat in the root package outside it,
and had done since the module layout moved. It carried a real doclint error the
whole time: two<h3>headings under an implicit<h1>, which is the sequence
breakdoclintexists to catch. Nothing else caught it either, because the
published Javadoc jar is built withdoclint=noneso a broken tag never blocks a
release. The gate now covers the whole ofcom.demcha.compose, root package
included, and the two headings are<h2>. Widening it also pulls in the
@Internalengine package:excludePackageNamesdoes not take effect alongside
subpackages, and that costs warnings rather than failures, sincefailOnError
fails on errors only. -
A CI job the gate guard cannot name is a job it cannot report. The guard that
checks every pull-request job is aggregated byCI Gatefound those jobs with a
pattern admitting lower case and hyphens — everything today's names happen to use.
A job added asbuild_and_testorCodeQLwas not matched, and neither was
security_scan: # nightlyor a name with a space after the colon, because the
pattern also required the line to end there. A job the guard never sees is one it
cannot report missing: it sits outside the gate'sneeds, the test stays green, and
the aggregate check branch protection requires is blind to it. Job names are now
taken structurally, from the YAML's own indentation, rather than from a guess at
GitHub's identifier grammar, and the parser takes the workflow as text so
CiGateCoverageGuardParsingTestcan drive it with the job spellings this
repository's own workflow does not happen to contain. -
A recipe nobody links is a recipe nobody reaches.
docs/recipes.mdis the page
the README and the documentation index both point at, and nothing held it to the
folder it indexes: a new page underdocs/recipes/left every gate green while
having no inbound link from anywhere.RecipeCatalogueGuardTestnow fails the build
in both directions — a page the catalogue omits, and a catalogue row pointing at a
file that was renamed or removed. -
A release re-renders the previews it publishes. The committed previews record
the version they were rendered at, and until now nothing moved it: a cut bumped
every pom, regenerated the showcase site at the new version, and left
assets/readme/**on the release before — with the drift gate comparing both
sides at the old version and staying green through it. The cut now bumps that
property with the tag and re-renders the previews from the same catalogue the
site is built from, before the verify step that checks them.-SkipShowcase
skips the published site underweb/and no longer skips these, since they ship
in the repository;-PostReleaseOnlyleaves them alone, because they belong to
the tag rather than to the branch it opens. -
A committed preview cannot fall behind the code that renders it. README and
the showcase site read files underassets/readme/**rather than rendering
anything, and nothing held those files to the catalogue: a change to an example,
a theme or the engine moved the render while the committed file stayed put, and
the first anybody knew was a release publishing it. Twenty-three of the
sixty-seven were behind and are re-rendered here; the twenty-two PDFs among
them rasterise to the same pixels as before, so nothing visible had been
carrying the drift, and the one DOCX now marks bold as<w:b/>rather than by
asking for a font namedHelvetica-Bold. Every preview is now
compared against a fresh render on each build, exactly: the comparison drops
only what a machine writes rather than an author (a PDF's clock-seeded/ID, an
OOXML package's zip and creation stamps, the platform's line separator, and one
named watermark whose antialiasing differs between machines), all of it measured
by rendering the catalogue on both platforms rather than assumed. Editing a
preview by hand now runs the job that checks it. -
The CI guard job runs every guard it names. It selected eight test classes
while scoping the reactor tograph-compose-core: two had been deleted months
earlier and two live ingraph-compose-qaandgraph-compose-render-pdf, so
four never ran. Surefire aborts only when a selection is entirely empty, so the
four that did match kept the job green. The list is now the five guards that
live in the engine module, andCiGuardListGuardTestfails the job if a name in
it stops resolving. -
The aggregate status check notices when nothing was built.
CI Gateis one of
the two checksdevelopandmainrequire, and it watched the four heavy jobs
without watching the path-detection job they all gate on. When that job's
git fetchreturned HTTP 503 the four resolved toskippedrather thanfailure,
so the gate found nothing to report and went green over a run that compiled
nothing — leaving both required checks green and, on a pull request, the branch
protection satisfied by a build that never happened. The gate now aggregates the
detection job too, andCiGateCoverageGuardTestreads the workflow and fails if
any job that can run on a pull request is left out of it. Schedule-only jobs are
recognised from their ownif:condition rather than an exclusion list, so a new
job either joins the gate or fails the guard. -
graph-composepublishes an API reference again. The coordinate the README
sends readers to for Javadoc carries no sources of its own, so the javadoc goal
found nothing to archive and attached no artifact — not an empty one, none. Every
2.x release shipped without it, and javadoc.io, which serves the newest version
that carries one, kept rendering the 1.9.1 API: complete, convincing, and two
majors stale, beside prose promising documentation fresh after each release. The
wrapper now builds that jar from the engine's sources, which is the surface a
caller of this coordinate authors against — 903 pages where there were none. Doc
lint stays off, as it is for the engine's own release jar, but a hard failure is
no longer swallowed: thefailOnError=falsethat hid the empty state is gone.
PublishedJavadocCoordinateGuardTestfails if a coordinate the documentation
advertises as an API reference cannot produce a javadoc jar, and the release
checklist builds the wrapper's before a cut. -
**A documentation-on...
GraphCompose v2.1.0
v2.1.0 — 2026-07-26
Highlights
- The same document now also prints to PowerPoint.
graph-compose-render-pptx
gains a fixed-layout backend that consumes the same resolved layout graph as the
PDF backend, so one page becomes one identically-sized slide and every element keeps
its position by construction — as native, editable shapes rather than a picture of a
page. Ships as@Beta(Experimental) in its first release. - A failed render no longer destroys the document it was replacing.
- Headings stay with their content across a page break, in the engine and in the
CV and proposal presets. - CV entry, subtitle and project-card titles accept inline
[text](url)links.
Public API
- Editable PowerPoint output.
buildPptx(Path),writePptx(OutputStream)and
toPptxBytes()render the current session through the PPTX backend when
graph-compose-render-pptxis on the classpath; a missing backend fails with
MissingBackendExceptionnaming the artifact.DocumentPageSize.SLIDE_16_9
(960 × 540 pt) andSLIDE_4_3(720 × 540 pt) match the PowerPoint defaults.
The PPTX surface — thedocument.backend.fixed.pptxpackages and these
convenience methods — carries@Beta, so its shape may still change in a minor
release; the geometry relationship with the PDF backend is a design invariant and
is not subject to change. See docs/api-stability.md. - A failed render no longer truncates the destination.
buildPdf(Path),
buildPptx(Path)and multi-sectionbuildPdf(Path)render into a scratch file in
the destination's own directory and move it onto the destination only after the
render returns. Previously the destination was opened — and therefore emptied —
before the backend produced a byte, so an oversized node, a missing backend or a
full disk left an empty file where a published document used to be. The move is
atomic where the filesystem supports it, so a concurrent reader never observes a
half-written document; on POSIX the destination keeps the permissions it already
had, or getsrw-r--r--when it is new. Replacing the destination entry replaces
a symlink rather than writing through it. - Keep a heading with its content —
SectionBuilder.keepWithNext(). A section
marked keep-with-next is never left stranded as the last block on a page apart from
the content it introduces: when the section plus the first slice of the following
block would overflow the remaining page space but fit on a fresh page, the section
relocates. The first slice is a paragraph's first line, a table's repeated header
rows plus first body row, or a list's first item, so the rule holds whether the
following block is atomic or page-spanning. Distinct fromkeepTogether(), which
relocates a whole block. Inert when nothing follows, best-effort when the heading
plus the first slice cannot share a page, and off by default.
LineBuilder.keepWithNext()is the line counterpart, so a full-width header rule
joins its banner's run and the whole title block moves together. - Keep-with-next also surfaces on the node model:
DocumentNode.keepWithNext()is a
new default method (existing implementations keep thefalsedefault), and
SectionNodeandLineNodeeach gain a trailingkeepWithNextrecord component.
Constructor calls are unaffected — both records keep an overload at the previous
argument count — but the canonical component count changed (SectionNode13 → 14,
LineNode17 → 18), so a record deconstruction pattern written against the
2.0.0 component list must add the new binding to compile. DocumentSession.buildPptx()(no-arg) is removed; usebuildPptx(Path). The
session has a single configured output path, shared withbuildPdf(), so the no-arg
form wrote deck bytes into whatever that path was — including a file named.pdf.
Naming the destination is also what lets one session emit both formats. The PPTX
surface is Experimental and was never published, so no released code can depend on
the removed overload.- Two backends registered for one format now fail loudly.
BackendProviders.fixedLayout(String format)selects a fixed-layout backend by its
FixedLayoutBackendProvider.format()key (case-insensitive), so several backends can
coexist on one classpath; the no-arg default resolves the"pdf"provider when
present, otherwise the lexicographically smallest format. Where both entry points
previously took the firstServiceLoadermatch — letting classpath order decide the
renderer — an ambiguous format now throwsIllegalStateExceptionnaming the
competing provider classes. MarkdownInlinegainsappendTransformed,appendUpperCasedand
appendIfPresent, andPdfRenderEnvironmentgainsfillAlphaState/
strokeAlphaStatefor handlers that need a shared alpha graphics state.
PPTX backend
The per-capability status — what is native, what is approximated, what is unsupported
— lives in
docs/architecture/backend-capability-matrix.md.
Of the 38 capabilities it tracks, 24 map to a native equivalent and 4 are unsupported.
The remaining 10 are partial, and not all in the same way: some render natively with an
approximated styling detail (distinct per-corner radii collapse to one value, numeric
dash arrays map to the nearest preset, a radial gradient uses the closest DrawingML
shade), while others lose something the format cannot carry — see Known limitations.
- Content. Paragraphs render as absolute, wrap-disabled text frames seated on the
measured baselines with PDF-identical glyph sanitization, carrying rich runs, inline
code chips, inline images, shapes and SVG. Tables render as row fills, border edge
lines and per-cell text frames at graph coordinates, across page breaks with repeated
headers and row spans. Shapes, ellipses, lines, polygons, free paths (with gradient
fills and strokes), images, barcodes and transform groups all render natively.
Unsupported payloads fail withUnsupportedNodeCapabilityException; custom handlers
plug in throughPptxFragmentRenderHandlerandBuilder.addHandler, which rejects a
duplicate registration for one payload type. - Chrome and navigation. Metadata maps onto OPC core properties, watermarks and
repeating headers/footers render per slide with token resolution, and hyperlinks,
internal slide jumps and bookmark slide names are emitted. Multi-section documents
concatenate into one deck; every section must share the slide size. - Fonts. Families are embedded where the licensing bits allow it and the backend
warns once per family whenever a font is substituted, so a deck that will render
differently on another machine says so at build time. - Clipping. DrawingML cannot express graphics-state clipping, so a clip region that
can actually cut ink renders through the PDF backend into one transparent picture on
the clip bounds — pixel-exact, but not editable as shapes, and run-level link
hotspots inside it are not emitted. A clip that provably cannot remove ink (a rounded
card whose padded content never reaches the corners) skips the fallback and stays
native.Builder.clipRasterFallback(false)restores unclipped vectors with a
one-time warning. The raster targets a 2048-pixel long edge, clamped between native
size and 4×. - Raster-slide mode.
Builder.rasterSlides(int dpi)renders each page through the
PDF backend and places it as one full-slide picture — a pixel-exact copy for decks
that must not be edited. - Reproducible output is opt-in.
Builder.deterministic(true)or
deterministic(Instant)pins the OPC created/modified properties and normalizes
every zip entry timestamp, so the same document renders to byte-identical bytes
across runs. The default path does not:buildPptx(Path)and the other
convenience methods stream the deck with live timestamps, matching the PDF backend's
opt-in convention.
Fixed
- PPTX text no longer overruns the frame the engine measured for it. A span that
named a standard-14 style variant —Helvetica-Bold,Times-Italicand their
siblings — travelled to PowerPoint as a bold or italic run flag. Those names are
family aliases: the engine resolves each to its regular base and takes the real face
from the span's decoration, so the layout had measured the regular metrics. The viewer
then drew a face about 6% wider than its slot, which pushed a chip label past its card
and closed the gap between two words of a rich-text heading until they read as one.
Run flags now follow the decoration for those families, so a deck renders the same
face the PDF does; a binary family still carries its face in its own name. - A large clipped region in a PPTX deck is no longer downscaled. The raster scale
was capped only from above, so a clip box wider than 2048 pt rasterized below
native size — an A0-scale composite landed near 44 DPI and read as visibly blurry,
since the picture is anchored at the full clip size regardless. Decks whose clip
regions fit a normal page are unaffected; their scale already saturated at the upper
cap. A very large region now costs transient memory proportional to its size. - The PDF backend draws
DocumentTextDecoration.UNDERLINEandSTRIKETHROUGH.
The flags previously resolved only to font faces, which alias to the regular program,
so decorated text rendered as plain glyphs while the PPTX backend already drew real
marks. Marks are em-proportional filled bands in the run's colour — underline 0.10 em
below the baseline, strikethrough 0.28 em above, thickness 0.05 em, the Type 1
convention — on paragraph runs, chips and table cell text alike. - The PDF backend honours the alpha channel of
DocumentColor.rgbaeverywhere —
text runs, lines, side borders, and ...
GraphCompose v2.0.0
Note
Documentation clarification
GraphCompose v2.0.0 is the current stable, module-first release. For the drop-in PDF-capable setup, use io.github.demchaav:graph-compose:2.0.0. For a lean setup with an explicitly selected backend, use graph-compose-core.
The README.md preserved inside this immutable tag still contains an outdated status line naming v1.9.1 as the latest stable release. The tagged code, Maven coordinates, and release itself are 2.0.0.
The tag has intentionally not been modified or force-moved. For current documentation, see the main branch, the module selection guide, and the 2.0 migration guide.
v2.0.0 — 2026-07-13
The 2.0 development line. Binary-breaking by design — japicmp runs report-only
for this cycle.
Removed
- The classic (pre-layered) CV and cover-letter template presets have been removed.
The layered template stack —templates.cv.*,templates.coverletter.*,
templates.invoice.*, andtemplates.proposal.*, all onBrandTheme— is now the
single template surface. - The standalone
BusinessThemedesign-token bundle (and itsDocumentPalette/
SpacingScale/TextScale/TablePresetcompanions) is no longer part of the
library. It lives on only as a styling helper inside the examples module; author
documents with explicitDocumentColor/DocumentTextStylevalues or a template
BrandTheme. - The DSL name-aliases
DocumentSession.builder()andDocumentDsl.text()have been
removed. UseDocumentSession.dsl()andDocumentDsl.paragraph(). - The PDF-typed document-chrome overloads on
DocumentSession—
metadata(PdfMetadataOptions),watermark(PdfWatermarkOptions),
protect(PdfProtectionOptions),header(PdfHeaderFooterOptions)and
footer(PdfHeaderFooterOptions)— have been removed in favour of the canonical,
backend-neutral overloads (metadata(DocumentMetadata),watermark(DocumentWatermark),
protect(DocumentProtection),header(DocumentHeaderFooter),
footer(DocumentHeaderFooter)). The PDF option types remain available on
PdfFixedLayoutBackend.builder()for advanced backend-level configuration. - The
linkOptions()accessor on the document nodes and inline runs (paragraph, table,
image, shape, ellipse, line, barcode, and the inline image / shape / text runs) has
been removed. UselinkTarget()and read the external URI from
ExternalLinkTarget.options(). - The unused engine-internal
Font.adjustFontSizeToFit(...)(and itsPdfFont/
WordFontimplementations) has been removed; text auto-sizing is resolved by the
layout compiler. - The dormant Entity-Component-System engine internals have been removed: the
EntityManager/SystemECSruntime, theEntitycomponent model with its
geometry / coordinator / renderable companions, the ECS render pipeline
(engine.render.*and the guide renderers underengine.render.guides), and
LayoutSnapshotExtractor. None were reachable from the live render path —
DocumentSession→ layout compiler → fixed-layout backend — so document layout,
PDF output, and the publicguideLines(...)overlay are unchanged.
Public API
- Reproducible PDF output (
@Beta).PdfFixedLayoutBackend.builder().deterministic(true)
(or.deterministic(Instant)for an explicit timestamp) pins the document
CreationDate / ModDate and derives the PDF/IDfrom the document metadata instead
of PDFBox's time-seeded default, so the same document renders to byte-identical PDF
bytes across runs — for reproducible builds and byte-level output tests. Off by
default (output keeps the live timestamp and/ID). PDF encryption viaprotect(...)
can reintroduce randomness (AES-256 uses random salts), so an encrypted document is
not byte-reproducible even with this enabled. Multi-section documents opt in through
the newMultiSectionDocument.toPdfBytes(FixedLayoutRenderer)/
writePdf(FixedLayoutRenderer, OutputStream)overloads — the multi-section
counterpart ofDocumentSession.render(backend). - The layered template packages dropped their
.v2suffix:
com.demcha.compose.document.templates.<family>.v2.*→
com.demcha.compose.document.templates.<family>.*forcv,coverletter,
invoice, andproposal. Update imports accordingly — behaviour and rendering are
unchanged; this is a package rename only.
Packaging
- 2.0 splits the monolithic engine into modules. The engine now builds under a new
graph-compose-corecoordinate, and the originalgraph-composecoordinate
becomes a thin drop-in aggregator that depends ongraph-compose-core— so an
existinggraph-composedependency keeps compiling and rendering PDF unchanged.
Consumer-testing support (graph-compose-testing) and the semantic DOCX / PPTX
backends (graph-compose-render-docx/graph-compose-render-pptx) are already
separate artifacts; the PDF render backend now lives in
graph-compose-render-pdf, which thegraph-composewrapper aggregates so a
baregraph-composestill renders PDF, whilegraph-compose-corealone is lean
(it throwsMissingBackendExceptionif asked to render without a backend). The
built-in templates now ship ingraph-compose-templates(opt-in; the
graph-composewrapper does not bundle them). The
optionalgraph-compose-fonts/graph-compose-emojiartifacts are unchanged.
Full install guidance ships with 2.0.0. graph-compose-render-pdfis a separate artifact: the entire PDFBox backend —
document.backend.fixed.pdf.**and theengine.render.pdf.**render tree — plus
PDFBox, zxing (barcodes), and the commons-logging→SLF4J bridge leave
graph-compose-core. The core keeps theDocumentSessionAPI and the
ServiceLoaderseam (FixedLayoutBackendProvider/FontMetricsProvider); the
PDF provider ships in render-pdf and is discovered at runtime. Depend on
graph-compose(orgraph-compose-render-pdfdirectly) for PDF; a bare
graph-compose-corerenders nothing until a backend is on the classpath.graph-compose-templatesis a separate artifact: the built-in CV, cover-letter,
invoice, and proposal templates (document.templates.**) leave the engine. They
are pure authoring code over the canonical DSL, so the module depends only on
graph-compose-core. Addgraph-compose-templatesfor the ready-made presets; a
consumer that used them throughgraph-composebefore must now add this artifact
(thecom.demcha.compose.document.templates.**packages are unchanged).- The semantic office backends are separate artifacts and Apache POI leaves the
engine:DocxSemanticBackendships ingraph-compose-render-docx(which brings
POI transitively — add it to export DOCX), andPptxSemanticBackendships in its
owngraph-compose-render-pptx(a POI-free skeleton for now — add it for the
slide-safe semantic manifest). Their packages —
com.demcha.compose.document.backend.semantic.{docx,pptx}— are unchanged. The
no-poibuild profile is retired. graph-compose-testingis now a separate artifact: the consumer testing
support —LayoutSnapshotAssertions(deterministic layout snapshots) and
PdfVisualRegression(pixel-diff of rendered pages) — leaves the
graph-composejar together with its Jackson and PDFBox dependencies. The
com.demcha.compose.testing.layout/com.demcha.compose.testing.visual
packages are unchanged, so imports stay the same; addgraph-compose-testing
at test scope to keep using them.graph-compose-bundleis the batteries-included aggregate for the split layout:
one dependency pulls the default PDF stack (graph-compose= core + render-pdf),
the built-in templates (graph-compose-templates), the bundled Google fonts, and
the colour-emoji set at compatible pinned versions. The office backends
(graph-compose-render-docx/graph-compose-render-pptx) stay opt-in and are not
bundled.
Internal
- The process-wide image caches (decoded source bytes and image metadata) are now
bounded with LRU eviction instead of growing without limit. A long-lived JVM that
renders many distinct images — a rendering service, a batch job — no longer
accumulates image data indefinitely. Single documents are unaffected: the caps sit
far above any realistic distinct-image count, so a render never evicts or re-decodes.
Build
- Added report-only cross-module code coverage. A new non-published
graph-compose-coveragemodule runs JaCoCoreport-aggregateover the engine, the
PDF backend, and the built-in templates plus thegraph-compose-qacross-module
suites, so each module's coverage counts the tests that actually exercise it (a
single-module report undercounts, because much of the production code is driven from
qa at test scope). CI publishes the HTML/XML report as an artifact; no coverage
threshold is enforced yet.
Documentation
- Documented that text is laid out left-to-right only: bidirectional (RTL) reordering
and complex-script shaping (Arabic joining, Indic reordering) are not performed. Added
to the README support matrix. - The 2.0 module migration guide now lists the API changes it previously delegated to the
changelog — the.v2package rename, theBusinessThemeremoval, the retired classic
presets, andFont.adjustFontSizeToFit— each with its migration action.