Skip to content

Releases: DemchaAV/GraphCompose

GraphCompose v2.4.1

Choose a tag to compare

@github-actions github-actions released this 21 Sep 22:31

v2.4.1 — 2026-09-21

Performance

  • A barcode is drawn as vector shapes, not as an image, in PDF and PPTX.
    PdfBarcodeFragmentRenderHandler turned ZXing's bit matrix into a bitmap, one setRGB
    call per pixel, wrote it to PNG with ImageIO — which, with its default cache, buffers a
    write to a stream through a temporary file in java.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.



    PptxBarcodeFragmentRenderHandler placed 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

  • PdfBarcodeRenderTest rasterises 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.
    BarcodeRunsTest holds 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. PptxVectorFragmentsTest renders 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-artifact step declared retention-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-pdfs at 7.57 GB over 1009 artifacts and coverage-core-aggregate at 2.66 GB
    over 837 — and nothing downstream reads any of them: there is no actions/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-report keeps 30, because it answers "when did this signature move, and
    against which baseline" during release prep rather than during the pull request.
    benchmark-smoke and benchmark-gate-reports keep 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-full and jmh-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-patch group. exec-maven-plugin
    3.6.3 → 3.6.4 in benchmarks/ and examples/, maven-install-plugin and
    maven-deploy-plugin 3.1.4 → 3.2.0, and the test-scope byte-buddy pin 1.18.13 → 1.18.14
    in core/. 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. ShowcaseSiteGuardTest fails the build
    on a featured id that is not a card, a card file missing from showcase/, 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 at develop instead of the released docs on main.

  • 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. ShowcaseSiteGuardTest now 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.mjs tests 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.json carries a schemaVersion, 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. ShowcasePresetRegistrationTest holds
    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
    ShowcaseSiteGuardTest fails the build on a manifest without a schemaVersion, 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 inline release-context block now holds stableVersion, releaseTag and
    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
    VersionConsistencyGuardTest holds every occurrence of all seven spots equal...

Read more

GraphCompose v2.4.0

Choose a tag to compare

@DemchaAV DemchaAV released this 14 Sep 16:47

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.
  • CvSkill carries the level as the document words it.
  • The structured invoice model carries what a second sheet needs.
  • CvEntry carries a link.
  • CvEntry carries a location and a mark, and gains a builder.
  • CvIdentity carries 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 DATE of DATE | ● | 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 receipt family — 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.
  • SerifHeadline links 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 Tc draws.
  • 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-templates is 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

Choose a tag to compare

@github-actions github-actions released this 31 Aug 21:25

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
    still FontName.HELVETICA and 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 a w:ftr / w:hdr part, 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() and pageTotal() return a node instead of an int:
    resolved text on a fixed-layout export, a live PAGE / NUMPAGES field 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. Reading PageContext.number() on a semantic
    export raises UnsupportedOperationException naming 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
    through document.chrome().zone(...), takes a content function called once per page with
    that page's PageContext and returns any DocumentNode. 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 that DocumentPageNumbering conflates. 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
    AtomicNodeTooLargeException naming 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). DocumentHeaderFooter is 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 now height positioned 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()
    (and canvas().innerHeight(), which it still aliases) report the reduced area, so a
    composition that sizes itself against the page — TimelineMinimal splits 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. ChromeGeometryGuardTest pins 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
    height reserves 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 never CvEntry.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, SidebarPortrait and
    MintEditorial compose 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 atomic addRow, so an uncapped block raises
    AtomicNodeTooLargeException instead 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 @Internal marker the policy already
    promised.
    docs/api-stability.md has always put the whole
    com.demcha.compose.engine tree in the Internal tier — removable in any release, no
    deprecation window — and names engine.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 published graph-compose-render-pdf Javadoc said so.
    engine.render.pdf and engine.render.pdf.helpers now carry the marker at the package
    level, the way document.layout does, and PdfFont, GlyphFallbackLogger,
    PdfHeaderFooterRenderer and PdfWatermarkRenderer carry 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.

    InternalEnginePackageMarkerTest enforces the package half module-locally, in
    graph-compose-render-pdf and graph-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

Choose a tag to compare

@github-actions github-actions released this 26 Aug 23:48

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 LayoutSnapshot did 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 LayoutDiagnosticSnapshot that wraps the layout
    snapshot rather than on LayoutSnapshot itself. That distinction is the guarantee:
    LayoutSnapshot still has exactly the four components it had in 2.0, so its JSON, its
    toString() and its equals are unchanged however you serialize it — through
    LayoutSnapshotJson, through an ObjectMapper of your own, or by hand. Every committed
    baseline in this repo is unchanged, and nothing added here can reach one of yours.

    LayoutDiagnosticSnapshot.formatVersion versions the envelope independently of the
    layout snapshot's 2.0, so a section added later moves one number and not the other.
    LayoutSnapshotOptions is a builder rather than an overload so that next section costs
    a method rather than a new layoutSnapshot(...) signature.

    LayoutDiagnosticSnapshot.typography() is a list of LayoutTypographySnapshot, 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
    LayoutTextLineSnapshot per 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() on path, 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, resolvedFamily and decoration are three fields because the face
    needs all three.
    A standard-14 face such as HELVETICA_BOLD is 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.
    fontSubstituted reports 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, nor Helvetica + DEFAULT from Helvetica + BOLD. The family
    rule is reachable as FontLibrary.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, decoration and fontSize describe the text the engine actually
    measured — after an autoSize shrink, and after a span-level override — so the reported
    size always matches the line boxes beside it. declaredFont stays what the paragraph
    asked for.

    The limits, stated rather than implied. A paragraph using a non-default
    TextVerticalAlign has 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

Choose a tag to compare

@github-actions github-actions released this 25 Aug 16:40

v2.2.1 — 2026-08-25

Public API

  • The SVG surface graduates from @Beta to Stable. SvgPath, SvgIcon,
    PathBuilder.svg(svgPath), both addSvgIcon(...) 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.svgIcon and the emoji pipeline were built on @Beta SvgIcon
    without carrying the marker themselves; now no SVG entry point does. No
    binary or source change for callers — the remaining @Beta carriers are
    the NodeDefinition Extension 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: Platform extracted as Pla orm, certification as
    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 GSUB substitutions 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
    and fl are drawn as two letters. PDFBox applies ccmp, liga and clig together
    and offers no way to keep one without the others, but in the bundled families the
    Latin ccmp changes 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 a SectionNode, ContainerNode,
    RowNode or LayerStackNode measured 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 from emitFragments, so dispatching a composed cell straight at
    the child's emitFragments picked 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 a LayerStackNode layer — 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 same RowSlots path 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 same IllegalStateException the 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 own LayerStackNode layer lays it out correctly.

  • The SVG reader honours the opacity family. opacity, fill-opacity and
    stroke-opacity — attribute or style="", number or percentage, with SVG's
    inheritance for the paint slots and composition for group opacity — now
    multiply into each layer's flat paint alpha, on top of any alpha the colour
    already carries from an 8-digit hex or rgba(). 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 an opacity="0" guide layer no longer paints at all. A
    partial opacity cannot reach a gradient slot — the DocumentPaint contract
    refuses translucent stops because shadings carry no alpha — so a gradient
    under fill-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-rule value outside nonzero / evenodd / inherit, previously unread, is
    now refused like any other bad presentation value). A mask="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-numeric rgb() channel or rgba() 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 \n inside
    text(...), which renders as a single line — the newline is whitespace
    between two words there. The recipe and the DocumentTableCell Javadoc now
    name the three cell shapes explicitly: text(...) for one line,
    lines(...) for several, node(...) for any registered node (and
    ParagraphNode does honour \n as a hard break, inside a cell as
    anywhere else). The two examples that showed the misleading form were
    switched to lines(...), and the composed-cell showcase gained a section
    and a row inside table cells.

  • The SVG Javadoc stopped describing a younger reader. SvgGradients claimed
    focal radials and stop-opacity are "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 ...
Read more

GraphCompose v2.2.0

Choose a tag to compare

@github-actions github-actions released this 15 Aug 12:28

v2.2.0 — 2026-08-15

Public API

  • A paragraph can say which way it runs. ParagraphBuilder.direction(...) takes
    TextDirection.LTR, RTL, or AUTO, 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 with w: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+061C and 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 an ActualText marked-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
    same TextDirection.LTR, RTL or AUTO a 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 AUTO reads. 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 a textAnchor of
    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:bidi on the cell's paragraph and
    w:rtl on 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, a ZWNJ between 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 OpenType GSUB — which a PDF never executes: showText walks the font's
    cmap and 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 (the GSUB-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's ToUnicode map 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...

Read more

GraphCompose v2.1.1

Choose a tag to compare

@github-actions github-actions released this 04 Aug 23:27

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-compose coordinate 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 a v2.1.0 badge — the release that shipped it, and whose own
    copy of that deck is published as a .pptx.
  • lineSpacing is documented in the units it uses. The authoring cheatsheet
    called it a leading multiple with a default of 1.0. It is extra space in
    points, defaulting to 0 — 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-pptx was listed before testing while 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 hold graph-compose-testing at 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. testing now installs
    second, and ReleaseScriptInstallListGuardTest derives 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-compose carries 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
    by PublishedJavadocCoordinateGuardTest; CI now builds the jar the release profile
    builds and looks inside, failing if the index, GraphCompose or DocumentSession is
    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
    subpackages set to com.demcha.compose.document, so GraphCompose — 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
    break doclint exists to catch. Nothing else caught it either, because the
    published Javadoc jar is built with doclint=none so a broken tag never blocks a
    release. The gate now covers the whole of com.demcha.compose, root package
    included, and the two headings are <h2>. Widening it also pulls in the
    @Internal engine package: excludePackageNames does not take effect alongside
    subpackages, and that costs warnings rather than failures, since failOnError
    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 by CI Gate found those jobs with a
    pattern admitting lower case and hyphens — everything today's names happen to use.
    A job added as build_and_test or CodeQL was not matched, and neither was
    security_scan: # nightly or 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's needs, 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
    CiGateCoverageGuardParsingTest can 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.md is the page
    the README and the documentation index both point at, and nothing held it to the
    folder it indexes: a new page under docs/recipes/ left every gate green while
    having no inbound link from anywhere. RecipeCatalogueGuardTest now 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 under web/ and no longer skips these, since they ship
    in the repository; -PostReleaseOnly leaves 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 under assets/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 named Helvetica-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 to graph-compose-core: two had been deleted months
    earlier and two live in graph-compose-qa and graph-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, and CiGuardListGuardTest fails the job if a name in
    it stops resolving.

  • The aggregate status check notices when nothing was built. CI Gate is one of
    the two checks develop and main require, and it watched the four heavy jobs
    without watching the path-detection job they all gate on. When that job's
    git fetch returned HTTP 503 the four resolved to skipped rather than failure,
    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, and CiGateCoverageGuardTest reads 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 own if: condition rather than an exclusion list, so a new
    job either joins the gate or fails the guard.

  • graph-compose publishes 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: the failOnError=false that hid the empty state is gone.
    PublishedJavadocCoordinateGuardTest fails 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...

Read more

GraphCompose v2.1.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 12:33
58ad29c

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-pptx is on the classpath; a missing backend fails with
    MissingBackendException naming the artifact. DocumentPageSize.SLIDE_16_9
    (960 × 540 pt) and SLIDE_4_3 (720 × 540 pt) match the PowerPoint defaults.
    The PPTX surface — the document.backend.fixed.pptx packages 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-section buildPdf(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 gets rw-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 from keepTogether(), 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 the false default), and
    SectionNode and LineNode each gain a trailing keepWithNext record component.
    Constructor calls are unaffected — both records keep an overload at the previous
    argument count — but the canonical component count changed (SectionNode 13 → 14,
    LineNode 17 → 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; use buildPptx(Path). The
    session has a single configured output path, shared with buildPdf(), 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 first ServiceLoader match — letting classpath order decide the
    renderer — an ambiguous format now throws IllegalStateException naming the
    competing provider classes.
  • MarkdownInline gains appendTransformed, appendUpperCased and
    appendIfPresent, and PdfRenderEnvironment gains fillAlphaState /
    strokeAlphaState for 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 with UnsupportedNodeCapabilityException; custom handlers
    plug in through PptxFragmentRenderHandler and Builder.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-Italic and 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.UNDERLINE and STRIKETHROUGH.
    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.rgba everywhere —
    text runs, lines, side borders, and ...
Read more

GraphCompose v2.0.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 06:41

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.*, and templates.proposal.*, all on BrandTheme — is now the
    single template surface.
  • The standalone BusinessTheme design-token bundle (and its DocumentPalette /
    SpacingScale / TextScale / TablePreset companions) is no longer part of the
    library. It lives on only as a styling helper inside the examples module; author
    documents with explicit DocumentColor / DocumentTextStyle values or a template
    BrandTheme.
  • The DSL name-aliases DocumentSession.builder() and DocumentDsl.text() have been
    removed. Use DocumentSession.dsl() and DocumentDsl.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. Use linkTarget() and read the external URI from
    ExternalLinkTarget.options().
  • The unused engine-internal Font.adjustFontSizeToFit(...) (and its PdfFont /
    WordFont implementations) has been removed; text auto-sizing is resolved by the
    layout compiler.
  • The dormant Entity-Component-System engine internals have been removed: the
    EntityManager / SystemECS runtime, the Entity component model with its
    geometry / coordinator / renderable companions, the ECS render pipeline
    (engine.render.* and the guide renderers under engine.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 public guideLines(...) 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 /ID from 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 via protect(...)
    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 new MultiSectionDocument.toPdfBytes(FixedLayoutRenderer) /
    writePdf(FixedLayoutRenderer, OutputStream) overloads — the multi-section
    counterpart of DocumentSession.render(backend).
  • The layered template packages dropped their .v2 suffix:
    com.demcha.compose.document.templates.<family>.v2.* →
    com.demcha.compose.document.templates.<family>.* for cv, coverletter,
    invoice, and proposal. 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-core coordinate, and the original graph-compose coordinate
    becomes a thin drop-in aggregator
    that depends on graph-compose-core — so an
    existing graph-compose dependency 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 the graph-compose wrapper aggregates so a
    bare graph-compose still renders PDF, while graph-compose-core alone is lean
    (it throws MissingBackendException if asked to render without a backend). The
    built-in templates now ship in graph-compose-templates (opt-in; the
    graph-compose wrapper does not bundle them). The
    optional graph-compose-fonts / graph-compose-emoji artifacts are unchanged.
    Full install guidance ships with 2.0.0.
  • graph-compose-render-pdf is a separate artifact: the entire PDFBox backend —
    document.backend.fixed.pdf.** and the engine.render.pdf.** render tree — plus
    PDFBox, zxing (barcodes), and the commons-logging→SLF4J bridge leave
    graph-compose-core. The core keeps the DocumentSession API and the
    ServiceLoader seam (FixedLayoutBackendProvider / FontMetricsProvider); the
    PDF provider ships in render-pdf and is discovered at runtime. Depend on
    graph-compose (or graph-compose-render-pdf directly) for PDF; a bare
    graph-compose-core renders nothing until a backend is on the classpath.
  • graph-compose-templates is 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. Add graph-compose-templates for the ready-made presets; a
    consumer that used them through graph-compose before must now add this artifact
    (the com.demcha.compose.document.templates.** packages are unchanged).
  • The semantic office backends are separate artifacts and Apache POI leaves the
    engine: DocxSemanticBackend ships in graph-compose-render-docx (which brings
    POI transitively — add it to export DOCX), and PptxSemanticBackend ships in its
    own graph-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-poi build profile is retired.
  • graph-compose-testing is now a separate artifact: the consumer testing
    support — LayoutSnapshotAssertions (deterministic layout snapshots) and
    PdfVisualRegression (pixel-diff of rendered pages) — leaves the
    graph-compose jar 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; add graph-compose-testing
    at test scope to keep using them.
  • graph-compose-bundle is 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-coverage module runs JaCoCo report-aggregate over the engine, the
    PDF backend, and the built-in templates plus the graph-compose-qa cross-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 .v2 package rename, the BusinessTheme removal, the retired classic
    presets, and Font.adjustFontSizeToFit — each with its migration action.

GraphCompose v2.0.0-rc.1

Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Jul 22:15

Release v2.0.0-rc.1. See CHANGELOG.md for details.