Backport to Java 17 - #9
Conversation
FixesDemchaAV#8 Note that the src/test/resources/layout-snapshots/document/nested_list_three_levels.json is changed slightly due to rendering differences based on platform; it may be that a straight merge needs a slight fix to address this, or a standardization. This is probably locale-specific. It may ALSO be that a good idea is to MAKE it locale-specific, so different locales can load different values. I did not do this. I am lazy.
DemchaAV
commented
May 8, 2026
Thanks for the PR — I really appreciate it. I’ll review it properly from desktop and run the tests locally before merging, especially because this touches quite a few files and includes a layout snapshot update. The Java 17 compatibility direction looks very useful, but I want to check the snapshot change carefully so we don’t accidentally make the tests platform- or locale-dependent. I’ll approve the checks and take a proper look. |
jottinger
commented
May 8, 2026
Okay, some thoughts: this is NOT a "good PR." The sanitization code here collapses multiple nonbreaking spaces into a single breaking space, that's where the layout changes are coming from; that's easy to fix BUT it doesn't fix the font inconsistencies across installations. It's still a change that needs to be part of this PR but this PR is not right. |
DemchaAV
commented
May 8, 2026
Yeah, no worries - thanks for catching it before merge. I agree, let’s not merge #9 as-is. I think the cleanest path is to close it and open a new PR with only the Java 17 migration: compiler release, CI JDK, and the Java 21 API replacements. The font sanitization / unsupported glyph thing was on my mind too, but I think it should be a separate PR. Fonts can miss all kinds of characters, so we probably need a proper fallback strategy there — maybe a square for unknown glyphs, or mappings for common symbols like arrows to "->", so the output keeps at least some meaning. But that’s more of a behavior/design change than a Java 17 compatibility fix. Also, the NBSP/list indentation issue is exactly why I don’t want to bless the snapshot change in this PR. Better to keep the Java 17 PR clean first, then handle glyph fallback with focused tests. |
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
Which way a paragraph runs was worked out twice from the same text. The builder scanned for the first character of strong directionality to decide which edge an unaligned paragraph sits at; the layout asked the bidirectional algorithm. Two readings of one rule, and they did not agree. They part company on isolates. UAX #9 rule P2 skips everything between an isolate initiator and its matching PDI when looking for the first strong character — that is what an isolate is for — and a plain scan reads straight into it. A paragraph opening with an isolated Hebrew quotation therefore aligned to the right edge and laid out from the left. This is the release that started carrying isolates through control sanitizing, so the disagreement was reachable by exactly the author who took the documentation's advice on steering a neutral stretch of text. ParagraphDirection is now the single answer, and it lives in the layout package because that is the sanctioned bridge from the canonical surface to the engine: the builder may call there, and only there does the call reach the resolver. Word is the third caller. AUTO used to reach the DOCX export unresolved, on the reasoning that it had already become a concrete alignment — but alignment is not direction, and what Word actually received was a paragraph with no base direction at all, left to infer one. That is the thing w:bidi exists to prevent, and it came out the other way round for a line opening with a digit or a parenthesis. The export is now marked from the same answer the page used, and the test that pinned the old behaviour asserts the new one in three cases instead.
Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it.
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it.
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it.
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it.
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
…base per paragraph Two wrong pages, both plausible enough to ship. A span is reversed — and drawn — as one unit, so it must be single-level. Tokenization splits on whitespace, but a direction change does not need a space: "ב-2026", the idiomatic Hebrew "in 2026", is one token whose digits run forwards inside a right-to-left word. Left as one span it was reversed whole, putting the year backwards — and only on the inline and markdown paths, so the same string authored through .text() rendered correctly while .rich() got it wrong. Spans are now split at each level boundary; sub-widths are re-measured, and advances are additive, so the line's geometry does not move. A chip stays whole (it is one rounded fill) and a right-to-left chip is now reversed at the chip seam, which previously ignored direction entirely. AUTO resolved its base per wrapped line, but UAX #9 fixes the base direction per paragraph — only the line-level reset is per line. Hebrew prose whose continuation line began with Latin flipped that line to a left-to-right base, arranging adjacent lines of one paragraph backwards relative to each other. The base is now resolved once, from the paragraph's full text, in the same place the builder derives AUTO's default alignment, so the page and the alignment cannot disagree. A page split preserves the direction on the fragment node it rebuilds. ParagraphLine now validates visualOrder as a permutation instead of trusting it: the payload is public API, and a hand-built order that was short or out of range would drop spans silently or fail deep inside a render handler. The proofs read glyph positions out of rendered pages: the digits of a mixed token stay forwards, and the Latin-initial second line of an AUTO paragraph keeps its sibling's base. The PPTX tests pin the other half of the contract — logical text in absolute frames, never the shared frame PowerPoint reflows.
Which way a paragraph runs was worked out twice from the same text. The builder scanned for the first character of strong directionality to decide which edge an unaligned paragraph sits at; the layout asked the bidirectional algorithm. Two readings of one rule, and they did not agree. They part company on isolates. UAX #9 rule P2 skips everything between an isolate initiator and its matching PDI when looking for the first strong character — that is what an isolate is for — and a plain scan reads straight into it. A paragraph opening with an isolated Hebrew quotation therefore aligned to the right edge and laid out from the left. This is the release that started carrying isolates through control sanitizing, so the disagreement was reachable by exactly the author who took the documentation's advice on steering a neutral stretch of text. ParagraphDirection is now the single answer, and it lives in the layout package because that is the sanctioned bridge from the canonical surface to the engine: the builder may call there, and only there does the call reach the resolver. Word is the third caller. AUTO used to reach the DOCX export unresolved, on the reasoning that it had already become a concrete alignment — but alignment is not direction, and what Word actually received was a paragraph with no base direction at all, left to infer one. That is the thing w:bidi exists to prevent, and it came out the other way round for a line opening with a digit or a parenthesis. The export is now marked from the same answer the page used, and the test that pinned the old behaviour asserts the new one in three cases instead.
* feat(api): let a paragraph declare its writing direction Text is stored in the order it is read and drawn in the order it appears on the page. For Latin those coincide, so the engine never had to tell them apart; for Hebrew and Arabic they do not. TextDirection is the authoring side of that distinction: LTR, RTL, or AUTO, which takes the direction from the first strong character. It is separate from TextAlign because alignment says where a line sits and direction says which way it runs — but they meet in one place, so a right-to-left paragraph aligns right unless the caller chose an alignment. The builder tracks that choice explicitly; a field defaulted to LEFT cannot tell "not chosen" from "chose LEFT", and either reading loses one of the two behaviours. BidiParagraphResolver wraps the Unicode Bidirectional Algorithm for the layout pipeline. It resolves the raw line, before control characters are stripped, because the explicit direction marks (U+200E, U+200F, U+061C) are category Cf and the sanitizer removes them — a resolver running afterwards would never see the instruction the author wrote. A line with no right-to-left character resolves to itself without constructing a Bidi at all, so existing documents take the path they always took. The resolver names its own base direction rather than the public enum, so the engine keeps knowing nothing about the document surface. ParagraphNode gains the direction as a trailing component with the previous canonical signature kept as a delegating constructor, the way verticalAlign and anchor were added. Nothing consumes the direction yet — wrapping and rendering follow. * feat(engine): lay out a line as one span per directional run A span is drawn with one show-text operation, so a line whose direction changes partway cannot be reordered by moving spans around — a single span holding "שלום Hello" draws in logical order however the renderer walks it. The plain-text path built exactly that: one span per line. A line that changes direction is now split at each change, and carries the permutation that describes how the spans are drawn. The spans themselves stay in logical order, because that is the order the text is read and the order the semantic backends need; only the permutation knows about the page. It is empty when the two orders coincide, so a left-to-right line takes the branch it always took and cannot drift. The bidirectional formatting characters now survive control-character sanitizing. They draw nothing and exist only to steer this algorithm, so removing them with the rest of category C deleted the author's instruction before anything could read it. They are dropped once read: from the span text, and at the glyph seam that both measures and draws — where substituting them with '?' would have put a visible mark on the page and given a zero-width character a width the wrapping had already committed to not having. The two copies of the fixed-width test measurement become one helper. * feat(engine): draw a right-to-left line the way it is read A PDF emits the characters of a string in the order it is handed them, so Hebrew stored in logical order came out backwards — every letter of every word, in a document that otherwise looked finished. A right-to-left run is now reversed as it is drawn, and only there: the span keeps its logical text for the semantic backends and for text extraction. Reversal is by grapheme cluster, because a letter and its vowel point are one thing on the page and reversing characters would put the mark before the letter it belongs to. Spans are walked in visual order. The cursor advances over that same sequence, so a run of text spans still leaves the pen exactly where the next one starts — the implicit text position stays correct and the left-to-right branch is untouched. Fixes the resolver reporting a single run at the paragraph's base level rather than the run's own. Direction of a paragraph and direction of a run are different things: Hebrew alone in a left-to-right paragraph is one run that still runs right to left, and Latin alone in a right-to-left paragraph is one run that does not. Both were being read as the paragraph's, which reversed the wrong lines — whole lines, silently. RtlGlyphOrderTest reads the X coordinate of every glyph out of the rendered page. A layout snapshot records node geometry and never sees a span, so it cannot tell a reordered line from a logical one; this can, exactly, with no pixel threshold. * feat(api): let AUTO decide the alignment it implies A paragraph whose direction is taken from its text was resolving that direction but still starting at the left edge, so Hebrew set to AUTO read correctly and sat on the wrong side of the page. AUTO now aligns right when the first strong character runs right to left — the same rule the algorithm applies to the text itself, and one that Character answers directly. That matters here: document.dsl is canonical surface and may not reach into the engine, but reading a character's directionality needs nothing from it. The decision stays in the builder because that is the only place that knows whether the caller chose an alignment; the node carries a concrete value and cannot tell a default from a choice. An explicit align() still wins. Link rectangles are now placed by walking the spans in the order they are drawn. No span carries a link on the directional path yet, so this fixes nothing visible today — it stops the second traversal from disagreeing with the first once inline runs start producing reordered lines. * refactor(engine): give the three backends one line-start expression Where a line starts along x was written out three times: the PDF handler that draws the glyphs, the PDF backend that places the clickable rectangles over them, and the PPTX handler that anchors its text boxes. They agreed, and nothing made them keep agreeing. A drifted copy would not crash. It would put a link somewhere its text is not, or move a slide's words a few points from where the page had them — the kind of difference a reader notices and a suite does not. They now call one helper, in the payload package all three already depend on. Behaviour is unchanged, including a line wider than its box overflowing towards the edge it is aligned to rather than being clamped: clamping would disagree with the width the layout already committed to. The alignment that a right-to-left paragraph implies has one place to live now, before the remaining backends need it. * feat(engine): carry direction through the inline-run path Rich text is how templates author a paragraph, and it was still laid out in logical order — so a CV or an invoice with Hebrew in it came out backwards while a plain paragraph of the same text came out right. Inline runs already arrive as one span per word, so unlike a plain paragraph nothing has to be split: each span is told which way it goes, and the line carries the order they are drawn in. Both halves matter. Marking the spans without reordering them reverses each word in place and leaves the words in the wrong order, which looks close enough to be missed. Direction is applied after the spans are built rather than inside the loop that builds them, so the wrapping, the chip coalescing and the trim seams are untouched. The line resolves over a probe string in which an inline graphic stands in as an object replacement character: a span's position in the probe then equals its position in the line, and an image inside right-to-left text takes part in the ordering as the neutral object it is instead of breaking it. A line with no right-to-left character returns an empty order and unmodified spans, so the path every existing rich-text document takes is the one it already took. * feat(engine): finish direction across the wrap paths and the slide backend Markdown was the last wrap path laying text out in logical order. It splits a line into styled bodies rather than words, which is a different splitter producing the same shape of spans, so it takes the same pass the inline path does. All three paths now carry direction, and a path that quietly kept logical order next to two working ones is exactly the kind of gap nobody looks for. PPTX drew a plain line by appending its runs to one shared frame and letting PowerPoint flow them, which puts the words back in reading order and undoes the ordering the layout resolved. A right-to-left line now goes through the per-span absolute frames the backend already uses for chips and graphics, so each word is pinned where the page put it, and spans are walked in visual order as the PDF backend walks them. The text handed to PowerPoint stays logical and is deliberately not reversed. PowerPoint has its own bidirectional engine and reverses a run itself; doing it here as well would reverse it twice. That asymmetry with the PDF backend is the point — one draws characters in the order given, the other does not. * feat(docx): tell Word which way a paragraph runs Word has its own bidirectional engine, so unlike a fixed-layout backend it reorders and shapes the text itself. The one thing it cannot work out is the paragraph's base direction: without w:bidi a line beginning with a neutral character, or one mixing scripts, is laid out as left-to-right text that happens to contain Hebrew. The document opens, every letter is there, and the line reads from the wrong end. A right-to-left paragraph now carries w:bidi. The text is handed over in logical order and deliberately not reversed — doing here what Word already does would undo it. That is the opposite of the PDF backend, and the tests say so out loud, because the asymmetry is the kind a later reader would "fix". w:rPr/w:rtl is not written per run: the paragraph's base direction is enough for Word to resolve each run itself, and a per-run flag would restate what it already derives. AUTO is not passed on. It was resolved into a concrete alignment when the node was built, and letting Word guess again could reach a different answer than the page did. * docs(changelog): record the writing-direction work for 2.2.0 Describes what a reader gets and what they do not: direction as a separate choice from alignment, the bidirectional resolution that keeps embedded Latin and digits running forwards, why the formatting characters now survive sanitizing, and that Arabic still renders unjoined. * docs(examples): show what each writing direction does to a paragraph Pairs every row with the call that produced it: the same Hebrew set with no direction, with RTL, with AUTO, and with RTL plus an explicit align(LEFT) that overrides the right edge RTL would otherwise pick. The mixed row shows a Latin word and a year running forwards inside right-to-left text, and the last row shows Arabic, right-aligned and still unjoined. Registered in GenerateAllExamples and the examples README. * docs(recipes): explain writing direction and when to reach for it Covers the distinction the API rests on — alignment says where a line sits, direction says which way it runs — and the three things a reader hits next: that an explicit align() wins, that AUTO reads the first strong character, and that a mixed line resolves itself so runs need no splitting by hand. Names the bundled Hebrew and Arabic families with the version that carries them, says plainly that no bundled family covers both scripts, and that Arabic is still unjoined. * perf(engine): stop paying for direction on lines that have none An A/B against this branch's base measured a real regression on documents with no right-to-left text at all: proposal-template +17.7% latency and -15% docs/s, long-token +11.6%, cv-template's layout stage +25.9%. The cost was per line and entirely wasted. Every laid-out line called the resolver, which copied the line into a char[] for java.text.Bidi.requiresBidi and then allocated a run record and a list to say "nothing to do". Lines are now screened before any of that. requiresBidi reads the string in place and stops at the first character that settles it, and the wrap paths ask that question before entering the resolver, so a line with nothing to reorder allocates nothing and takes the branch it took before direction existed. The scan stands in for a JDK method, so it is tested against it rather than against my reading of it — which caught two disagreements. A left-to-right mark alone does not require resolution (it has no right-to-left text to steer), and a surrogate pair has to be decoded and asked, because that range holds emoji as well as right-to-left scripts and treating every pair as bidirectional would send an emoji-bearing line down the slow path for nothing. * fix(engine): split spans where direction changes and fix AUTO to one base per paragraph Two wrong pages, both plausible enough to ship. A span is reversed — and drawn — as one unit, so it must be single-level. Tokenization splits on whitespace, but a direction change does not need a space: "ב-2026", the idiomatic Hebrew "in 2026", is one token whose digits run forwards inside a right-to-left word. Left as one span it was reversed whole, putting the year backwards — and only on the inline and markdown paths, so the same string authored through .text() rendered correctly while .rich() got it wrong. Spans are now split at each level boundary; sub-widths are re-measured, and advances are additive, so the line's geometry does not move. A chip stays whole (it is one rounded fill) and a right-to-left chip is now reversed at the chip seam, which previously ignored direction entirely. AUTO resolved its base per wrapped line, but UAX #9 fixes the base direction per paragraph — only the line-level reset is per line. Hebrew prose whose continuation line began with Latin flipped that line to a left-to-right base, arranging adjacent lines of one paragraph backwards relative to each other. The base is now resolved once, from the paragraph's full text, in the same place the builder derives AUTO's default alignment, so the page and the alignment cannot disagree. A page split preserves the direction on the fragment node it rebuilds. ParagraphLine now validates visualOrder as a permutation instead of trusting it: the payload is public API, and a hand-built order that was short or out of range would drop spans silently or fail deep inside a render handler. The proofs read glyph positions out of rendered pages: the digits of a mixed token stay forwards, and the Latin-initial second line of an AUTO paragraph keeps its sibling's base. The PPTX tests pin the other half of the contract — logical text in absolute frames, never the shared frame PowerPoint reflows. * test(engine): pin the bidi seams that nothing was holding BidiText existed for one property — reversal by grapheme cluster — and no test exercised it: swapping the BreakIterator for StringBuilder.reverse left the suite green, because the rest of it uses unpointed Hebrew, which naive reversal happens to get right. Pointed text and emoji now hold the claim, the iterator is pinned to Locale.ROOT, and reversal is asserted to be its own inverse. The sanitizer's three direction-mark operations and the glyph seam's drop are now covered the same way: the marks survive sanitizing, leave at the seam that both measures and draws, and a genuinely unencodable character still becomes a question mark — the exemption must not widen into swallowing text. The fast scan stands in for the JDK's requiresBidi, so its contract is stated against it: it may over-include (the Arabic comma, class CS, costs a harmless trip through the resolver) but can never miss. The JDK's own mask counts Arabic-Indic digits, which the tests document rather than assume. The bidirectional characters in the scan are now written as escapes: raw ones in source are the Trojan-Source shape, flagged by scanners and easy for a tool to mangle silently. The remaining new public surface carries its @SInCE. The changelog says out loud what extraction does with a right-to-left line — the content stream carries the visual order, so copy-paste yields reversed characters until ActualText is written; DOCX is unaffected. The examples README gains the section its catalogue row already pointed at. * fix(engine): make the escape comment in the bidi scan true The comment above isBidirectional said the ranges were written as escapes to avoid the Trojan-Source shape; the literals below it were the raw invisible characters themselves. A comment that misstates the line under it is worse than no comment, and these particular characters are the ones a diff cannot show. The ranges are now genuinely \u escapes, byte-identical in behaviour — the scan-parity test against java.text.Bidi pins that. Also drops two imports made redundant by their files' wildcard imports, and stamps the version marker on ParagraphNode's direction component the way its two sibling additions already carry it. * docs(examples): publish the text-direction preview Every document the examples catalogue renders is either a committed preview or deliberately listed as unpublished — a render nobody decided about is what the drift guard exists to catch, and the new text-direction example was exactly that. The preview is committed and linked from its catalogue row and section, the way every other published example is. * fix(engine): read a paragraph's direction in one place Which way a paragraph runs was worked out twice from the same text. The builder scanned for the first character of strong directionality to decide which edge an unaligned paragraph sits at; the layout asked the bidirectional algorithm. Two readings of one rule, and they did not agree. They part company on isolates. UAX #9 rule P2 skips everything between an isolate initiator and its matching PDI when looking for the first strong character — that is what an isolate is for — and a plain scan reads straight into it. A paragraph opening with an isolated Hebrew quotation therefore aligned to the right edge and laid out from the left. This is the release that started carrying isolates through control sanitizing, so the disagreement was reachable by exactly the author who took the documentation's advice on steering a neutral stretch of text. ParagraphDirection is now the single answer, and it lives in the layout package because that is the sanctioned bridge from the canonical surface to the engine: the builder may call there, and only there does the call reach the resolver. Word is the third caller. AUTO used to reach the DOCX export unresolved, on the reasoning that it had already become a concrete alignment — but alignment is not direction, and what Word actually received was a paragraph with no base direction at all, left to infer one. That is the thing w:bidi exists to prevent, and it came out the other way round for a line opening with a digit or a parenthesis. The export is now marked from the same answer the page used, and the test that pinned the old behaviour asserts the new one in three cases instead. * docs(dsl): say once what resolveAlign does, and say it accurately The comment explaining that the alignment default asks the shared resolver was pasted twice, and the method Javadoc still credited Character with the answer from before the resolver existed. * test(engine): name the frame and the flag these guards are about Both asserted around their subject rather than at it. The slide guard checked that a right-to-left line is not in the frame PowerPoint reflows — true of any name at all, including a renamed shared frame, which the sibling Latin test would force someone to rename while leaving this one silently passing. It names the frame the path is supposed to produce instead. The single-run wrapping test is called "is marked" and asserted the span count and the line text, both of which a fast path skipping the marking would leave exactly as they are. It asserts the mark. * docs(engine): say that direction stops at the paragraph A table cell's text goes through the table's own layout, which has no direction handling, so the same Hebrew string draws correctly in a paragraph and reversed in a cell. Confirmed by rendering both into one page and reading the content stream: the paragraph emits its letters reversed for display, the cell emits them in logical order. Nothing said so. Someone would have found it by looking at a finished table and wondering why only that part was backwards, which is the worst way to learn the scope of a feature. * docs(engine): say what each backend is actually told about direction The entry lumped PowerPoint and Word together as being 'marked'. Only Word is: it receives w:bidi, which is the one way it can lay out a line opening on a neutral character. PowerPoint receives no direction mark at all — each span is pinned in an absolute frame where the page put it, so the position carries what a mark would have said.
Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it.
The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table.
* feat(engine): map Arabic letters to their contextual forms Arabic letters change shape by position, and a font does that through OpenType GSUB — which a PDF content stream never executes: showText walks the cmap and nothing else. The only way to reach the joined forms there is to hand the font their code points directly, from the presentation-forms block the bundled Arabic family was chosen for carrying. The shaper is a pure text-to-text mapping: a per-letter table of the four positional forms, the four lam-alef ligatures, and the joining walk that picks a form from what precedes and follows. Vowel points and the bidirectional format characters are transparent — they sit between letters without breaking the join, exactly as Unicode's joining rules say, and a direction mark that DID break a join would visibly tear the word apart. It is idempotent, since the forms lie outside the base block, and text with no Arabic letter is returned as the same instance after one early-exiting scan — the path every existing document takes. The inverse mapping is deliberate API: a font that lacks a form can draw the base letter — unjoined but readable — instead of losing the text to '?'. That degradation lands with the glyph seam, in the next change; nothing consumes the shaper yet. * feat(engine): join Arabic and mirror paired punctuation Shaping runs before measurement — on the logical lines of the plain and markdown paths, at token construction on the inline path — because the contextual forms have their own advance widths and the engine's contract is that what is measured is what is drawn. Text without an Arabic letter passes through as the same instances. Each backend again does only what its medium requires. The PDF draws the forms, since its content stream will never run the font's own shaping. PowerPoint gets the base letters back at its seam: it shapes Arabic itself, and frozen presentation forms would end up in a file users search and copy from. Word was never handed forms at all. Mirroring lives with reversal at the PDF seam and nowhere else, for the same reason reversal does — both PowerPoint and Word apply UAX #9 L4 themselves, and a mirror baked into the span would come out double-mirrored there. A font that carries the Arabic letters but not the forms — the GSUB-only families — now degrades at the glyph seam to unjoined base letters instead of '?', which costs the joining rather than the text; a lam-alef ligature decomposes back into its two letters. The proofs read the content stream: every Arabic glyph on the page is a presentation form and none a base letter, a parenthesis before Hebrew is drawn facing what it encloses, and the slide text carries base letters with no form leaked into it. * docs(recipes): Arabic joins now, and the docs stop apologising for it The recipe's "not done yet" section becomes a section on how joining works — through the presentation forms the font carries, with the degradation to unjoined base letters named for fonts that lack them. The example's Arabic row caption follows, and its committed preview is regenerated so the published render shows the joined forms it now produces. * fix(engine): sanitize before reversing, and ligate only adjacent lam-alef Two ordering bugs, both found by review before anything shipped. The glyph seam may degrade a lam-alef ligature into its two letters, and it appends them in logical order — into a string the handler had already reversed for display, which landed the pair swapped on the page. The handler now sanitizes the logical text first and reverses last, so every substitution the seam makes keeps its letter order. The degradation also warns once per font now: losing the joining silently left an author no clue why their Arabic came out unjoined. The ligature is formed only for an adjacent lam-alef pair. A vowel point between the letters belongs to the lam; folding the pair into one glyph stranded the mark after the ligature, and the inverse mapping the slide backend relies on put it back on the wrong letter — the slide's text then differed from what the author wrote. A vocalized lam-alef renders as two joined letters instead: unligated, and exactly restorable, which the round-trip test pins. Alef maksura's deliberate divergence from ArabicShaping.txt gets the comment that stops the next reader from fixing it, the recipe names the covered block (the Persian and Urdu extensions render unjoined for now), and the fully-qualified names become imports. * build: mark PDFs binary so checkout cannot corrupt the committed previews Without the attribute, core.autocrlf treats a committed preview PDF as text on checkout and rewrites its line bytes in the working tree. The file then shows as permanently modified, which corrupts the published preview and blocks every branch switch across a change to it — the A/B benchmark harness was the first to hit the wall. * test(qa): hold the right-to-left acceptance render against a pixel baseline Every other test in this feature asserts one mechanism — span order, run direction, the code points in the content stream — and each can pass while the page still looks wrong, because none of them looks at the page. This one does. The document is the acceptance case from the issue: Hebrew with an embedded Latin word and a year, Arabic joined with a mirrored parenthesis pair around Latin, and a paragraph left to AUTO. The baseline was blessed after reading the render, and any visible change to how these scripts lay out fails it, whichever mechanism caused it. The pixel budget matches the sibling regression test's, which was settled against observed cross-platform antialiasing drift; a real regression here repaints whole lines and clears it by orders of magnitude. * fix(engine): let an author say where Arabic must not join The zero-width non-joiner and joiner are how Unicode lets an author override contextual joining — U+200C forbids a join the letters would otherwise make, U+200D forces one — and both were deleted before the shaper, the only thing that reads them, ever ran. They are category C like any other control, so the sanitizer that runs first removed them along with the rest, and a document could not express the distinction at all. The shaper needed almost nothing for the non-joiner: it is an opaque non-joining character, so the lookups that walk letters already came to the right answer once it survived. The joiner is the one that had to be taught, since it stands in for a letter that is not there. Both are consumed during shaping, because past that point they are code points no font can encode — measured as one thing and drawn as a substitution mark. Text with no Arabic in it never reaches the shaper, so the glyph seam drops them there instead. The joining controls get their own predicate rather than joining the bidirectional ones: these say nothing about direction, only about whether two letters connect. Also: - Auto-size measured a highlight run unshaped while the token it builds from that run is shaped, so a chip was sized against a string the layout never uses. Both paths shape now. This is not currently visible end to end, because auto-size does not shrink a paragraph holding a chip at all for any script, which is its own defect. - The degraded-shaping warning is keyed on the font rather than on (font, code point). A font that carries no presentation forms carries none of them, so the second letter said nothing the first had not, and a document turned one fact about the font into a warning per letter. - The changelog claimed mirroring per UAX #9 L4 without the scope the implementation has always documented: the punctuation that occurs in documents, not the whole mirroring table. * fix(engine): keep the joining controls all the way to a shaper that reads them Consuming U+200C and U+200D during shaping was right for the PDF and wrong for everything downstream of it. PowerPoint shapes Arabic itself, so the slide backend maps the presentation forms back to base letters — and with the controls already gone, it handed PowerPoint two letters that joined straight back up. An author who wrote a non-joiner got a word that ignored it, in a file the page had rendered correctly. They no longer need consuming. Measurement and drawing both pass through the same glyph seam — getTextWidth sanitizes before measuring — so a control that cannot be encoded is removed from both at once and nothing drifts. That leaves it free to stay in the text, which is what a backend with a shaper of its own needs to see. Two seams had to stop removing it. The line splitter ran the sanitizer that strips direction marks, which had been widened to strip every formatting control; it is back to the bidirectional ones, since those have been read by the time a line is split into runs and the joining controls have not. The slide backend now sanitizes with a variant that keeps them: a PDF drops a zero-width control because it has no glyph to draw, which is not a reason that applies to a backend emitting text. The names follow the behaviour: removeExceptDirectionMarks keeps more than direction marks and is now removeExceptFormattingControls, while removeDirectionMarks again removes exactly what it says. Separately, which characters are transparent to shaping is decided by general category rather than by a list of ranges. Unicode's rule is that anything it does not list explicitly and that is Mn, Me or Cf has joining type T; the ranges covered the common vowel points and missed the rest, so an annotation mark such as U+0610 unjoined the letters around it — a word came apart where an author added an honorific. The two engine text packages are tagged @internal. Their documentation had always said internal, but only the annotation is something a guard test can read, and the coverage test now reads it. * fix(engine): clear the whole dedup cache the reset promises to clear resetForTesting clears the state that makes a warning fire once, and the per-font set added for the degraded-shaping warning was not in it — so a test that expected that warning passed or failed on whether an earlier test in the same JVM had already triggered it. Two documentation corrections alongside. The paragraph about the joining controls landed after @PARAM, where javadoc stops treating prose as description, and it said the shaper consumes them, which stopped being true when they were left in the text for a backend with its own shaper. And the root pom named the fonts line as 1.0.0 — it is 1.1.0 now — so it points at the property and the module instead of repeating a number that goes stale every font release. * perf(engine): return early from the inverse lookup for a certain miss baseLettersOf walked the ligature list and all forty-two letter entries before concluding that a code point is not a presentation form. Its caller on the glyph seam asks that about every code point a font cannot encode, on each occurrence, so a Latin document with one unsupported bullet paid the walk per bullet. Everything the shaper emits lives in one block, so a bounds check answers the miss. * docs(engine): name the operators that are not mirrored The entry said a mathematical operator passes through drawn as written, in a sentence that had already listed angle brackets among what does mirror. U+003C and U+003E are both: Unicode classes them as mathematical, and they are in the table. Naming a relational or set operator says the intended thing without the same character sitting on both sides of it.
The comment said PowerPoint applies UAX #9 L4 itself and that a mirror baked into the span would double-mirror there. It does not: measured on a slide, a bracket closing a right-to-left line kept facing the way it was typed even with the frame's direction declared, which is why the PPTX backend now mirrors at its own seam. The reason mirroring stays out of the span is the one that survives — the span's logical text is what the semantic backends read.
…ntitled to The mirroring was keyed to the span's direction flag, which describes a chip only approximately: a chip is one rounded fill, so the wrapper cannot split it at a level boundary and gives it its first character's level whole. Swapping such a span whole reaches an interior sitting at the opposite level, where UAX #9 L4 mirrors nothing -- a chip reading "(a > b)" after a Hebrew word was stored, and drawn, as ")a < b(", with the comparison inverted in the only copy of the text the file has. A chip is exempt from the swap. It still declares its frame direction, which is what places a neutral, and its runs stay in logical order the way PowerPoint's own engine expects. Three cases cover the two frame-declaring sites, which had one between them: the chip frame declares its direction, the chip's interior keeps its comparison, and a left-to-right line stores its brackets as typed. Each goes red on its own when the hunk it guards is reverted alone. The existing allSatisfy over a filtered list is given the isNotEmpty it needs to be worth anything.
The entry led with PowerPoint doing the mirroring and closed, eight lines later, with GraphCompose doing it because PowerPoint does not. It now says once what was measured. The bullet earlier in the same release still claimed PowerPoint needs no direction mark; it does, per frame. Two scoping corrections a reader needs. The worked example was "a > b" in Latin, which is exactly the shape the swap never touches -- that stretch gets a left-to-right span of its own. And the capability matrix presented "visually correct" as a property of the format when it holds only for a viewer that skips UAX #9 L4: one that applies it mirrors the character a second time and draws the original bug, with the stored text wrong too. The matrix also claimed every frame declares its direction, where table cells, headers, footers and watermarks build theirs through singleRunBox and declare nothing.
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 rather than 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. A chip holding Hebrew and a year drew the year backwards for the same reason. BidiVisualOrder resolves the run's own embedding levels, reorders the level runs visually, and reverses and mirrors only the right-to-left ones. For a single-level run the result is exactly the old reverse-and-mirror, so a wholly-Hebrew chip is unchanged -- which is also why the transform is safe to apply to every right-to-left chip rather than only the mixed ones. The slide backend uses the same resolution instead of its own exemption: a mixed chip is handed to PowerPoint as the engine's settled visual string in a frame that declares no direction -- the same string the PDF draws -- because handing it logical text would let PowerPoint re-place the neutrals it does not mirror. A single-level chip keeps the plain-span treatment: logical order, mirrored pairs, declared direction. Tests at all three seams, each red when its hunk alone is reverted: BidiVisualOrderTest pins the transform, PdfRtlChipTest reads the drawn glyphs by position (extraction answers with ActualText and the ToUnicode map, which both deliberately report what the author wrote, whichever order the page drew), and PptxRightToLeftFrameTest holds the two chip treatments apart. Full reactor gate green; no shipped example or visual baseline combines a chip with right-to-left text, so committed previews are untouched by construction.
…and a chip keep its meaning (#548) * fix(pptx): let a reordered line's frames say which way they read A right-to-left line is drawn 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, paired punctuation still has to be mirrored, and PowerPoint does that from the paragraph's base direction — which nothing declared, leaving it left-to-right by default. So a frame holding a lone bracket had nothing to resolve against. The sample slide put the closing bracket's frame at x=142 and the opening one at x=368 — the right visual positions, the engine's own resolution — and PowerPoint drew each character the way it was typed, giving a line that reads )AUTO resolves it ... ( where the same document as a PDF reads (AUTO resolves it). The em-dashes of a mixed line sat on the wrong side of their frames for the same reason. Declared rather than mirrored in the backend, though both would look right: the stored text stays the author's, so a copy out of the slide is the written word and PowerPoint's own Arabic shaper still has the letters it expects. Mirroring here would have baked reversed brackets into what a reader copies. Four tests: the frames that carry right-to-left text declare it, the bracket's frame specifically does, the text inside is still unmirrored, and a left-to-right line declares nothing. The first two fail without the change; the last is the control for every existing deck. * fix(pptx): mirror the punctuation too — declaring the direction is not enough Declaring each frame's direction fixed placement: the em-dash of a mixed line moved to the side it belongs on the moment it was written. It did not fix the character. PowerPoint does not go on to mirror a neutral it has placed, so a bracket closing a right-to-left line kept facing the way it was typed — measured on a slide, ")AUTO resolves it (" where the same document as a PDF reads "(AUTO resolves it)". So the swap happens here as well, at the same seam the PDF backend uses and for the same reason. The cost is real and worth naming: a copy out of the slide now carries the mirrored bracket rather than the typed one. A line a reader cannot follow is the worse of the two. The test that asserted the stored text stays the author's asserted the wrong thing and is replaced by one that pins the swap per frame — each frame is its own paragraph, so the bracket the author closed with is drawn leftmost and has to carry the opening form. * docs(changelog): the slide entry claimed a fix that only went half way Declaring the frame's direction fixes where a neutral lands, not which way it faces; the entry said the text stays the author's, and after the mirroring it does not. Both halves are now stated, cost included. * docs(pptx): say what the two halves each do, and name what copying costs The Javadoc added alongside the mirroring described the approach the mirroring replaced — "declared rather than mirrored here on purpose" — which is how a later reader talks themselves into deleting one half as duplication. It now says what each half settles: declaring the direction puts a neutral on the correct side, mirroring turns it the right way round, and removing either brings the bug back. The mirrored set is document punctuation and includes < and >, so an expression like "a > b" inside a right-to-left line copies out of the slide with the comparison swapped. That is a stronger cost than swapped brackets and the changelog now says so rather than leaving it under "paired punctuation". The matrix moves with the code: the direction cell no longer cites the bug as open, the mirroring cell stops saying PowerPoint does it, and the extraction cell stops claiming runs are stored exactly as written. * docs(changelog): name what mirroring costs a copy, including < and > The mirrored set is document punctuation and includes the angle brackets, so an expression like "a > b" inside a right-to-left line copies out of the slide with the comparison swapped — a stronger cost than swapped parentheses, and it was hiding under "paired punctuation". * docs(pdf): correct what this file says PowerPoint does with mirroring The comment said PowerPoint applies UAX #9 L4 itself and that a mirror baked into the span would double-mirror there. It does not: measured on a slide, a bracket closing a right-to-left line kept facing the way it was typed even with the frame's direction declared, which is why the PPTX backend now mirrors at its own seam. The reason mirroring stays out of the span is the one that survives — the span's logical text is what the semantic backends read. * fix(pptx): leave a chip's interior the punctuation its own level is entitled to The mirroring was keyed to the span's direction flag, which describes a chip only approximately: a chip is one rounded fill, so the wrapper cannot split it at a level boundary and gives it its first character's level whole. Swapping such a span whole reaches an interior sitting at the opposite level, where UAX #9 L4 mirrors nothing -- a chip reading "(a > b)" after a Hebrew word was stored, and drawn, as ")a < b(", with the comparison inverted in the only copy of the text the file has. A chip is exempt from the swap. It still declares its frame direction, which is what places a neutral, and its runs stay in logical order the way PowerPoint's own engine expects. Three cases cover the two frame-declaring sites, which had one between them: the chip frame declares its direction, the chip's interior keeps its comparison, and a left-to-right line stores its brackets as typed. Each goes red on its own when the hunk it guards is reverted alone. The existing allSatisfy over a filtered list is given the isNotEmpty it needs to be worth anything. * docs(pptx): record what the mirroring rests on, and which paths get it The entry led with PowerPoint doing the mirroring and closed, eight lines later, with GraphCompose doing it because PowerPoint does not. It now says once what was measured. The bullet earlier in the same release still claimed PowerPoint needs no direction mark; it does, per frame. Two scoping corrections a reader needs. The worked example was "a > b" in Latin, which is exactly the shape the swap never touches -- that stretch gets a left-to-right span of its own. And the capability matrix presented "visually correct" as a property of the format when it holds only for a viewer that skips UAX #9 L4: one that applies it mirrors the character a second time and draws the original bug, with the stored text wrong too. The matrix also claimed every frame declares its direction, where table cells, headers, footers and watermarks build theirs through singleRunBox and declare nothing. * fix(engine): draw a mixed-direction chip the way each of its parts reads 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 rather than 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. A chip holding Hebrew and a year drew the year backwards for the same reason. BidiVisualOrder resolves the run's own embedding levels, reorders the level runs visually, and reverses and mirrors only the right-to-left ones. For a single-level run the result is exactly the old reverse-and-mirror, so a wholly-Hebrew chip is unchanged -- which is also why the transform is safe to apply to every right-to-left chip rather than only the mixed ones. The slide backend uses the same resolution instead of its own exemption: a mixed chip is handed to PowerPoint as the engine's settled visual string in a frame that declares no direction -- the same string the PDF draws -- because handing it logical text would let PowerPoint re-place the neutrals it does not mirror. A single-level chip keeps the plain-span treatment: logical order, mirrored pairs, declared direction. Tests at all three seams, each red when its hunk alone is reverted: BidiVisualOrderTest pins the transform, PdfRtlChipTest reads the drawn glyphs by position (extraction answers with ActualText and the ToUnicode map, which both deliberately report what the author wrote, whichever order the page drew), and PptxRightToLeftFrameTest holds the two chip treatments apart. Full reactor gate green; no shipped example or visual baseline combines a chip with right-to-left text, so committed previews are untouched by construction. * fix(pptx): keep a chip's letters logical — reordering belongs to the characters PowerPoint reorders a strong right-to-left character by what it is, not by what the frame declares: display order is a property of the letters, and a declared direction only sets the base the neutrals resolve against. A mixed chip handed the engine's pre-reordered visual string therefore had its Hebrew re-reversed on display -- scrambled -- the double reversal the slide backend's own contract test warns about, on the one span that had been routed around it. The chip now keeps its text logical and declares its direction, like every other right-to-left frame, and only the punctuation sitting at a right-to-left level is pre-mirrored (mirrorRightToLeftLevels): placement is what PowerPoint does itself, mirroring is what it was measured not to do, and a ">" at the left-to-right level must stay a ">". For a single-level chip this is the whole-string mirror it always had. The PDF path is untouched -- a content stream draws characters in the order it is given, so the visual transform is exactly right there. mixesDirections is gone: with one treatment per backend, nothing asks. A chip holding Hebrew and a year pins the seam: its stored text must be exactly what the author typed, with the direction declared -- red against a visual-string hand-off, where the stored letters arrive pre-reversed. The bracket chip now pins ")a > b(" with the direction declared: brackets mirrored, comparison as typed, order logical. * refactor(engine): make the null guard the first thing the bidi transforms do Both transforms dereferenced their argument on the strength of an invariant one call away -- levelsFor answers an empty array for null -- which static analysis rightly declines to take on faith. Null and empty are now answered before anything else, and the empty-levels return no longer needs a ternary. Behaviour is unchanged; the existing null and empty cases in BidiVisualOrderTest already pin it.
The image at the top of README was a page of the module-first deck, and the caption beside it linked a third document again. The Maven banner is the one written to be seen first — it names the coordinate, the version and the outputs, and the release re-renders it on every version bump — and nothing on the front page pointed at it. So the hero is page one of that banner, written to the file it always was: ReadmeHeroRenderer replaces ReadmeBannerV2Renderer at the same output path, so the release script keeps its staging list and the version stamped into the image keeps arriving from the same place. One line of the script names the new class. The banner gained a page for the release it is advertising. 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. Three cards below say what the engine does with them: resolve the line through UAX #9, shape Arabic before measuring it, hand each backend what its own text engine needs. The deck now embeds those five families, which is where its size goes: PowerPoint is handed each font whole rather than subset. Two guards moved with the page — the deck's slide count, and its committed preview. The deck's only rasterised element is still the checkmark in the badge.
* docs(readme): make the hero the banner the release advertises The image at the top of README was a page of the module-first deck, and the caption beside it linked a third document again. The Maven banner is the one written to be seen first — it names the coordinate, the version and the outputs, and the release re-renders it on every version bump — and nothing on the front page pointed at it. So the hero is page one of that banner, written to the file it always was: ReadmeHeroRenderer replaces ReadmeBannerV2Renderer at the same output path, so the release script keeps its staging list and the version stamped into the image keeps arriving from the same place. One line of the script names the new class. The banner gained a page for the release it is advertising. 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. Three cards below say what the engine does with them: resolve the line through UAX #9, shape Arabic before measuring it, hand each backend what its own text engine needs. The deck now embeds those five families, which is where its size goes: PowerPoint is handed each font whole rather than subset. Two guards moved with the page — the deck's slide count, and its committed preview. The deck's only rasterised element is still the checkmark in the badge. * docs(readme): name the banner's pages, and guard the step that renders it Three sentences the change above made false, and one it left behind. The README caption said the banner embeds no images: the badge checkmark rasterises in the PPTX, which the deck's own shape guard asserts by name. The CHANGELOG said what re-renders the hero is unchanged, when that is the one part that changed — the path and the step's place in the cut are what survived. The gallery row and the showcase blurb called the banner a single slide; it has been four pages since before this branch, and is five now. And the deck's banner image method lost its only caller, next to a class comment written while the release still pointed at it. The step that renders the hero had no guard. Its two halves are strings the compiler never reads — the script hands the class to exec:java as text, and one part of it writes the file while another stages it — so a rename reaches the maintainer as a cut that has already bumped the version. Both halves are now read out of the script rather than restated in the test, driven from its text in five broken states: the hero class renamed, another exec class renamed, the step writing elsewhere, the staging line dropped, the step gone.
Fixes#8
Note that the src/test/resources/layout-snapshots/document/nested_list_three_levels.json is changed slightly due to rendering differences based on platform; it may be that a straight merge needs a slight fix to address this, or a standardization. This is probably locale-specific. It may ALSO be that a good idea is to MAKE it locale-specific, so different locales can load different values. I did not do this. I am lazy.