Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 6
fix(render): let a reordered line's frames say which way they read — and a chip keep its meaning#548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
fix(render): let a reordered line's frames say which way they read — and a chip keep its meaning #548
Changes from all commits
4aa28ef8e4437c69cb1d34acbca3993da3f89cc9e57df85c7df7852e1cc101ba4530d040624b3f6729e5File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| package com.demcha.compose.engine.text.bidi; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| /** | ||
| * Turns one atomic right-to-left run into the exact string a backend draws, level by | ||
| * level. | ||
| * | ||
| * <p>A plain span never needs this: the wrapper splits it wherever its characters | ||
| * resolve to a different embedding level, so each piece is single-level and reversing | ||
| * it whole is correct. An inline chip cannot be split — it is one rounded fill — so it | ||
| * reaches the backend carrying its first character's level and whatever its interior | ||
| * holds. Reversing <em>that</em> whole is wrong the moment the interior sits at the | ||
| * opposite level: a chip reading {@code (a > b)} after a Hebrew word was drawn as | ||
| * {@code (b < a)}, the comparison inverted, because the left-to-right interior was | ||
| * reversed and mirrored along with the brackets that enclose it.</p> | ||
| * | ||
| * <p>This class re-resolves the run's own levels (right-to-left base, matching the | ||
| * flag the caller holds), reorders the level runs visually, and reverses and mirrors | ||
| * only the right-to-left ones — UAX #9's L2 and L4 applied to the one span the wrapper | ||
| * could not split. For a single-level run the result is identical to reversing and | ||
| * mirroring the whole string, so the transform is safe for every chip, not only the | ||
| * mixed ones.</p> | ||
| * | ||
| * <p>Ownership: shared engine foundation.</p> | ||
| * | ||
| * @since 2.2.0 | ||
| */ | ||
| public final class BidiVisualOrder { | ||
| private BidiVisualOrder() { | ||
| } | ||
| /** | ||
| * Mirrors only the characters that resolve to a right-to-left level, keeping | ||
| * logical order. | ||
| * | ||
| * <p>For a viewer that runs the bidirectional algorithm itself — PowerPoint — the | ||
| * text must stay logical: reordering is a property of strong right-to-left | ||
| * characters, not of a declared direction, so a pre-reordered string would have its | ||
| * Hebrew re-reversed on display. What such a viewer was measured not to do is | ||
| * UAX #9's L4, so the paired punctuation is swapped for it here — but only | ||
| * where L4 itself would apply. A run at the left-to-right level keeps its brackets | ||
| * and comparisons exactly as typed; mirroring those, as the whole-string swap did, | ||
| * turns {@code a > b} into {@code a < b} in the only copy of the text the file | ||
| * has.</p> | ||
| * | ||
| * <p>For a single-level right-to-left run this is {@link BidiMirroring#mirror}, | ||
| * unchanged.</p> | ||
| * | ||
| * @param text run text in logical order | ||
| * @return the same order with paired punctuation at right-to-left levels swapped | ||
| */ | ||
| public static String mirrorRightToLeftLevels(String text) { | ||
| if (text == null || text.isEmpty()) { | ||
| return ""; | ||
| } | ||
| int[] levels = BidiParagraphResolver.levelsFor( | ||
| text, BidiParagraphResolver.BaseDirection.RIGHT_TO_LEFT); | ||
| if (levels.length == 0) { | ||
| return text; | ||
| } | ||
| StringBuilder mirrored = new StringBuilder(text.length()); | ||
| int from = 0; | ||
| while (from < text.length()) { | ||
| int level = levels[from]; | ||
| int to = from + 1; | ||
| while (to < text.length() && levels[to] == level) { | ||
| to++; | ||
| } | ||
| String run = text.substring(from, to); | ||
| mirrored.append(BidiParagraphResolver.isRightToLeftLevel(level) | ||
| ? BidiMirroring.mirror(run) | ||
| : run); | ||
| from = to; | ||
| } | ||
| return mirrored.toString(); | ||
| } | ||
| /** | ||
| * Resolves one right-to-left run into visual order, mirroring what moves. | ||
| * | ||
| * <p>Level runs are reordered as UAX #9 orders them; a right-to-left run is | ||
| * reversed by grapheme cluster and its paired punctuation mirrored, a | ||
| * left-to-right run passes through as written. Text that resolves to no | ||
| * right-to-left level at all is returned unchanged — drawing it as written is | ||
| * already correct.</p> | ||
| * | ||
| * @param text run text in logical order | ||
| * @return the text as a left-to-right drawing order | ||
| */ | ||
| public static String visualize(String text) { | ||
| if (text == null || text.isEmpty()) { | ||
| return ""; | ||
| } | ||
| int[] levels = BidiParagraphResolver.levelsFor( | ||
| text, BidiParagraphResolver.BaseDirection.RIGHT_TO_LEFT); | ||
| if (levels.length == 0) { | ||
| return text; | ||
| } | ||
| List<String> runs = new ArrayList<>(); | ||
| List<Integer> runLevels = new ArrayList<>(); | ||
| int from = 0; | ||
| while (from < text.length()) { | ||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. github-advanced-security[bot] marked this conversation as resolved.
Fixed
Uh oh!There was an error while loading. Please reload this page. | ||
| int level = levels[from]; | ||
| int to = from + 1; | ||
| while (to < text.length() && levels[to] == level) { | ||
| to++; | ||
| } | ||
| runs.add(text.substring(from, to)); | ||
| runLevels.add(level); | ||
| from = to; | ||
| } | ||
| int[] levelArray = new int[runLevels.size()]; | ||
| for (int index = 0; index < levelArray.length; index++) { | ||
| levelArray[index] = runLevels.get(index); | ||
| } | ||
| int[] order = BidiParagraphResolver.visualOrder(levelArray); | ||
| StringBuilder visual = new StringBuilder(text.length()); | ||
| for (int position = 0; position < runs.size(); position++) { | ||
| int logical = order.length == 0 ? position : order[position]; | ||
| String run = runs.get(logical); | ||
| visual.append(BidiParagraphResolver.isRightToLeftLevel(levelArray[logical]) | ||
| ? BidiMirroring.mirror(BidiText.reverseForDisplay(run)) | ||
| : run); | ||
| } | ||
| return visual.toString(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.demcha.compose.engine.text.bidi; | ||
| import org.junit.jupiter.api.Test; | ||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| /** | ||
| * Pins the level-aware transform for the one span the wrapper cannot split. | ||
| * | ||
| * <p>The failure it guards inverted meaning, not just shape: a chip reading | ||
| * {@code (a > b)} after a Hebrew word was reversed whole, and the page said | ||
| * {@code (b < a)} — operands swapped and the comparison flipped, in the only copy of | ||
| * the text the file has. The interior of that chip sits at the left-to-right level, | ||
| * where UAX #9 reorders nothing and mirrors nothing; only the brackets enclosing it | ||
| * belong to the right-to-left level that moves.</p> | ||
| */ | ||
| class BidiVisualOrderTest { | ||
| private static final String HEBREW = "שלום"; | ||
| private static final String HEBREW_REVERSED = new StringBuilder(HEBREW).reverse().toString(); | ||
| @Test | ||
| void aBracketWrappedLeftToRightInteriorKeepsItsMeaning() { | ||
| // Brackets are right-to-left level and swap positions and forms; the interior | ||
| // is left-to-right level and must come through exactly as written. The two | ||
| // effects cancel on the brackets, so the visual string equals the logical one — | ||
| // which is precisely what reversing the whole span destroys. | ||
| assertThat(BidiVisualOrder.visualize("(a > b)")).isEqualTo("(a > b)"); | ||
| } | ||
| @Test | ||
| void aSingleLevelRunIsReversedAndMirroredWhole() { | ||
| // For homogeneous text the transform must agree with the treatment plain spans | ||
| // always had: reverse by cluster, mirror the pairs. | ||
| assertThat(BidiVisualOrder.visualize("(" + HEBREW + ")")) | ||
| .isEqualTo("(" + HEBREW_REVERSED + ")"); | ||
| } | ||
| @Test | ||
| void digitsInsideARightToLeftRunStayForward() { | ||
| // Digits resolve to the left-to-right level even in Hebrew text: the year in | ||
| // "ב-2026" reads forwards. Reversed whole, it would not. | ||
| assertThat(BidiVisualOrder.visualize(HEBREW + " 123")) | ||
| .isEqualTo("123 " + HEBREW_REVERSED); | ||
| } | ||
| @Test | ||
| void levelKeyedMirroringSwapsOnlyWhatTheAlgorithmWould() { | ||
| // For a viewer with its own bidi engine the text must stay logical — strong | ||
| // right-to-left characters are reordered by what they are, not by what the | ||
| // paragraph declares, so a pre-reordered string would come back re-reversed. | ||
| // Only the mirroring is done for it, and only on the levels L4 touches. | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels("(a > b)")) | ||
| .describedAs("brackets sit at the right-to-left level and swap; the " | ||
| + "interior's comparison does not") | ||
| .isEqualTo(")a > b("); | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels("(" + HEBREW + ")")) | ||
| .describedAs("a single-level run is the whole-string mirror, unchanged") | ||
| .isEqualTo(BidiMirroring.mirror("(" + HEBREW + ")")); | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels(HEBREW + " 2026")) | ||
| .describedAs("nothing mirrors, nothing reorders — the letters stay " | ||
| + "logical for the viewer's own engine") | ||
| .isEqualTo(HEBREW + " 2026"); | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels("plain latin")) | ||
| .isEqualTo("plain latin"); | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels("")).isEmpty(); | ||
| assertThat(BidiVisualOrder.mirrorRightToLeftLevels(null)).isEmpty(); | ||
| } | ||
| @Test | ||
| void textWithoutARightToLeftLevelPassesThrough() { | ||
| // The flag the caller holds comes from paragraph context; if the run's own | ||
| // analysis finds nothing right-to-left, drawing it as written is already | ||
| // correct and the transform must not invent a reversal. | ||
| assertThat(BidiVisualOrder.visualize("plain latin")).isEqualTo("plain latin"); | ||
| assertThat(BidiVisualOrder.visualize("")).isEmpty(); | ||
| assertThat(BidiVisualOrder.visualize(null)).isEmpty(); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.