Skip to content

Backport to Java 17 - #9

Closed
jottinger wants to merge 1 commit into
DemchaAV:mainfrom
jottinger:8/fix/migrate-to-java-17
Closed

Backport to Java 17#9
jottinger wants to merge 1 commit into
DemchaAV:mainfrom
jottinger:8/fix/migrate-to-java-17

Conversation

@jottinger

Copy link
Copy Markdown
Contributor

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.

Fixes DemchaAV#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

DemchaAV commented May 8, 2026

Copy link
Copy Markdown
Owner

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

Copy link
Copy Markdown
Contributor Author

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

DemchaAV commented May 8, 2026

Copy link
Copy Markdown
Owner

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.

@DemchaAV DemchaAV closed this May 8, 2026
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 11, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
…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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
* 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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 12, 2026
* 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.
DemchaAV added a commit that referenced this pull request Aug 13, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 13, 2026
…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.
DemchaAV added a commit that referenced this pull request Aug 13, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 13, 2026
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.
DemchaAV added a commit that referenced this pull request Aug 13, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backmigrate to Java 17

2 participants