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.