Skip to content

fix(render): let a reordered line's frames say which way they read — and a chip keep its meaning - #548

Merged
DemchaAV merged 12 commits into
developfrom
fix/pptx-rtl-frame-direction
Aug 13, 2026
Merged

fix(render): let a reordered line's frames say which way they read — and a chip keep its meaning#548
DemchaAV merged 12 commits into
developfrom
fix/pptx-rtl-frame-direction

Conversation

@DemchaAV

@DemchaAVDemchaAV commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Closes#545.

Why

Opening a slide with a right-to-left line shows a parenthesis facing the way it was typed rather than the way the line reads. The same document as a PDF is correct.

last line of the sample
PDF(AUTO resolves from the first strong letter)
PPTX)AUTO resolves from the first strong letter(2026

The placement was never the problem. A right-to-left line goes through per-span absolute frames, and the engine puts them exactly where its own resolution says — from the produced slide, the closing bracket's frame at x=142 and the opening one at x=368, the correct visual positions.

What was missing is the mirroring. The PDF backend mirrors paired punctuation at its own seam (UAX #9 L4); the PPTX backend hands PowerPoint the logical character and relies on PowerPoint, which does do it — from the paragraph's base direction. Nothing declared one, so it defaulted to left-to-right and a frame holding a lone ) had nothing to resolve against. The em-dashes of a mixed line sat on the wrong side of their frames for the same reason.

What changed

Two halves, because the first one alone was tried and measured insufficient.

Each frame carrying a right-to-left span declares its direction.PptxTextFrames.preparedParagraph gains an overload that writes it; both absolute-frame sites (plain span, chip text) pass span.rightToLeft(). This fixes placement — the em-dash of a mixed line moved to the side it belongs on, confirmed on a rendered slide.

And the bracket is mirrored at the same seam the PDF backend uses. Declaring the direction was tried on its own first and shipped to a reviewer, who opened the slide: the dashes were right and the bracket still faced the way it was typed. PowerPoint places a neutral from the declared direction but does not go on to mirror it, so the swap has to happen here.

The cost, stated because it is real: a copy out of the slide now carries the mirrored character rather than the typed one. BidiMirroring maps document punctuation — brackets, braces, guillemets — and < / >, so a comparison written between two Hebrew words copies out reversed. A > surrounded by Latin does not — that stretch gets a left-to-right span of its own and the swap is keyed to the span's direction. A line a reader cannot follow is still the worse of the two, but the angle brackets are a sharper edge than the parentheses and the changelog names them.

And the one span the wrapper cannot split is resolved level by level. A chip is one rounded fill, so it reaches the renderer whole, carrying its first character's level. The PDF backend reversed and mirrored it whole, which inverted meaning rather than shape: a chip reading (a > b) after a Hebrew word drew as (b < a) — operands swapped, comparison flipped — and a chip holding שנה 2026 drew its year backwards. BidiVisualOrder (new, in the engine's bidi package) resolves the run's own embedding levels, and each backend takes what its viewer lacks. The PDF draws characters in the order given, so it gets the full visual transform — for a single-level chip exactly the old reverse-and-mirror, so wholly-Hebrew chips are unchanged. The slide backend keeps the chip's text logical with its direction declared — PowerPoint reorders strong right-to-left characters by what they are, not by what the frame says, so a pre-reordered string would come back with its Hebrew re-reversed — and pre-mirrors only the punctuation at right-to-left levels (mirrorRightToLeftLevels), which is what keeps the interior's > a >.

What this does not cover

Three text paths in the backend get neither half and still show the reported bug: table cells (PptxTableRowFragmentRenderHandler), headers/footers and watermarks (PptxChromeRenderer). They are out of this change's scope, not fixed by it.

Verified in the produced file — rtl lands on exactly the right frames, and the stored bracket is the one that draws correctly:

x= 142pt rtl=true |(| <- author typed ')', drawn leftmost, so it carries '('
x= 147pt rtl=- |AUTO resolves from the first strong letter|
x= 368pt rtl=true | שנה טובה )|
x= 430pt rtl=- |2026|

Tests

PptxRightToLeftFrameTest, 8 cases: the frames carrying right-to-left text declare it, the bracket's frame specifically does, the bracket is stored mirrored (per frame — each frame is its own paragraph, so the one the author closed with is drawn leftmost and carries the opening form), a left-to-right line declares nothing and stores its brackets as typed, a mixed chip keeps its comparison as typed and its letters logical, a chip holding Hebrew and a year stores exactly what the author typed, and a single-level chip gets the plain-span treatment. PdfRtlChipTest, 2 cases, reads the drawn glyphs by position — extraction is routed through ActualText and the font's ToUnicode map, which both deliberately answer with what the author wrote whichever order the page drew. BidiVisualOrderTest, 5 cases, pins the transform itself. Each fails with its own hunk alone reverted.

Full reactor gate green: render-pptx 102, render-pdf 209, qa (visual baselines) 734. No shipped example or baseline combines a chip with right-to-left text, so committed previews are untouched.

What I could not verify: how PowerPoint actually draws it. Rasterising a PPTX needs PowerPoint, so the visual confirmation is the reviewer's — a sample slide is attached to the issue thread. What this PR proves is that the file now carries the direction each frame needs, on the frames that need it.

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.
…t 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.
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.
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.
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".
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.
@DemchaAVDemchaAV changed the title fix(pptx): let a reordered line's frames say which way they readfix(render): let a reordered line's frames say which way they read — and a chip keep its meaningAug 13, 2026
…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.
…-direction
# Conflicts:
#	CHANGELOG.md
#	docs/architecture/backend-capability-matrix.md
…orms 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.
@DemchaAV
DemchaAV merged commit 5e036ba into developAug 13, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/pptx-rtl-frame-direction branch August 13, 2026 14:18
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.

2 participants

@DemchaAV@github-advanced-security