Skip to content

GraphCompose v2.2.0

Latest

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. Everything else takes exactly the path it took before, saving once.

    Password protection is applied between the two saves rather than before them, because
    encrypting is part of saving and writes ciphertext back into the streams it encrypted —
    a map built by a protected first save would be unreadable, and the correction would
    silently find nothing. The protected-document test reads the decrypted map itself, since
    opening and extracting were both true even while the correction was being skipped.

    And each reordered run now states its own text in the file. The glyphs of a
    right-to-left run go out backwards — that is what drawing one means — and every reader
    was left to work the letters back out for itself. The run is now wrapped in an
    ActualText marked-content section carrying its text as written (for Arabic, the
    letters rather than the joined forms), so a reader that honours the section gets the
    words with no algorithm at all, and one that ignores it loses nothing it ever had.

    The section is one run, deliberately not the line. A reader takes ActualText
    instead of the glyphs it covers, so a section spanning the line would swallow the
    left-to-right words inside it — measured before this shape was chosen: wrapping the
    line made PDFBox return the embedded Latin word reversed and dropped the highlight
    chip's glyph positions from extraction entirely. Wrapped run by run, mixed lines
    extract exactly as they did before the sections existed, which a regression test now
    pins. The order of runs across a line stays a bidirectional question either way; the
    letters inside each run no longer are. A document with no reordered run emits no
    marked content at all.

    Two costs, stated plainly. Each section registers a property-list entry in the page's
    resources — roughly a tenth of an Arabic page's size in bookkeeping; writing the
    dictionary inline would reclaim it and needs an operator PDFBox's content-stream API
    does not expose. And a reader that honours ActualText takes it instead of the glyphs
    it covers, so per-glyph text inside a reordered run is no longer available through
    plain extraction — an extractor reports the run's whole text against its first glyph.
    The character each glyph stands for is still in the file, in the font's own map, which
    is where anything asking where a particular word was drawn now has to look; the
    engine's own tests read it there.

  • A chip that opens on Latin still draws its Hebrew the way it reads. A chip takes
    its direction from its first character, and that character settles only where the chip
    sits in the line — not what the chip holds. One opening on Latin is a left-to-right
    run that may still carry Hebrew or Arabic, and the PDF skipped the bidirectional
    resolution for it entirely, handing the content stream logical order: drawn left to
    right, the word came out backwards.

    The direction is now the base the resolution runs against rather than the question
    of whether to run it, so a chip is resolved whenever its text needs it. A chip that is
    wholly right-to-left is unchanged, and so is one holding no such script at all.

    The slide backend has the same gap for the same reason, and it shows in the
    punctuation rather than the letters: PowerPoint orders the Hebrew itself, but a
    neutral standing between two right-to-left words takes their level even under a
    left-to-right base, and PowerPoint does not mirror what it places. A chip reading
    a בית > ספר now reaches the slide as a בית < ספר, so the comparison faces the way
    the line reads; a bracket enclosing Hebrew swaps for the same reason, while everything
    the left-to-right base owns is left as typed.

  • A deck carries the bundled fonts it drew with. A family a caller registers has
    always been embedded; a family this library ships — Amiri for Arabic, David Libre for
    Hebrew, the Noto faces for Georgian and Armenian, Gothic A1 for Hangul — was only warned
    about. The deck named it and embedded nothing, so a viewer without the font installed
    substituted, and for a script the substitute does not cover the slide showed boxes. The
    shipped families are exactly the ones a deck reaches for when its reader is least likely
    to have the font, which is what made the asymmetry sharp: register your own Arabic font
    and it travelled, use the one shipped for exactly that purpose and it did not.

    Only what was drawn travels, down to the face: the bundled set is dozens of families,
    and a face nobody drew is pure weight — embedding carries a font program whole, so the
    five-script catalogue was shipping Gothic A1 Bold at 2.2 MB for glyphs no slide
    contains. Measured on the shipped examples, the Arabic article goes from 29 KB to
    235 KB and the catalogue from 27 KB to 1.4 MB.
    PptxFixedLayoutBackend.Builder.embedBundledFonts(false) declines it for a deck whose
    readers are known to have the fonts.

    A family the deck carries is no longer reported as one the reader must install. The
    substitution warning fires while a run is drawn and the embedding happens after the last
    one, so a deck that carries Amiri was telling its author to register Amiri — the opposite
    of what shipped. A render told not to carry them still says so, because then the file
    really does only name the family.

    Decks that use no bundled binary family are byte-identical — all seven committed deck
    previews, measured. Layout is untouched either way: a registered family also contributes
    viewer metrics, which participate in placement, and taking those from a bundled family
    now would move text in decks that already render correctly. This changes what the file
    carries, not where anything sits.

  • Word draws a right-to-left paragraph on the side it starts from, at the size it was
    asked for.
    Four properties — one on the paragraph, three on its runs — meant something
    different to Word than the way they were written, and all of them showed the moment a
    Hebrew or Arabic document was opened.

    w:jc takes left and right as the start and end of the text flow rather than as
    edges of the page. The alignment the page resolved — flush right for a right-to-left
    paragraph — was written literally, which told Word to align to the flow's end and drew
    the text flush left. Alignment is now mapped through the paragraph's direction, so the
    value written is the one that means what the page decided.

    Hebrew and Arabic are complex scripts, and Word takes their size and weight from
    w:szCs, w:bCs and w:iCs; the Latin twins do not reach them. None of the three was
    written, and the export ships no styles.xml to fall back on, so a 15pt Hebrew
    paragraph was drawn at Word's own default while Latin in the same run obeyed w:sz.
    Each is now written alongside its Latin twin.

    The same two properties now reach a paragraph inside a table cell, which the cell
    walk had been writing without them: it wrote the runs and skipped the alignment and
    direction the identical paragraph gets outside a table. That is where an invoice keeps
    its line items, so every right-to-left cell in one was left undeclared.

    A left-to-right document is drawn the way it always was: the alignment mapping is the
    identity there, and Latin always obeyed the properties that were being written, which is
    why none of this surfaced until a document had Hebrew in it. Its bytes do move, in
    two ways worth knowing before re-baselining a committed .docx. A paragraph in a table
    cell now carries w:jc, where before it carried none — so a right-aligned amount in a
    line item draws flush right instead of flush left, which is what it asked for and did
    not get. And every run now carries the complex-script twins beside the Latin ones; Word
    picks between them per character, so for Latin they are inert, but they are in the file.

    While the size path was being touched: a run's size is now written as the half-points
    w:sz and w:szCs actually count, rather than rounded to whole points first. A 9.5pt
    label — the timeline builder writes two — was reaching Word as 10pt.

  • A right-to-left slide's punctuation faces the way the line reads. A line that gets
    reordered reaches PowerPoint as one frame per span, each pinned where the layout put it,
    so the order across the line is settled before PowerPoint sees it. What was not
    settled is what happens inside a frame: the text handed over is logical, the frame
    declared no base direction, and a frame holding a lone bracket had nothing to resolve
    against — so a parenthesis closing a right-to-left line was drawn facing the way it was
    typed rather than the way the line reads, while the same document as a PDF was correct.

    Each frame carrying right-to-left text now declares it, which settles placement: the
    em-dash of a mixed line moved to the side it belongs on the moment that was written.
    That is necessary and not sufficient. Measured on a slide, PowerPoint places a neutral
    from the declared direction but does not go on to mirror it, so the character is swapped
    before it is handed over, at the same seam the PDF backend swaps it.

    The cost is worth naming: a copy out of the slide carries the mirrored character rather
    than the typed one. The swapped set is document punctuation and includes < and >, so
    a comparison written between two Hebrew or Arabic words copies out reversed. A >
    surrounded by Latin does not, because the wrapper gives that stretch a left-to-right span
    of its own and the swap is keyed to the span's direction. Left-to-right frames are
    untouched.

    This rests on PowerPoint not applying UAX #9 L4 itself, which is measured rather than
    specified. A viewer that does apply it mirrors the character a second time and draws the
    original bug; the assumption is recorded in the backend capability matrix.

  • A chip that mixes directions is drawn the way each of its parts reads — in both
    backends.
    A chip is one rounded fill, so the wrapper cannot split it at a level
    boundary the way it splits plain text; it reaches the renderer whole, carrying its
    first character's level. The PDF backend reversed and mirrored it whole, and that
    inverted meaning, not just shape: a chip reading (a > b) after a Hebrew word drew as
    (b < a) — operands swapped, comparison flipped — while the chip's interior is
    left-to-right text that UAX #9 neither reorders nor mirrors.

    The engine now resolves the chip's own embedding levels (BidiVisualOrder), and each
    backend takes from that resolution exactly what its viewer lacks. The PDF draws
    characters in the order it is given them, so it gets the full visual transform —
    runs reordered, right-to-left ones reversed and mirrored; for a single-level chip that
    is exactly the old reverse-and-mirror, so a wholly-Hebrew chip is unchanged, and a
    chip holding שנה 2026 no longer draws its year backwards. The slide backend keeps
    the chip's text logical and its frame's direction declared, because PowerPoint
    reorders strong right-to-left characters by what they are, not by what the frame says
    — a pre-reordered string would come back with its Hebrew re-reversed. What PowerPoint
    was measured not to do is the mirroring, so pairs are swapped for it — but only on the
    levels UAX #9 mirrors, which is what keeps the interior's > a >.

Fonts

  • Bundled families for Arabic and Hebrew. FontName.AMIRI and
    FontName.DAVID_LIBRE join the bundled catalog, shipping in
    graph-compose-fonts 1.1.0 (the font artifact keeps its own version line).
    Both scripts previously rendered as ? unless you registered a font of your own.
    Amiri was picked for a property that a later release needs: a PDF draws text through
    the font's cmap without executing OpenType GSUB, so contextual Arabic letter
    forms are only ever reachable when the font itself carries the Arabic presentation
    forms — and popular families that shape purely through GSUB, Scheherazade New and
    Rubik among them, carry none. ArabicHebrewFontCoverageTest holds both families to
    the ranges they were chosen for, so swapping in a GSUB-only family fails loudly.
    David Libre ships no italic upstream; italic styles resolve to its regular face and
    bold-italic to its bold one. No bundled family covers both scripts, so a run mixing
    Arabic and Hebrew still needs a font registered through FontFamilyDefinition.
    Text is still laid out in logical order — this release makes the glyphs available,
    not the bidirectional ordering or Arabic joining that use them.

  • Bundled families for Georgian and Armenian. FontName.NOTO_SANS_GEORGIAN and
    FontName.NOTO_SANS_ARMENIAN ship in the same 1.1.0 font artifact. Both scripts
    rendered as ? before, and both are covered in full: Armenian in both cases, Georgian
    in Mkhedruli and Mtavruli — the capitals headings are set in, which Unicode encodes in
    a block of its own, so a family carrying only the lowercase range sets body text and
    loses every title. Upstream publishes them as variable fonts with no static weights to
    take, so the artifact carries the regular instance and the other faces resolve to it:
    bold Georgian renders unemboldened rather than failing, which is asserted rather than
    left to be discovered.

    With Arabic, Hebrew and Korean that makes five scripts covered by a named family. A
    paragraph is drawn in a single family, so a run mixing two scripts needs one family that
    carries both — and for Arabic, Georgian, Armenian and Hangul no bundled family carries
    more than its own, so that means a font of your own registered through
    FontFamilyDefinition. (Hebrew is the exception: Tinos and Cousine carry it too.)
    fonts/README.md has the table, and
    BundledScriptCoverageTest holds it to the binaries.

  • A bundled family for Korean. FontName.GOTHIC_A1 ships in the same 1.1.0
    font artifact, in a drawn regular and bold. It carries all 11 172 precomposed Hangul
    syllables — asserted whole rather than sampled, since a gap in that range loses
    whichever words use it while the rest of the document renders — plus both jamo forms
    and Latin-1, Latin Extended-A, the whole Cyrillic block and the modern Greek alphabet.

    The Latin coverage is why this family rather than a better-known one. A paragraph is
    drawn in a single family, so a Korean sentence holding a European name is drawn
    entirely in the Korean font; the popular alternatives carry ASCII but almost no
    accented Latin, which turns Müller into M?ller with the Korean around it
    rendering perfectly. Hanja are not covered.

    Chinese and Japanese are still without a bundled family, and not by oversight: the
    official static Noto CJK faces use CFF outlines, which the PDF backend cannot embed
    at all, and the variable ones default to the Thin weight — the weight a renderer
    without variable-font support draws. Register a CJK font of your own through
    FontFamilyDefinition.

  • A stale font artifact is now distinguished from a missing one. Asking for a
    family that a newer graph-compose-fonts introduced produced the same message as
    having no font artifact at all — an instruction to add a dependency that was already
    there. The two causes are now told apart, and the stale case names the version the
    consumer actually has.

    The artifact states that version itself: it ships a small descriptor written at build
    time, and the engine reads it. Presence is asked separately, of a face the artifact has
    carried since its first release — the descriptor only ships from 1.1.0, so for the
    releases published before it a missing descriptor means "too old to say", not "not
    here", and those two need opposite advice. The first shape of this carried a map from family folder
    to the release that introduced it, which meant the catalog had to remember the
    artifact's history and every new font needed an entry in a class that otherwise knows
    nothing about which fonts exist. Reading the version off the artifact answers the
    question that actually helps — what do you have rather than what should you have
    and needs no maintenance as families are added.

Templates

  • Timeline Minimal renders the whole CV. The preset used to drop content three
    ways, none of them visible in the output. Per-module caps kept the first few lines
    of each block and discarded the rest, so a fourth degree or a third employer simply
    was not drawn — on a page that the fixed-height axis left looking only four-fifths
    used. Prose was cut at a character count, ending a summary mid-sentence with an
    ellipsis while the column still had room. And sections were matched to modules by
    title keyword, taking the first hit for each category: a second prose section was
    shadowed by the first, a section whose title matched nothing — a user's own
    "Awards" or "Publications" — was never looked at, and the ones that did match were
    relabelled, so "Projects" printed as EXPERTISE and "Additional Information"
    printed as LANGUAGES.

    Everything the document carries is now rendered. Headings come from the section's
    own title, with the preset's label left only for a module that matched nothing.
    An unmatched paragraph is still set as prose: reaching the main column that way
    no longer sets a summary in the tighter face the bulleted modules use.
    Content past one page continues on the next: the body is a row, and a row is
    atomic — the paginator cannot break inside one — so the preset estimates its
    columns' heights from the column width and font metrics, emits one row per page,
    and lets each finished row overflow naturally. The axis keeps its full height on
    the opening page and follows the content on a continuation page.

  • New SectionAllocation for CV presets. Hands each section out once and
    returns what no module claimed, which is what SectionLookup.firstMatching alone
    cannot express. The remaining CV presets still slot by keyword and still discard
    what does not match; they are unchanged here.

Build

  • CI builds the companion asset artifacts instead of downloading them. The engine
    pulls graph-compose-fonts and graph-compose-emoji at test scope, and both carry
    their own version lines, so the tree can legitimately pin a version before it is
    published. Jobs scoped to a single module without -am resolved them from Maven
    Central and failed on dependency resolution — before running anything — whenever the
    pinned version was newer than the published one. The guards and binary-compatibility
    jobs now install both from source first, and the three benchmark jobs that already
    did so for fonts do it for emoji too. Whether a pinned version really exists on
    Central is still checked, by the release smoke harness, which resolves every
    coordinate through Central in an isolated repository.

  • The open changelog entry and the development version cannot name different
    releases.
    The post-release step opens the next line by incrementing the patch, so a
    GA of X.Y.Z always leaves the poms on X.Y.(Z+1)-SNAPSHOT — right when the next
    release is a patch, wrong from the first commit when it is a minor. What that costs is
    not tidiness. While the poms and the changelog name different releases, an @since
    tag written in between has two answers available, and @since is a contract with the
    consumer that outlives the cycle: the last time the two disagreed, tags went out
    against both, and the ones that followed the previous release had to be corrected when
    the line was. VersionConsistencyGuardTest now holds the poms to the open entry, which
    is where the next version gets recorded first, so there is one answer to take.

    An entry counts as open because it carries no date, not because of the word after the
    version — the 2.1.0 line was opened as — in progress, and a check that recognised
    only one spelling would have watched that whole line go by. For the same reason a ##
    heading that names no release is reported rather than skipped: leaving the check with
    nothing to compare must not look like agreement. Two open entries fail as well, being
    an ambiguous answer rather than a wrong one.

    The wording is then held to the exact — Planned the cut replaces, em dash included,
    since an ASCII hyphen is as invisible to that replacement as another word would be.
    Getting it wrong does not ship an undated entry — the cut stops on the missing date —
    but it stops the release rather than the commit that introduced it, and by then the
    cause is a step away. A version keeps its pre-release qualifier throughout, so a dated
    -rc.N entry reads as shipped instead of as a second open one.

    Having no open entry passes: the post-release bump writes none and runs this guard as
    its own gate, so demanding one would fail the commit that opens the window. The check
    begins with the cycle's first entry. It compares the two recorded answers against each
    other, so it catches one being corrected without the other — not a pair that was wrong
    together from the start.

Documentation

  • Three examples that render right-to-left documents rather than describing them. An
    Arabic article that runs onto a second page, a Hebrew invoice, and a catalogue of every
    bundled script. The article exists because the interesting failures only appear at
    length: a paragraph wrapping over many lines with each one reordered on its own, and a
    page break landing inside a paragraph that then has to start from the right edge again.
    The invoice exists because that is where right-to-left text meets numbers — nearly every
    line mixes a Hebrew description with a Latin product name, a quantity and a total.

    One thing the examples had to work around is worth knowing before writing one: a list
    carries no direction of its own, so an Arabic list needs align(RIGHT) or its bullets
    sit on the wrong side of the text. The invoice is built from rows rather than a table
    because it was written before a cell could declare a direction — a table would carry it
    now, and the layout is what keeps the rows.

    The invoice also shows what isolates are for. A Latin name inside a Hebrew line is
    handled by the algorithm, but the punctuation touching it is neutral — so
    "GraphCompose Ltd." printed its full stop at the far end of the line until the run was
    isolated.

  • The image at the top of README is page one of the Maven Central banner. It was a page
    of the module-first deck, and the caption beside it pointed at a PDF the release does not
    advertise. Both now name the same document — MavenBannerPptxExample, which the release
    publishes as a PDF and as an editable PowerPoint deck, so the picture a reader sees is a
    page of a file they can open. What the release commits keeps its name and its place in
    the cut, so the version stamped into the image still arrives from the version bump.

    The banner gained a page for this release: Amiri, David Libre, the two Noto faces and
    Gothic A1 each set a word, and where that word sits in its card is the direction it
    declared — Hebrew and Arabic to the right, the rest to the left. Nothing on the page is an
    image.

Tests

  • Right-to-left layout is held against coordinates and against pixels. Five scenarios —
    Arabic wrapping, Hebrew wrapping, a page break inside a right-to-left flow, a line mixing
    scripts with Latin and digits, and every bundled script on one page — each asserted twice.

    The two catch different things, which was measured rather than assumed: switching the
    right-to-left reversal off leaves every coordinate identical (same spans, same widths,
    same positions) and changes only the order the glyphs are painted in, so all five layout
    snapshots still pass while four of the five pixel baselines fail. Coordinates cannot see
    a line drawn backwards. The fifth — every bundled script on one page — guards the font
    catalogue rather than the reordering: its right-to-left lines are too short to trip a
    pixel budget, and the wrapping and mixing scenarios are what guard the reordering.

    That same experiment found the pixel budget was too loose: copied from a page four times
    the area, it absorbed the regression on the shortest scenarios. It scales with the page
    now.

    The published examples are held the same two ways, with one honest difference: their A4
    pages are rasterised differently enough across platforms that a pixel budget tight
    enough to catch a reordering regression on the quietest page would fail an honest render
    on another machine — the two signals overlap, measured. So their pixel baselines catch
    gross breakage and show what a deliberate change looked like, while exactness is carried
    by the byte-level drift guard, which no rasteriser can blur.

  • What a document tells a reader its own text is, is now two questions. What an
    extractor returns and what the font's glyph map says are not the same thing: the first
    can be right while the second is wrong, because a reader is free to normalise a shaped
    form back to its letter and PDFBox's own extractor does. Both are asserted, for Arabic
    and for Hebrew, so a correction that overreached — rewriting entries that were already
    right — fails on the Hebrew case rather than passing everywhere.

    Two existing assertions moved to a different channel as a result. They read the joined
    forms out of what the file said its glyphs meant, which only worked while that answer
    was the drawing rather than the text; joining is now read from the glyph codes
    themselves, which is where it always was.

  • A hyperlink, an underline, and a highlight chip are each held to the words they were
    written on
    , in a line that gets reordered. All three are placed by arithmetic rather
    than by where the glyphs went, and in a left-to-right line the two orders agree, so
    nothing distinguishes a correct implementation from one that walks the logical order.
    Each is checked against that failure: placed by a logical walk, the mark or rectangle
    lands under the other words, and the left-to-right control keeps passing — so the tests
    tell the reordering apart from the arithmetic.

    A first-line indent is held to the edge its paragraph starts from, which is the right
    one: the indent is a text prefix, a left-to-right shape of thinking, and a reader of an
    earlier measurement could reasonably have concluded it was being dropped. It is not —
    the prefix spaces are drawn glyphs sitting at the margin, so asking where a line's first
    glyph is answers where the padding starts rather than the text.

  • Every rendered document is now held against a fresh render. A third of the example
    catalogue — thirty-two documents, the cover-letter presets and most of the CV gallery
    among them — rendered on every run with nothing comparing the result, so a change to the
    engine moved those documents and no test said so. They are committed as previews now,
    which is what puts them under CommittedAssetDriftTest: a PDF is compared by its bytes,
    and a difference names the file that moved.

    The list of deliberately unpublished documents shrinks from thirty-five entries to three,
    each carrying its reason. The emoji gallery stays out on weight: its embedded glyph set
    renders to nearly 4 MB against 1.4 MB for the thirty-two together, so committing it would
    put another copy of that in history on every deliberate re-render. The other two hold
    pixels rasterised at render time, which a CI runner antialiases differently from a
    developer's machine — the measurement AssetContent already records — so committing them
    would fail the build for a document nobody changed. Those three renders stay unguarded,
    which is the price of the exceptions rather than an oversight.

Fixed

  • An inline chip reads the same on a slide as it does on the page. A chip is one
    rounded fill, so the wrapper cannot split it where its characters change direction — it
    reached PowerPoint as a single frame holding the whole thing. PowerPoint has a
    bidirectional engine of its own and re-resolves whatever string it is handed, and it
    re-resolved this one without the line around it: a fragment out of context comes back
    in the order that fragment deserves, so a chip reading a בית (ספר) put its Latin in one
    place on a slide and another in the PDF. The chip's text is now split into its
    directional runs, one frame each, placed in the order the page places them; the fill
    stays a single shape. A single-level run has nothing left to reorder, so the question
    does not arise rather than being answered.

    The mirroring that goes with it was keyed to the wrong thing. Paired punctuation is
    swapped for PowerPoint because it places a neutral without mirroring it — but only for a
    run that is uniformly right-to-left, the one it reverses for itself. A run that mixes
    levels comes out right from the text exactly as typed, and swapping it drew (a > b) as
    )a > b( inside a Hebrew line. Both halves were measured in PowerPoint, one category of
    content at a time, rather than derived.

  • Word mirrors the brackets in an Arabic line. A right-to-left paragraph declared its
    direction with w:bidi and nothing else. That settles which edge the line starts from;
    it does not settle how Word resolves the characters inside a run, which comes from
    w:rtl. Without it a run is handled as Latin, and paired punctuation is not mirrored —
    صدرت في (2026) was drawn as صدرت في )2026( while the same document as a PDF was
    correct. Every right-to-left run now carries w:rtl, in a paragraph and in a table cell
    alike; it is the run-level half of the pair w:szCs belongs to.

    Hebrew came out right either way, which is how this shipped: the defect needs Arabic,
    where digits following a letter resolve as an Arabic number rather than a European one,
    and only there does Word part company with the algorithm. Measured in Word, one property
    at a time, against a matrix that also ruled out w:cs and w:lang.

  • A Word export resolves an image once, and says so when it cannot. writeImage
    needed the node's data twice over — the bytes it writes, and the intrinsic size it
    measures the fit against — and fetched it twice, from two places. Resolving is not
    free: the source cache copies the byte array whole and hashes it, so a large image
    paid both costs on every export.

    The second fetch was also reading a different thing. The cache keys on the path alone,
    so once a render had warmed it, a file rewritten underneath gave the export fresh bytes
    from disk and the previous version's dimensions — a picture embedded at another image's
    size, with nothing reporting it, because both halves succeeded and simply described
    different files. The bytes now come from the resolution that sizes the frame.

    One behaviour changes with it: an image whose source cannot be read stops the export.
    It used to disappear from the document silently — not a decision, but the side effect
    of a read that swallowed its own exception — and a document that comes back one picture
    short is the worst of the available answers.

  • DOCX keeps the styling a mixed paragraph asks for. A RichText paragraph exported
    with every run in the paragraph's base style, so a bold segment, an accent-coloured
    segment and plain text all came out identical — a valid .docx, no warning, and the
    emphasis simply absent. InlineTextRun documents its style as falling back to the
    paragraph's when null; the backend was applying that fallback unconditionally, with
    the run in hand. Each run now carries its own style, in a paragraph and in a row
    cell, which used to be written from its concatenated text in one style. (A table
    cell is written from lines rather than runs and still carries no styling.)

  • STRIKETHROUGH reaches Word. It was the one DocumentTextDecoration with no
    branch in the DOCX style mapping and fell through to no decoration at all.

  • DOCX writes a table on the grid its cells occupy. An authored row is not a row of
    columns: a rowSpan covers positions in the rows below and those rows do not repeat the
    covered cells, and a colSpan makes the record count differ from the column count. The
    backend read a row's records as its columns and sized the grid from the first row's
    record count, so a rowSpan shifted every row beneath it one column to the left, and a
    colSpan did that and left the grid too narrow, dropping the cells past its end
    without a word. colSpan and rowSpan now map to Word's w:gridSpan and w:vMerge, a
    cell takes the most specific text style in the table / column / row / cell cascade, a
    composed cell exports its node instead of the empty lines() it has by definition, and
    a multi-line cell is separated by a real break rather than a newline Word reads as a
    space.

    A table whose authored rows cannot form a rectangle now fails the export with the
    position at fault, where before it was drawn wrong. That is the rule the layout pipeline
    already applied, so a document the PDF backend refuses is no longer one DOCX accepts.

  • A DOCX table is painted the way it was styled. DocumentTableStyle carries a fill and
    a stroke, and neither reached the file: a zebra body, a header band and a ruled grid all
    exported on Word's defaults. The fill maps to w:shd and the stroke to w:tcBorders, and
    the cascade that already resolved a cell's text style now resolves every field on its own,
    so a table-wide rule survives a row that only overrides the fill. A merged cell is painted
    on every position it covers, since a w:vMerge continuation draws its own shading and
    would otherwise stripe the region. A stroke of no width — how this codebase says "no
    border", and what a shipped CV preset uses — writes that instruction rather than omitting
    it, so a borderless design no longer inherits the grid Word puts on a table by default.
    What a fill loses is its opacity: w:shd is opaque, and blending it needs a background
    Word owns rather than the backend.

    The Word companion example styles its table, so the feature ships with a render behind it
    — and both of its committed previews move, the DOCX for the new markup and the PDF because
    the fixed-layout backend paints the same style it was never given before.

  • A DOCX image is the size it asked for, in the shape it asked for. The drawn box came
    from the node's literal width and height and fell back to a hardcoded 100 × 100 pt
    when either was absent, so an image sized only by scale — or by one dimension with the
    other implied by its aspect ratio — came out at a size nothing had asked for. The box now
    comes from NodeDefinitionSupport.resolveImageDimensions, the rule layout already applies,
    clamp to the page's content width included.

    fitMode was not read at all, which left CONTAIN and COVER behaving as STRETCH.
    CONTAIN is embedded at its fitted size, which needs no clipping because it is inside the
    box already; COVER fills the box and the overflow is cropped out of the picture source,
    centred, since Word has no clip for an inline picture — the same geometry the PPTX backend
    expresses. And the picture type is read from the image's signature instead of every picture
    being declared PNG, which is what a JPEG was announced as.

    The grid itself is resolved by TableGrid, extracted from the layout pipeline so both it
    and the backend answer from one implementation. It is @Internal: a backend seam, not a
    public promise.

Documentation

  • The rich(...) lambda examples seed the builder they are handed. The Javadoc on
    ParagraphBuilder.rich(Consumer<RichText>) and AbstractFlowBuilder.addRich(Consumer<RichText>)
    opened the lambda with t.text("Status: ") — but RichText.text(String) is a static factory,
    and Java resolves a static call made through an instance reference. The documented line
    compiled, built a separate RichText, discarded it, and the paragraph rendered empty — no
    warning, no exception. Both examples now seed with plain(...), the factory's Javadoc spells
    out the trap, and a regression test pins the documented lambda form to a non-empty paragraph.
    The shape-as-container recipe's RichText.of() — a factory that never existed — is now
    RichText.text(...).