v2.2.0 — 2026-08-15
Public API
-
A paragraph can say which way it runs.
ParagraphBuilder.direction(...)takes
TextDirection.LTR,RTL, orAUTO, which reads the direction off the first strong
character. Hebrew and Arabic were previously laid out and drawn in logical order — the
order text is read in, not the order a page draws it — so every line came out reversed
in a document that otherwise looked finished.Direction is a separate choice from
TextAlign: alignment says where a line sits,
direction says which way it runs. They meet in one place, so a right-to-left paragraph
aligns right unless the caller chose an alignment of their own.Lines are resolved with the Unicode Bidirectional Algorithm, so a Latin word or a
number embedded in Hebrew keeps running forwards, and the paragraph direction only
decides what it is embedded in. A line with no right-to-left character resolves to
itself without the algorithm running at all, so existing documents take the path they
always took — held to that by the layout snapshots and visual baselines, none of which
moved.A paragraph is the unit this applies to; the sibling entry below carries it into a
table cell.All three wrap paths carry it: plain text, inline runs (what templates author
through), and markdown. Each backend does what it must and no more — the PDF backend
reverses a right-to-left run, because a PDF draws characters in the order it is given
them; PowerPoint and Word have their own bidirectional engines, so the text reaches
them in logical order rather than rewritten. Word is told the paragraph's base
direction withw:bidi, which is the only way it can lay out a line that opens on a
neutral character; PowerPoint is told the same thing per frame, because pinning a span
where the page put it settles the order across the line but not which side a neutral
falls on inside a frame.The bidirectional formatting characters (
U+200E,U+200F,U+061Cand the
embeddings and isolates) now survive control-character sanitizing until the algorithm
has read them. They are what an author uses to steer a neutral stretch of text, and
removing them with the rest of Unicode category C deleted the instruction before
anything could act on it. They draw nothing, so they are dropped again at the seam
that measures and draws — where substituting them with?would have put a visible
mark on the page and given a zero-width character a width.One shaping limit is worth knowing. Letters are given their contextual forms before
the line is wrapped, because wrapping measures widths and the forms are what carry
them. A word longer than the column is therefore broken with the forms it was given
while whole: the letters either side of the break keep their connecting strokes, as if
the word continued across the line boundary. Arabic does not break words, so this only
arises where the word cannot fit at all — the case every script degrades in — and
re-shaping the halves would change their widths, which is what the wrap already spent.One limit was worth knowing here, and this release closes it further down this page:
the content stream carries the visual order, and every reordered run now states its
text as written in anActualTextmarked-content section — see the entry below. The
DOCX export was never affected, since Word receives logical text. -
A table cell can say which way it runs.
DocumentTableStyle.direction(...)takes the
sameTextDirection.LTR,RTLorAUTOa paragraph does, and inherits down the cascade
a cell style already follows — the table's default, then the column's, then the row's,
then the cell's own — so one call turns a whole table round.A cell written as a plain string reached the page through the table's own layout rather
than the text pipeline, and so received neither of the two things that make Hebrew and
Arabic correct: the same string drew reversed in a cell while drawing properly in a
paragraph, and Arabic came out as isolated letters instead of joined. Both now happen on
the cell path, and a column is measured on the joined forms, so an auto column is sized
to the text the page actually draws rather than to a wider form that never appears.The cell is the unit
AUTOreads. Two cells side by side under one declared direction
answer it separately, and a cell's second line does not run the other way from its first
because it happens to open on Latin. Direction decides the edge only when nobody asked
for one — a right-to-left cell sits at its right edge unless it carries atextAnchorof
its own — which is the rule a paragraph already follows for alignment.Each backend does what it must and no more. The PDF is painted, so the engine shapes and
reorders the line itself and marks it with the text as written, which is what a reader
copies out. Word is told the direction twice —w:bidion the cell's paragraph and
w:rtlon its runs — and receives the text untouched, because it reorders and joins on
its own. PowerPoint is told the direction on the cell's frame and likewise receives the
text as written.The Unicode formatting controls reach whoever still has to read them. A cell's line keeps
its joining controls and direction marks through the backend's own sanitising, because
below that sit the shaper and the algorithm — and in a cell handed to PowerPoint neither
has run yet. They are dropped at the glyph seam, where a zero-width character has nothing
to draw. Removed earlier, with a space in their place, aZWNJbetween two Arabic letters
did not merely go missing: it became a word break, and the letters the author had
separated joined up anyway.That last one is the opposite of what a paragraph does, and the difference is the size of
what each hands over. A paragraph reaches PowerPoint as one frame per span, so no frame
holds a bracket pair for PowerPoint to resolve and the mirroring has to be done first. A
cell is one frame holding a whole line, which is the input PowerPoint's own algorithm is
complete for — mirroring it first swaps the brackets a second time, and(2026)closing
an Arabic cell was drawn as)2026(until this stopped. The upside is that a copy out of
a cell carries the brackets as typed, which the paragraph path cannot promise.A left-to-right cell is untouched, so a table of Latin content keeps the geometry and the
export it always had. A cell holding Hebrew or Arabic is not, and deliberately: what a
declaration settles is the direction a line is embedded in, while a script runs the way
it runs inside that whatever the base. So a cell that declares nothing is now shaped and
ordered too, and its auto column measured on the joined forms. Such a table moves —
because what it drew before was the word backwards. -
Arabic joins. Arabic letters change shape by position, and a font does that
through OpenTypeGSUB— which a PDF never executes:showTextwalks the font's
cmapand nothing else. The engine now shapes Arabic itself, mapping each letter to
its contextual presentation form (and lam-alef to its ligature) before measurement,
so what is measured is what is drawn. Vowel points and direction marks are
transparent to the join, as Unicode's joining rules say. PowerPoint gets the base
letters back — it shapes Arabic itself, and frozen forms would end up in a file
users search and copy from — and Word was never given forms to begin with. The
joining controls travel with them: they are the author's instruction about which
letters may connect, and PowerPoint's shaper is the reader they were written for, so
dropping them would have handed it a word it joins straight back up. A font
that carries the letters but not the forms (theGSUB-only families) now degrades
to unjoined base letters instead of?, which costs the joining rather than the
text. An annotation mark between two letters no longer breaks their join: which
characters are transparent to shaping is decided by Unicode's own rule — general
category — rather than by a list of ranges that covered the vowel points and missed
the rest. In right-to-left runs, paired punctuation is mirrored at the PDF seam
(UAX #9 L4), so a parenthesis in Hebrew faces what it encloses. The mirrored set is
the punctuation that occurs in documents — parentheses, brackets, braces, angle
brackets, guillemets — rather than the whole Unicode mirroring table, so a relational
or set operator such as≤or⊂passes through drawn as written. (The angle brackets
in that list are<and>, which Unicode also classes as mathematical, so they do
mirror.) -
A PDF now says the letters an author wrote, not the shapes they were drawn as. A
font'sToUnicodemap states what each glyph in the file means, and the subsetter
builds it from the characters shown — which, for Arabic, are the joined forms. So the
file stated that a glyph meant U+FE8E, the final form of alef, where the author had
typed U+0627. A reader that applies a compatibility normalisation recovered the letter,
which is why extraction usually looked right; one that handed the code point straight to
a search box did not, and a search for the ordinary spelling of a word found nothing
with no sign of why. The maps are now corrected once the subsetter has built them, so a
copied word is the word, and the lam-alef ligature — one glyph standing for two letters
— comes back out as both. Hebrew is untouched: it is reordered, never shaped, so its
glyphs already named their own letters.Only a document that drew a reordered run pays anything. The map has to exist before it
can be read and it is written during the save, so such a document is saved twice — the
first time into a null sink, so both saves stream and nothing is buffered regardless of
document size. 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
ActualTextmarked-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 honoursActualTexttakes 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 asa בית < ספר, 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:jctakesleftandrightas 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:bCsandw:iCs; the Latin twins do not reach them. None of the three was
written, and the export ships nostyles.xmlto fall back on, so a 15pt Hebrew
paragraph was drawn at Word's own default while Latin in the same run obeyedw: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 carriesw: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:szandw:szCsactually 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שנה 2026no 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.AMIRIand
FontName.DAVID_LIBREjoin the bundled catalog, shipping in
graph-compose-fonts1.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'scmapwithout executing OpenTypeGSUB, so contextual Arabic letter
forms are only ever reachable when the font itself carries the Arabic presentation
forms — and popular families that shape purely throughGSUB, Scheherazade New and
Rubik among them, carry none.ArabicHebrewFontCoverageTestholds both families to
the ranges they were chosen for, so swapping in aGSUB-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 throughFontFamilyDefinition.
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_GEORGIANand
FontName.NOTO_SANS_ARMENIANship 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.mdhas the table, and
BundledScriptCoverageTestholds it to the binaries. -
A bundled family for Korean.
FontName.GOTHIC_A1ships 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 newergraph-compose-fontsintroduced 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
SectionAllocationfor CV presets. Hands each section out once and
returns what no module claimed, which is whatSectionLookup.firstMatchingalone
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
pullsgraph-compose-fontsandgraph-compose-emojiat 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-amresolved 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 ofX.Y.Zalways leaves the poms onX.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@sinceis 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.VersionConsistencyGuardTestnow 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
— Plannedthe 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.Nentry 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 needsalign(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 underCommittedAssetDriftTest: 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 measurementAssetContentalready 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 readinga בית (ספר)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 withw:bidiand 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 carriesw:rtl, in a paragraph and in a table cell
alike; it is the run-level half of the pairw:szCsbelongs 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 outw:csandw: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
RichTextparagraph 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.InlineTextRundocuments 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 arow
cell, which used to be written from its concatenated text in one style. (Atable
cell is written from lines rather than runs and still carries no styling.) -
STRIKETHROUGHreaches Word. It was the oneDocumentTextDecorationwith 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: arowSpancovers positions in the rows below and those rows do not repeat the
covered cells, and acolSpanmakes 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 arowSpanshifted every row beneath it one column to the left, and a
colSpandid that and left the grid too narrow, dropping the cells past its end
without a word.colSpanandrowSpannow map to Word'sw:gridSpanandw: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 emptylines()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.
DocumentTableStylecarries 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 tow:shdand the stroke tow: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 aw:vMergecontinuation 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:shdis 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 literalwidthandheightand fell back to a hardcoded 100 × 100 pt
when either was absent, so an image sized only byscale— 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 fromNodeDefinitionSupport.resolveImageDimensions, the rule layout already applies,
clamp to the page's content width included.fitModewas not read at all, which leftCONTAINandCOVERbehaving asSTRETCH.
CONTAINis embedded at its fitted size, which needs no clipping because it is inside the
box already;COVERfills 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>)andAbstractFlowBuilder.addRich(Consumer<RichText>)
opened the lambda witht.text("Status: ")— butRichText.text(String)is a static factory,
and Java resolves a static call made through an instance reference. The documented line
compiled, built a separateRichText, discarded it, and the paragraph rendered empty — no
warning, no exception. Both examples now seed withplain(...), 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'sRichText.of()— a factory that never existed — is now
RichText.text(...).