Skip to content

fix(pdf): resolve a chip against its own base, not against having one - #553

Merged
DemchaAV merged 2 commits into
developfrom
fix/pdf-latin-first-mixed-chip
Aug 14, 2026
Merged

fix(pdf): resolve a chip against its own base, not against having one#553
DemchaAV merged 2 commits into
developfrom
fix/pdf-latin-first-mixed-chip

Conversation

@DemchaAV

@DemchaAVDemchaAV commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Why

A chip takes its direction from its first character, and that character settles only where the chip sits in the line — not what the chip holds. PdfParagraphFragmentRenderHandler.renderChip read that flag as the question of whether to resolve the span at all:

if (span.rightToLeft()) { text = BidiVisualOrder.visualize(sanitizedLogical); }

So a chip opening on Latin — "a בית" inside a right-to-left paragraph — is flagged left-to-right, skips the resolution entirely, and its Hebrew reaches the content stream in logical order. A PDF draws characters in the order it is given them, so the word is drawn backwards. The chip is the only span exposed to this: everything else is split at level boundaries by ParagraphWrapping and arrives single-level.

This is the other half of the atomicity gap #548 closed. That PR fixed the chip whose flag is right-to-left; this one fixes the chip whose flag is left-to-right and whose contents are not.

What changed

The direction is now the base the resolution runs against, not the question of whether to run it. BidiVisualOrder.visualize and mirrorRightToLeftLevels take that base instead of assuming right-to-left, and the PDF resolves a chip whenever BidiParagraphResolver.requiresBidi says its text needs it. Against a left-to-right base the Hebrew inside the chip is its own right-to-left run and is reversed; the Latin around it stays put. A wholly right-to-left chip is unchanged (same reverse-and-mirror it already had), and one carrying no such script is untouched.

BidiVisualOrder is unreleased — it ships in 2.2.0-SNAPSHOT — so the signature moves without a compatibility question.

The slide backend has the same gap, and gets the same fix. PowerPoint orders the Hebrew itself, so the letters need no help — but a neutral standing between two right-to-left words takes their level even under a left-to-right base, and PowerPoint does not mirror what it places. a בית > ספר reached the slide with its comparison facing the wrong way. Its gate is now span.rightToLeft() || requiresBidi(text) with the flag as the base, matching the PDF path.

An earlier revision of this PR claimed the opposite — that nothing inside such a chip resolves to a right-to-left level, so PPTX needed nothing. That was generalised from four probe samples (a בית, a (בית), (בית) a, a בית (x)) which all happen to be no-ops: a mirrorable neutral only takes the right-to-left level when it stands between two right-to-left runs, and none of the four had one. The claim was false and the revision removed a correct fix; both are restored here.

Verification

./mvnw clean verify over the full reactor — all 13 modules, BUILD SUCCESS. render-pdf 210, render-pptx 114, qa (visual baselines) 734.

Five cases. Four are checked against the state they are meant to catch; the fifth is a contract pin and is marked as one:

CaseRed without the fix
BidiVisualOrderTest.aLeftToRightBaseKeepsItsHebrewOnTheSideThatBaseGivesItyes — make baseOf ignore its argument
PdfRtlChipTest.aChipThatOpensOnLatinStillDrawsItsHebrewInReadingOrderyes — restore the span.rightToLeft() gate
PptxRightToLeftFrameTest.aChipThatOpensOnLatinStillMirrorsANeutralBetweenTwoHebrewWordsyes — restore the span.rightToLeft() gate
PptxRightToLeftFrameTest.aChipThatOpensOnLatinMirrorsOnlyItsRightToLeftLevelPunctuationyes — same
PptxRightToLeftFrameTest.aChipThatOpensOnLatinCarriesNothingToMirrorWhenItHoldsOnlyLettersno — and it says so in its own comment

Only the last PPTX case is a contract pin rather than a regression guard — a chip of letters only is genuinely untouched either way, and its comment says so. The two carrying a mirrored character are real guards.

The PDF case reads the drawn glyphs by position (DrawnGlyphs) rather than extracted text — extraction goes through ActualText and the font's ToUnicode map, both of which deliberately answer with what the author wrote whichever order the page drew, so they cannot see this bug.

No shipped example or visual baseline pairs a chip with right-to-left text, so committed previews are untouched by construction.

A chip takes its direction from its first character, and that character
settles only where the chip sits in the line -- not what the chip holds.
The renderer read the flag as the question of whether to resolve the span
at all, so a chip opening on Latin was left alone entirely and its Hebrew
reached the content stream in logical order: drawn left to right, the word
came out backwards. Only the chip is exposed to this, because it is the
one span the wrapper cannot split at a level boundary.
The direction is now the base the resolution runs against, and the
resolution runs whenever the text needs it. BidiVisualOrder therefore
takes the base rather than assuming right-to-left: against a left-to-right
base the Hebrew inside such a chip is its own right-to-left run and is
reversed, while the Latin around it stays put. A wholly right-to-left chip
is unchanged, and so is one carrying no such script.
The slide backend needs no matching change and gets none: measured, against
a left-to-right base nothing inside such a chip resolves to a right-to-left
level -- even brackets enclosing Hebrew stay put -- so there is nothing to
mirror, and PowerPoint orders the letters itself. Its case here pins that
answer rather than guarding a defect, and says so.
BidiVisualOrder ships unreleased in 2.2.0-SNAPSHOT, so the signature moves
without a compatibility question.
The slide backend gated the mirroring on the chip's own direction, which
is its first character's and describes only where the chip sits in the
line. A neutral standing between two right-to-left words takes THEIR
level even under a left-to-right base, so it is one of the characters
PowerPoint places but does not mirror: a chip reading "a בית > ספר"
reached the slide with its comparison facing the wrong way.
The flag is now the base the mirroring resolves against, matching what
the PDF path already does with the same run. Ordering stays PowerPoint's;
only L4 is done for it, and only on the levels L4 owns -- text the
left-to-right base owns is left exactly as typed.
Three cases replace the single one that could not see this. The two
holding a mirrored character go red when the gate is restored; the third,
a chip of letters only, stays green either way and says so, because it
pins a contract rather than guarding this defect.
The class javadoc claimed the transforms always resolve against a
right-to-left base and only serve right-to-left runs. Both were wrong in
the same direction as the gate: a left-to-right base is not a
left-to-right run -- Hebrew inside such a chip resolves to level 1, and
the base decides how those runs order, not whether they exist. Corrected
there, in the changelog, and in the comments at both seams.
@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Confirmed and fixed — the Major stands, and my reasoning for dismissing it was wrong.

I re-probed with your example against the built classes:

a בית → changed=false
a בית > ספר → changed=true a בית < ספר
a בית (ספר) → changed=true a בית )ספר(
a בית > x → changed=false

A mirrorable neutral takes the right-to-left level only when it stands between two right-to-left runs. All four samples I had probed earlier lacked one, and I generalised "nothing inside such a chip resolves to a right-to-left level" from them — which is false, as you say: the Hebrew itself is level 1. On the strength of that generalisation an earlier revision of this PR removed a correct PPTX fix. Both are restored.

MajorrenderChip now gates on span.rightToLeft() || BidiParagraphResolver.requiresBidi(text) with the flag as the base, as you specified. Three cases replace the one that could not see this:

storedred with the old gate
a ביתa ביתno — pins a contract, and its comment says so
a בית > ספרa בית < ספרyes
a בית (ספר)a בית )ספר(yes

Reverting the gate fails exactly those two of eleven.

Minor — all four sites corrected. The BidiVisualOrder class javadoc no longer claims an always-right-to-left base or right-to-left-only runs, and now states the distinction directly: a left-to-right base is not a left-to-right run, Hebrew inside such a chip resolves to level 1, and the base decides how the runs order rather than whether they exist. Same correction in CHANGELOG.md, in the comments at both seams, and in the PPTX test. I grepped the repository for the false invariant's phrasing — no other copy.

Full reactor clean verify, 13/13 modules, BUILD SUCCESS; git diff --check clean, no baselines touched.

@DemchaAV
DemchaAV merged commit baa7aae into developAug 14, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/pdf-latin-first-mixed-chip branch August 14, 2026 01:00
Sign up for freeto 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.

1 participant

@DemchaAV