Skip to content

GraphCompose v1.9.0

Latest

Choose a tag to compare

@github-actions github-actions released this 29 Jun 09:57
· 6 commits to main since this release

v1.9.0 — 2026-06-29

In-document navigation. Rendered PDFs can now declare named anchors and
internal links that jump to them — clickable tables of contents,
[text](#heading)-style links, and bidirectional footnotes — emitted as native
PDF GoTo actions. External links are unchanged.

Public API

  • DocumentSession.pageMargins(List<PageMarginRule>) (@since 1.9.0). Overrides
    the page margin for ranges of pages, so one document can mix a full-bleed cover
    (PageMarginRule.page(1, DocumentInsets.zero())) with book margins on the body
    (PageMarginRule.from(2, …)) — both horizontally and vertically. Pages are 1-based;
    rules apply in list order, last-covering-rule wins. Each top-level block is laid out
    at the content width of the page it begins on. A document that sets no rules is laid
    out exactly as before.

  • chrome().viewerPreferences(...) + DocumentViewerPreferences /
    DocumentPageMode / DocumentPageLayout
    (@since 1.9.0). Controls how a PDF
    reader presents the document on open — the page mode (USE_OUTLINES opens the
    bookmark panel, pairing with bookmark(...)), the page layout (single / one-column
    / two-column / two-page), and the window flags (displayDocTitle, hideToolbar,
    hideMenubar, fitWindow, centerWindow). Written to the PDF document catalog;
    DocumentViewerPreferences.openOutline() is a one-line preset. PDF-only — other
    backends ignore it. A document that sets none is unchanged.

  • Container bookmark(...) (@since 1.9.0). bookmark(DocumentBookmarkOptions)
    on any container flow builder (Section / Container / page flow) — previously
    only the seven leaf builders carried a bookmark — adds a PDF outline entry pointing
    at that container's start page, making a structured document navigable through the
    reader's bookmark panel. Emitted via its own non-visual marker fragment, so it works
    even on an unstyled container, and a container without a bookmark is unaffected.

  • **RowBuilder.flexSpacer() / pushRight() / arrangement(...) + RowArrangement

    • SpacerBuilder.grow(...)** (@since 1.9.0). Main-axis (justify-content) layout
      for a row. A flexSpacer() (or pushRight()) is an invisible spring that absorbs
      the row's leftover width — a title stays left while a badge sits flush right; a
      spacer's grow(...) factor sets its share. arrangement(START / CENTER / END / SPACE_BETWEEN / SPACE_AROUND / SPACE_EVENLY) justifies content-sized children
      instead. Flex is mutually exclusive with weights / columns. The default
      (START, no grow) is byte-for-byte unchanged, so existing rows are unaffected.
  • RowBuilder.verticalAlign(...) + RowVerticalAlign (@since 1.9.0). Seats a
    row's children on the cross axis within the row band, whose height is that of the
    tallest child: TOP (the default), CENTER, or BOTTOM — the align-items
    analogue for a horizontal row, without manual coordinates. The measure phase is
    unchanged and TOP rows render byte-for-byte as before, so existing documents are
    unaffected.

  • GraphCompose.documents() + MultiSectionDocumentBuilder / MultiSectionDocument
    (@since 1.9.0). Concatenates several independently authored DocumentSession
    sections — each with its own page size, margins, fonts, and footer numbering —
    into one PDF inside the engine, with no external PDF merge. Anchors, internal
    links, and the bookmark outline resolve across section boundaries against the
    combined document, and each section is numbered from its own first page, so a
    full-bleed cover of one page size can precede a margined, page-numbered body of
    another. Document-level metadata and protection are taken from the first section
    that declares them. Single-section output is unchanged. MultiSectionDocument
    is AutoCloseable and owns its sections.

  • addTableOfContents(...) + TocBuilder / DocumentLeader (@since 1.9.0).
    A native, clickable table of contents: each entry(label, anchor) becomes a row
    whose label links to the chapter (linkTo), a dotted or dashed leader fills the
    gap, and the page number is resolved automatically from the laid-out document —
    no manual two-pass. Built entirely from the existing primitives (auto/weight
    columns, line().fill(), addPageReference) and added to the flow, so a long
    contents paginates across pages.

  • addPageReference(anchor) + PageReferenceNode (@since 1.9.0). Prints the
    page a declared anchor(...) lands on — a native "see page N" cross-reference —
    in a single authoring pass. A document that contains a page reference is laid
    out twice: the first pass resolves every anchor's page, the second renders the
    references with the resolved numbers; the reference reserves only its content
    width in both passes, so its own footprint does not shift the pages it reports.
    Available on flows (addPageReference(anchor)) and inside rows (the number
    column of a table-of-contents row). Documents without a page reference are
    unaffected (single pass, byte-identical). pageIndex() remains for programmatic
    access.

  • RowBuilder.columns(...) + DocumentRowColumn (@since 1.9.0). Size each row
    column explicitly: DocumentRowColumn.fixed(pt), auto() (intrinsic content
    width), or weight(w) (a share of the space left after the fixed and intrinsic
    columns). Mix them freely — columns(auto(), weight(1), auto()) with a
    line().fill() in the middle is a dot-leader table-of-contents row, with the
    label and page number sized to their content. weights(...) stays as sugar for
    the even / weighted split (and a weight-only column list resolves identically),
    so existing rows are byte-identical.

  • LineBuilder.fill() (@since 1.9.0). A line stretches to the width
    available where it is placed — its column inside a row, or the content width at
    flow level — instead of its authored fixed width. Paired with a dotted stroke
    (dashed(0.1, 4).lineCap(ROUND)) it is the flex leader behind a
    table-of-contents row, drawn without measuring the gap by hand. A non-fill line
    is unchanged, so existing line output stays byte-identical.

  • Negative-margin handling (@since 1.9.0). A negative page margin
    (DocumentSession.margin(...) or the builder's margin(...)) is now rejected
    with an IllegalArgumentException — it would make the content area larger than
    the sheet, silently overflowing it; use a node's bleed(...) to reach the page
    edge instead. Separately, a negative node bottom margin now pulls the
    following content up — symmetric with a negative top margin, which was already
    honoured (the vertical flow previously dropped it). Existing documents are
    unaffected, since neither shape was usable before.

  • DocumentSession.pageIndex() + PageIndex / PageReference (@since 1.9.0).
    Resolves every declared anchor(...) to its final page in a single,
    backend-neutral pass over the laid-out document — pageNumberOf("intro") for a
    "see page N" cross-reference, forAnchor(...) for the full PageReference.
    Computed from the resolved layout graph (not from rendered PDF bytes) and cached
    per layout revision alongside layoutSnapshot(). The read-side foundation for
    clickable tables of contents and cross-references. A duplicate anchor resolves to
    its last registration — the same destination a linkTo(anchor) jumps to.

  • DocumentPageNumbering / DocumentPageNumberStyle (@since 1.9.0). Header
    and footer {page} / {pages} tokens can now offset, restart, restyle, and
    suppress-on-first-page numbering per zone via
    DocumentHeaderFooter.builder().numbering(...). DocumentPageNumbering carries
    startAt (printed value on the first counted page), countFrom (physical page
    where counting begins), showOnFirstPage, and a DocumentPageNumberStyle
    (DECIMAL, LOWER_ROMAN, UPPER_ROMAN, LOWER_ALPHA, UPPER_ALPHA) — e.g.
    lower-roman or alphabetic numbering, an uncounted cover, or an offset/restarted
    count (one style per zone; switching style mid-document — roman front matter then
    arabic body — is a per-section concern). Under
    an offset, {pages} expands to the counted total
    (startAt + (totalPages - countFrom)), not the physical page count. The default
    (DocumentPageNumbering.DEFAULT) is decimal, no offset, shown on every page, so
    existing header/footer output is byte-identical.

  • LineBuilder.lineCap(DocumentLineCap) (@since 1.9.0). Lines gain the
    round / square end-caps PathBuilder already exposed. Pairing ROUND with a
    short dash draws a dotted line — line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)
    renders round dots (the standard table-of-contents leader / separator style).
    The BUTT default emits no cap operator, so existing line output is
    byte-identical.

  • Content bleed: DocumentBleed / DocumentEdge (@since 1.9.0). Flow
    builders gain bleed(DocumentBleed) and bleedToEdge(DocumentEdge...), so a
    section's background fill extends to the trimmed physical page edge on the
    declared sides — a full-bleed masthead band or an edge-to-edge colour panel —
    while the section's children stay inside the content margin (text never runs
    off the page). It is the content-side twin of PageBackgroundFill and the
    intent-revealing replacement for the hand-computed negative-margin idiom,
    resolved against the active page margin at layout time. Nodes that do not bleed
    render byte-identically to before.

  • In-PDF navigation: anchors + internal links (@since 1.9.0). Every flow
    and leaf builder gains anchor(String), declaring a named destination at the
    element's top-left — section.anchor("intro"), paragraph.anchor("fn-1"), and
    the same on image / shape / ellipse / line / barcode / table builders. A link
    targets an anchor instead of a URI via RichText.linkTo(text, anchor) /
    linkTo(text, style, anchor), ParagraphBuilder.inlineLinkTo(text, anchor) /
    linkTo(anchor), and linkTo(anchor) on the leaf builders. Inline graphics
    inside a paragraph jump to anchors too via RichText.imageLinkTo(...) /
    shapeLinkTo(...) (and the matching ParagraphBuilder.inlineImageLinkTo(...) /
    shapeLinkTo(...)). Anchor resolution
    is deferred to the end of the render pass, so a link may target an anchor that
    appears later in the document (a forward reference). An unknown anchor renders
    as ordinary styled text (no annotation) and logs a warning; a link whose text
    wraps produces one annotation per line fragment; a duplicate anchor name keeps
    the last registration. Backends without in-document navigation (DOCX) render an
    internal link as plain text.

  • Unified DocumentLinkTarget (@since 1.9.0). A new sealed
    DocumentLinkTargetExternalLinkTarget (wrapping DocumentLinkOptions)
    and InternalLinkTarget (an anchor name) — is now the link type carried
    through semantic nodes and resolved layout fragments. DocumentLinkOptions is
    unchanged and still accepted by every existing link(DocumentLinkOptions) and
    inline-link DSL method (wrapped into an ExternalLinkTarget automatically), so
    authoring code is source-compatible. The link accessor on the inline-run
    records (InlineTextRun / InlineImageRun / InlineShapeRun) is now
    linkTarget(); the former linkOptions() remains as a deprecated bridge that
    returns the external options (or null for an internal link).

  • Inline SVG-icon runs (@since 1.9.0). A parsed SvgIcon can now sit on
    the text baseline inside a paragraph via RichText.svgIcon(icon, size) and
    ParagraphBuilder.inlineSvgIcon(icon, size) (with alignment / baselineOffset /
    link overloads, plus a clickable form). size is the glyph's height in points;
    the width follows the icon's aspect ratio. The icon is drawn as crisp vector
    layers carrying their own colours — gradients included — so it renders
    independently of the active font's glyph coverage. This is the engine path for
    vector colour emoji (e.g. a Twemoji SVG dropped inline) and small vector marks.
    A new sealed InlineRun variant (InlineSvgRun) joins text / image / shape;
    the inline render reuses the existing SVG paint pipeline (shared with the block
    path fragment), so flat-colour output stays byte-identical.

  • Inline highlight chips (@since 1.9.0). An inline run can now sit on a
    rounded, padded background fill — the GitHub inline code look and inline
    status badges. RichText.highlight(text, style, bg, radius, padding) is the
    primitive; code(text) ships engine defaults (a monospace font, a muted ink
    and a light chip) and chip(text, fg, bg) colours a badge — with the matching
    ParagraphBuilder.inlineHighlight / inlineCode / inlineChip. A highlight
    overload takes DocumentLinkOptions, so a chip can also be a link. The fill is
    a new InlineBackground(fill, cornerRadius, padding) carried by a new sealed
    InlineRun variant, InlineHighlightRun; horizontal padding widens the run's
    advance, vertical padding overflows the line box without changing line metrics.
    A multi-word chip wraps with the surrounding line, painting one continuous
    rounded fill per visual-line fragment (its horizontal padding sits on the run's
    outer edges, so a wrapped fragment is open on the inner break). Text-only
    backends (DOCX) keep the text and drop the fill.

  • Colour emoji by shortcode (@since 1.9.0). RichText.emoji(":star:", size)
    and ParagraphBuilder.inlineEmoji(...) resolve a GitHub-style shortcode to an inline
    vector colour glyph. Resolution is lenient — an unknown shortcode (or no emoji
    set on the classpath) is rendered as the literal text, the way GitHub treats an
    unrecognised :code:. The resolver is the new EmojiLibrary
    (com.demcha.compose.document.emoji): data-driven from the classpath layout
    emoji/emoji-index.properties (shortcode=codepoint) + emoji/svg/<codepoint>.svg,
    with find(...) (lenient Optional), require(...) (strict), isAvailable()
    and per-codepoint caching (a glyph using an SVG feature the parser rejects is
    treated as unresolved, so it falls back to text rather than failing the render).
    The glyphs ship in a new, independently-versioned graph-compose-emoji
    companion module (mirroring the graph-compose-fonts split): the engine carries
    no emoji art and has no Maven dependency on it. The module bundles the full
    Noto Emoji SVG set (~3.7k glyphs, SIL OFL 1.1) with a GitHub-style shortcode
    index (~1.6k shortcodes) generated from the gemoji database; both are rebuilt by
    emoji/tools/build-emoji-set.py.

  • SVG gradient import is now best-effort (@since 1.9.0). stop-opacity
    (which has no opaque-PDF-shading analogue) is ignored — the gradient renders
    with opaque stops — and a focal radial (fx / fy) approximates as a plain
    radial about the centre, instead of failing the whole icon. This lets
    real-world artwork import (keeps gradient scenes like :framed_picture: /
    :city_sunrise: looking like scenes rather than flat blobs); fully-opaque
    gradients are unchanged, byte for byte.

  • SVG clip-path and display:none support (@since 1.9.0). A
    clip-path:url(#id) (including the Adobe-Illustrator <use> + clipPath
    idiom, where the clipPath references a <defs> shape) is resolved to a clip
    region on each affected SvgIcon.Layer and honoured by the inline renderer, so
    glyphs that clip detail to a silhouette — hand gestures, body parts, the
    probing cane — render correctly instead of overflowing into halos. Hidden
    subtrees (display:none, e.g. an Illustrator guide layer of registration
    hatching) are skipped. Together these take the Noto Emoji set to essentially
    the whole bundled set rendering cleanly.

  • Same-colour translucent gradients are dropped, not painted opaque. A
    gradient whose stops are all the same RGB with at least one translucent stop
    carries no colour — it is a pure alpha overlay (a soft shadow or edge
    highlight, e.g. the hair-edge darkening on the vampire glyphs). With no
    shading-alpha in the backend, painting it opaque covered the art beneath (the
    vampire's face rendered as a solid hair blob); such layers are now dropped.
    Multi-colour gradients (real scenes — :framed_picture:, :sunrise:,
    :city_sunset:) are structural and keep rendering as gradients.

  • Inline SVG icons are clipped to their viewBox. Real-world icon art
    (notably Noto's working files) parks geometry outside the viewBox — a browser
    clips it to the viewBox, but the inline renderer was painting it, so an icon
    could smear copies of itself across adjacent glyphs (:package: rendered as
    several duplicated boxes overlapping its neighbours). The inline SVG render now
    clips each icon to its glyph box, matching SVG viewBox semantics.

  • Block SVG icons are clipped to their viewBox too. The same off-canvas art
    bled past the box on the block path (addSvgIcon(icon, w) / SvgIcon.node(w)),
    which had no viewBox clip. A block icon's layer stack now clips its layers to
    the icon box: LayerStackNode gains an opt-in clipToBounds (@since 1.9.0,
    default off so existing stacks stay byte-identical) and SvgIcon.node(...)
    sets it. It reuses the ShapeContainer clip pipeline — one paired
    begin/end marker per icon — so it matches the inline fix above. The same
    flag is exposed to the DSL as LayerStackBuilder.clipToBounds() — the
    overflow: hidden of a stacking box for any layer stack.

  • Render a document straight to images (@since 1.9.0). DocumentSession
    gains toImages(int dpi)List<BufferedImage> (one per page) and
    toImage(int pageIndex, int dpi)BufferedImage, plus transparent
    overloads (toImages(dpi, transparent) / toImage(pageIndex, dpi, transparent))
    that return ARGB instead of opaque white. These rasterize the in-memory document
    directly, skipping the previous toPdfBytes() → reparse round-trip needed to get
    a preview or thumbnail. The return type is the JDK java.awt.image.BufferedImage,
    so the public surface stays renderer-agnostic; the PDFBox PDFRenderer call lives
    in the PDF backend. PdfVisualRegression also gains direct renderPages(session) /
    assertMatchesBaseline(name, session) overloads on the same path.

Deprecations

  • templates.api.CoverLetterTemplate marked @Deprecated(forRemoval = true).
    Nothing implements it — the layered cover-letter presets implement the generic
    DocumentTemplate<CoverLetterDocumentSpec> seam instead. Removed in 2.0.
  • cv.v2.components.HeadlineRenderer / ContactRenderer / BannerRenderer
    (already-deprecated pre-widgets shims) are now forRemoval — use the
    cv.v2.widgets Headline / ContactLine / SectionHeader widgets instead.
    Removed in 2.0.

Documentation

  • New runnable flagship example
    examples/src/main/java/com/demcha/examples/features/title/BookTemplateExample.java
    — a full novel front (full-bleed wave cover, clickable dotted-leader table of
    contents with live page numbers, chapters) assembled in one DocumentSession
    using the v1.9 book primitives, with no external PDF merge or two-pass probe.
  • New runnable example
    examples/src/main/java/com/demcha/examples/features/navigation/InPdfNavigationExample.java
    — a clickable table of contents plus a bidirectional footnote.
  • New runnable example
    examples/src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java
    — multi-colour vector glyphs (gold star, green check badge, violet gradient
    orb, info / warning marks) flowing inline with text, at several sizes.
  • New graph-compose-emoji module bundling the Noto Emoji SVG set (OFL 1.1) with
    emoji/OFL.txt, emoji/NOTICE.md and the emoji/tools/build-emoji-set.py
    generator that rebuilds the glyphs + shortcode index from noto-emoji + gemoji.
  • New runnable example
    examples/src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java
    :shortcode: colour emoji flowing inline with text, the starter-set legend,
    the unknown-shortcode text fallback, and several glyph sizes.
  • New runnable example
    examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java
    — a Shortcode | SVG (vector) | PNG (raster) comparison table, drawing each
    starter glyph down both inline paths (RichText.svgIcon vs RichText.image).
  • New runnable example
    examples/src/main/java/com/demcha/examples/features/text/EmojiGalleryExample.java
    — a paginated catalogue of the entire bundled emoji set (every indexed glyph,
    drawn inline).

Build

  • The README hero banner is now version-stamped and re-rendered on release.
    EngineDeckExample reads its version and codename from a filtered
    banner.properties (@project.version@) instead of hardcoded constants, and
    the new ReadmeBannerRenderer writes
    assets/readme/repository_showcase_render.png straight from the engine via
    DocumentSession.toImage(...) — no PDF-rasterize round-trip.
    cut-release.ps1 re-renders and stages the hero on every tag, and
    VersionConsistencyGuardTest fails the build if the banner version is ever
    hardcoded again.

Tests

  • InternalLinkAnchorTest (PDFBox assertions): forward and backward references
    resolve to GoTo; an unknown anchor produces no annotation and no crash; the
    destination points at the correct page across a page break; a wrapped link
    emits an annotation per line fragment; external links still emit URI; a
    section anchor and a shape internal link are both navigable; a duplicate anchor
    keeps the last registration; plus a visual artifact write.
  • InlineSvgRunTest (run validation: null icon, non-finite / non-positive
    dimensions, alignment default, external-link wrapping) and InlineSvgRenderTest
    (PDFBox end-to-end: text preserved with no glyph substitution, the icon's fill
    colour and an inline gradient both rasterize onto the page, a linked icon emits
    a clickable annotation, and svgIcon sizes by aspect ratio). InlineSvgRenderTest
    also rasterizes off-canvas geometry to prove the inline glyph-box clip, and the
    new BlockSvgRenderTest does the same for the block path — off-canvas art does
    not bleed, in-box art still paints, the layer stack emits a balanced
    CLIP_BOUNDS begin/end pair, and a plain (non-icon) stack emits none.
  • EmojiLibraryTest (resolves shortcodes case-insensitively with/without colons,
    unknown → empty, require throws, an absent set reports unavailable and names
    the graph-compose-emoji artifact) and EmojiRenderTest (a known shortcode
    rasterizes a colour glyph, a gradient emoji paints its shading, an unknown
    shortcode falls back to literal text, and RichText.emoji yields an
    InlineSvgRun or a text run accordingly).
  • DocumentSessionImageTest (direct render-to-image): toImages(dpi) returns one
    image per page sized to the page at that DPI; dimensions scale with DPI; rendered
    pages contain painted (non-background) pixels; transparent yields an ARGB image
    with a fully-transparent margin while the default is opaque RGB; toImage(pageIndex, dpi) returns the requested page and is pixel-identical to the matching toImages
    entry; a post-processed watermark also lands in the raster; the direct render is
    pixel-identical to the toPdfBytes() round-trip (PdfVisualRegression / ImageDiff,
    budget 0); and dpi <= 0, an out-of-range page, and an empty document are rejected.