Skip to content

applib: render bidirectional text with the Unicode bidi algorithm#2

Open
kaluaim wants to merge 1 commit into
bidi-review-basefrom
bidi-engine-main
Open

applib: render bidirectional text with the Unicode bidi algorithm#2
kaluaim wants to merge 1 commit into
bidi-review-basefrom
bidi-engine-main

Conversation

@kaluaim

@kaluaim kaluaim commented Jul 19, 2026

Copy link
Copy Markdown
Owner

What

Replace the per-direction segment reversal in walk_line() with a UAX 9 engine implementing the Basic Display Algorithm (implicit levels only — no RLE/LRI overrides, which don't occur in watch text): character classes, weak types (W1-W7), bracket pairs (N0/BD16), neutrals (N1/N2) and implicit levels (I1/I2) resolve once per paragraph, then each display line applies L1/L2/L4 to its slice. Weak, neutral and bracket resolution therefore cross soft wraps exactly as in an unwrapped paragraph.

The segment approach couldn't handle mixed-direction lines: a bracket pair split across an LTR/RTL segment boundary mirrored one side only — an Arabic word in parentheses after Latin text rendered with two opening parentheses — and neutrals at run boundaries reordered wrongly.

Design notes

  • Stack: all working state (the ~3.6 KiB BidiScratch, paragraph-sized at 255 codepoints) lives in a single applib_malloc block per rendered RTL line. Task stacks are 2-4 KiB (legacy 2.x/3.x apps get 2 KiB), so none of it can sit in the render path's frame. Allocation failure, over-cap lines and paragraphs beyond 255 codepoints all fall back to the standard logical-order path instead of truncating or rendering blank.
  • Base direction follows UAX 9 P2/P3 via bidi_base_level_utf8(), with one deviation: digit-only Arabic-Indic text stays RTL so times/verification codes keep right alignment. Alignment (prv_line_justify) and reordering share this rule, so a line can no longer right-align but reorder as LTR or vice versa.
  • Gate: bidi_contains_rtl() scans exactly the line's own paragraph using the engine's class table (Hebrew, Arabic incl. supplements and presentation forms), so gating, alignment and reordering agree. Paragraphs with no RTL never enter the path — no behavior change and no allocations for LTR-only text (the gate itself is a per-line paragraph scan, bounded by watch text sizes).
  • Bounds: the fitting scan caps at BIDI_MAX_CODEPOINTS (255) — zero-width marks consume codepoints without consuming width, so pixel width alone cannot bound the count.
  • Shaping across wraps: line shaping folds Lam-Alef and regional-indicator pairs with the paragraph as joining context and carries resolved levels through the fold, so Arabic letterforms stay correct across soft wraps.
  • Class fidelity: classes follow the UCD for watch-relevant codepoints (Persian digits EN, Arabic comma / no-break spaces CS, LRM/RLM strong); boundary neutrals are transparent per UAX 9 §5.2 (a ZWNJ can't split a number); the truncation suffix reorders with the line, so an RTL ellipsis lands at the visual left edge.
  • The old rtl_support module (utf8_reverse_for_rtl and the segment machinery) is deleted entirely.

Before / after

cmp_bidi

Test

  • test_bidi.c: 50 cases — base level (P2/P3 + digit deviation), strong runs, Western + Arabic-Indic digits, time and date group order (W4), separator-needs-two-digits, bracket mirroring in RTL, LTR island in RTL, RTL island in LTR (no mirror).
  • Full suite passes (the only failing binaries — put_bytes, workout — are pre-existing rand/parallelism flakes that pass standalone and share no code with this change).
  • obelix@pvt firmware builds clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the previous segment-based RTL handling in the text renderer with a minimal UAX 9 (Unicode Bidirectional Algorithm) implementation (implicit levels only), fixing incorrect reordering/mirroring in mixed-direction lines (e.g., bracket pairs spanning LTR/RTL runs).

Changes:

  • Add a new bidi engine (bidi.c/.h) to resolve bidi classes/levels, reorder to visual order, and mirror paired punctuation.
  • Update text_layout.c to use the bidi engine during the render pass for lines containing RTL characters, and align justification with the same base-direction rule.
  • Add a dedicated bidi test suite (test_bidi.c) and wire bidi.c into relevant test build scripts; simplify RTL support tests to only cover RTL detection.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/fw/wscript_build Add bidi.c to firmware test build inputs and register new test_bidi.c.
tests/fw/ui/wscript_build Include bidi.c in UI-related rendering test sources.
tests/fw/test_rtl_support.c Remove reversal/segment tests; keep only utf8_contains_rtl gating coverage.
tests/fw/test_bidi.c New unit tests covering base direction, weak types, separators, bracket pairing/mirroring, and islands.
tests/fw/services/timeline/wscript_build Include bidi.c in timeline rendering test sources.
tests/fw/graphics/wscript_build Include bidi.c in graphics text rendering test sources.
tests/fw/apps/watch/kickstart/wscript_build Include bidi.c in kickstart app rendering test sources.
tests/fw/apps/system_apps/workout/wscript_build Include bidi.c in workout app rendering test sources.
tests/fw/apps/system_apps/weather/wscript_build Include bidi.c in weather app rendering test sources.
tests/fw/apps/system_apps/timeline/wscript_build Include bidi.c in system timeline app rendering test sources.
tests/fw/apps/system_apps/music/wscript_build Include bidi.c in music app rendering test sources.
tests/fw/apps/system_apps/launcher/wscript_build Include bidi.c in launcher rendering/common sources.
tests/fw/apps/system_apps/health/wscript_build Include bidi.c in health app rendering test sources.
src/fw/applib/graphics/text_layout.c Switch RTL render pass to shape + bidi-reorder + visual-order draw; align justification with paragraph base direction.
src/fw/applib/graphics/rtl_support.h Narrow RTL support API to gating only; document relationship to bidi engine.
src/fw/applib/graphics/rtl_support.c Remove old reversal/segment helpers; keep utf8_contains_rtl.
src/fw/applib/graphics/bidi.h New public header for bidi base-level detection and line/UTF-8 reordering.
src/fw/applib/graphics/bidi.c New minimal UAX 9 implementation (weak types, bracket pairing, neutrals, implicit levels, mirroring).
src/fw/applib/graphics/arabic_shaping.c Increase shaping cap to cover a full bidi-processed display line.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +662 to +677
int content_width_px = 0;
Codepoint prev_cp = 0;
bool skip_ligature_member = false;
utf8_t *fit_end = line->start;
utf8_t *p = (utf8_t *)line->start;
while (p < line_end && *p != '\0' && *p != '\n') {
utf8_t *pnext = NULL;
Codepoint cp = utf8_peek_codepoint(p, &pnext);
if (cp == 0 || pnext == NULL) {
break;
}
if (prv_codepoint_is_invisible(cp)) {
p = pnext;
fit_end = p;
continue;
}
Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines +709 to +713
size_t scratch_size = fit_len * 2 + 8; // shaping may expand 2-byte -> 3-byte forms
utf8_t *shaped = applib_malloc(scratch_size);
utf8_t *visual = applib_malloc(scratch_size);
// ~1.3 KiB of bidi working storage; task stacks are 2-4 KiB, so it must
// live on the heap, not in this frame.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines 649 to 653
bool is_rendering = (char_visitor_cb == render_chars_char_visitor_cb);

// For segment-based RTL rendering during render pass
if (is_rendering && line->start != NULL && text_box_params->utf8_bounds != NULL &&
text_box_params->utf8_bounds->end != NULL &&
text_box_params->utf8_bounds->end > line->start &&
utf8_contains_rtl(line->start, text_box_params->utf8_bounds->end)) {
Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines +718 to +724
size_t scratch_size = fit_len * 2 + 8; // shaping may expand 2-byte -> 3-byte forms
utf8_t *shaped = applib_malloc(scratch_size);
utf8_t *visual = applib_malloc(scratch_size);
// ~1.3 KiB of bidi working storage; task stacks are 2-4 KiB, so it must
// live on the heap, not in this frame.
BidiScratch *bidi_ws = applib_malloc(sizeof(BidiScratch));
if (shaped && visual && bidi_ws) {

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.

Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines +745 to +750
// Base direction is a paragraph property (UAX 9 P2/P3): use the
// paragraph's first strong character so a wrapped continuation line
// that happens to start with the opposite script keeps the same base.
int base_level = bidi_base_level_utf8(text_box_params->utf8_bounds->start, line_end);
visual_len = bidi_reorder_utf8(shaped, shaped_len, visual, scratch_size, base_level,
bidi_ws);
Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines 1283 to 1286
if (effective_alignment == GTextAlignmentLeft && line->start != NULL) {
if (prv_utf8_starts_with_rtl(line->start, text_box_params->utf8_bounds->end)) {
if (bidi_base_level_utf8(text_box_params->utf8_bounds->start,
text_box_params->utf8_bounds->end) == 1) {
effective_alignment = GTextAlignmentRight;
Comment thread src/fw/applib/graphics/bidi.c Outdated
Comment on lines +23 to +25
if (cp == 0x000A || cp == 0x000D || cp == 0x0085 || cp == 0x2029) return B;
if (cp == 0x0009 || cp == 0x000B || cp == 0x001F) return S;
if (cp == 0x0020) return WS;
Comment thread src/fw/applib/graphics/bidi.c Outdated
Comment on lines +45 to +47
// Zero-width and other boundary neutrals.
if (cp == 0x200B || cp == 0x200C || cp == 0x200D || cp == 0xFEFF ||
prv_in(cp, 0x0000, 0x0008) || prv_in(cp, 0x000E, 0x001B)) return BN;

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comment thread src/fw/applib/graphics/text_layout.c Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.

Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment on lines +1301 to +1305
if (effective_alignment == GTextAlignmentLeft && line->start != NULL) {
if (prv_utf8_starts_with_rtl(line->start, text_box_params->utf8_bounds->end)) {
utf8_t *para_start = NULL;
utf8_t *para_end = NULL;
prv_paragraph_bounds(text_box_params->utf8_bounds->start, text_box_params->utf8_bounds->end,
line->start, &para_start, &para_end);
Comment thread src/fw/applib/graphics/text_layout.c Outdated
Comment thread src/fw/applib/graphics/text_layout.c Outdated
cursor.origin.x += walked_width_px;
text_resources_get_glyph(&ctx->font_cache, line->suffix_codepoint, text_box_params->font);
render_glyph(ctx, line->suffix_codepoint, text_box_params->font, cursor);
return fit_end;
Comment thread src/fw/applib/graphics/bidi.c Outdated

size_t bidi_reorder_line(const Codepoint *cps, size_t n, int base_level, Codepoint *out,
BidiScratch *ws) {
if (n == 0 || ws == NULL) return 0;

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/fw/applib/graphics/text_layout.c:706

  • The bidi render path hard-caps the fitting scan to BIDI_MAX_CODEPOINTS (including zero-width marks). The measurement/layout path explicitly “shares the standard path below” and does not enforce this cap, so a line can be laid out to include >96 codepoints (e.g. many zero-width marks) but then the render pass will stop early and silently drop any subsequent visible glyphs that still fit in pixel width.
    // Walk the logical text accumulating shaped (ligature-folded, mark-aware)
    // advances - the same arithmetic the measurement pass uses - to find where
    // this line ends within the available width. Also cap the codepoint count:
    // the reorder handles at most BIDI_MAX_CODEPOINTS, and zero-width marks
    // consume codepoints without consuming width, so width alone cannot bound
    // it; without the cap the tail past the limit would be consumed but never
    // drawn.
    int content_width_px = 0;
    size_t fit_cps = 0;
    Codepoint prev_cp = 0;
    bool skip_ligature_member = false;
    utf8_t *fit_end = line->start;
    utf8_t *last_cp_start = NULL;
    utf8_t *p = (utf8_t *)line->start;
    while (p < line_end && *p != '\0' && *p != '\n' && fit_cps < BIDI_MAX_CODEPOINTS) {
      utf8_t *pnext = NULL;
      Codepoint cp = utf8_peek_codepoint(p, &pnext);

Comment on lines +674 to +683
// Gate on the line's whole paragraph: a display line never crosses a
// newline, so RTL text in another paragraph must not pull this line
// through the (allocating) bidi path - but every line of a paragraph that
// contains RTL anywhere must take it, including LTR-only continuation
// lines, so their alignment and edge punctuation follow the paragraph
// base direction.
prv_paragraph_bounds(text_box_params->utf8_bounds->start, text_box_params->utf8_bounds->end,
line->start, &para_start, &para_end);
take_bidi_path = utf8_contains_rtl(para_start, para_end);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Comment thread src/fw/applib/graphics/arabic_shaping.c Outdated
Comment on lines +8 to +10
// Caps codepoints per shaping call. Sized to cover a whole display line, which
// the bidi layout shapes in one pass (matches BIDI_MAX_CODEPOINTS).
#define MAX_SHAPE_CODEPOINTS 96
Comment thread src/fw/applib/graphics/bidi.h Outdated
Comment on lines +48 to +51
//! True if the range contains a strong RTL codepoint (bidi class R or AL:
//! Hebrew, Arabic including supplements and presentation forms). Gates the
//! bidi layout path with the same class table the reordering itself uses, so
//! the gate and the base direction can never disagree.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head ae7988e: the prv_debug_perimeter cleanup is correct, moving the L1 level copy into BidiScratch is functionally safe, the focused bidi/shaping/layout tests pass, and the resolver still matches all 100,038 applicable Unicode 17 BidiTest.txt runs. The full suite is 330/331 only because the known parallel put_bytes flake fired; both Obelix and Gabbro targets pass standalone.

I found one remaining glyph-loss bug in the joining integration:

The new invisible-codepoint handling makes the bidi fit scan agree with the builder, but word_init() still skips those codepoints and can under-measure the line. word_init() gets next_cp through char_iter_next(), which filters formatting indicators at text_layout.c:492-497; consequently it shapes across U+200C ZWNJ even though the raw fit/builder correctly treat ZWNJ as a join break.

Concrete shipped-font repro: logical U+0628 U+200C U+0628 (Beh, ZWNJ, Beh) in GOTHIC_18. word_init() sees two adjacent Beh and measures U+FE91 + U+FE90 as 4 + 13 = 17 px in the generated Vazirmatn extension. Render fit sees the raw ZWNJ and correctly measures two isolated U+FE8F glyphs as 12 + 12 = 24 px. The line therefore has width_px == 17; render fits only the first Beh plus ZWNJ, while layout has already consumed the complete word and the returned render endpoint is ignored, so the second Beh disappears—even in a box wide enough for both isolated glyphs.

Please make word_init() and the standard walk_line() measurement preserve the same raw joining controls as the fit scan/builder (while still giving them zero width/no draw), and add this to the planned real walk_line harness.

Two smaller cleanup notes:

  • bidi.h:38-40 says orig remains paragraph state and bidi.h:138 says apply uses only vis, but bidi_apply_line() now overwrites ws->orig as its level scratch. The implementation is safe; those contracts should be updated.
  • Despite the latest reply, the whole-PR check is not yet clean: git diff --check 9272312..ae7988e reports a new blank line at EOF in codepoint.c:221 and codepoint.h:73.

@kaluaim

kaluaim commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head. word_init() now takes its shaping neighbor from the raw peek unconditionally, and both consumers of the filtered character iterator (word measurement and the standard walk_line path) recover the joining breaker from the raw byte gap the iterator skipped — so a ZWNJ between two Beh measures the same isolated forms everywhere that the fit scan and line builder already produced, and your Beh-ZWNJ-Beh glyph-loss case closes. All four measurement/render stages now derive Arabic joining context from one pair of rules (raw peek forward, raw gap backward). The bidi.h contracts are corrected (orig is documented as apply-time scratch), and git diff --check against the merge base is clean including the codepoint.c/h line endings. This is again a layout-stage transition the engine tests cannot reach — it joins the harness docket as case five. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head e7d984b: the original within-line Beh–ZWNJ–Beh case is fixed, the bidi.h scratch contracts and EOF cleanup are correct, all six focused bidi/shaping/layout/iterator suites pass, and the resolver still matches all 100,038 applicable Unicode 17 BidiTest.txt runs. The Obelix firmware build also passes. The full suite is 329/331 only because both known parallel put_bytes flakes fired; both targets pass standalone.

I found one remaining ZWNJ glyph-loss path at a soft-wrap boundary:

A filtered formatting codepoint can become the raw start of a continuation line, where the standard measurement path treats it as a visible wildcard. line_add_word() advances a split word with word->start = utf8_get_next(last_visited) at text_layout.c:1483. If the next raw codepoint is ZWNJ, the following line therefore starts on ZWNJ. The new raw-gap recovery at lines 1091-1103 only runs after a successful iter_next(); the initial codepoint is taken directly at lines 1027-1031 and sent through prv_shaped_glyph_advance() at lines 1051-1053 without an invisible-codepoint check. ZWNJ is a formatting indicator but is not in codepoint_is_zero_width(), so the shipped font lookup substitutes the wildcard glyph and gives it nonzero width. The bidi fit path, correctly, gives the same ZWNJ zero width at lines 791-799.

Concrete shipped-font repro: U+0628 U+200C U+0628 U+0628 U+0628 (Beh, ZWNJ, Beh, Beh, Beh) in GOTHIC_18 with an 18 px box. The relevant advances are isolated Beh 12, initial 4, medial 5, final 13, hyphen 6, and the base-font wildcard 9 px.

  • Line 1 measures isolated Beh + hyphen as 18 px, then leaves word->start pointing at ZWNJ.
  • Line 2 layout counts the ZWNJ as 9 px; with the 6 px hyphen it cannot fit the following 4 px Beh (9 + 6 + 4 = 19), so it advances only past ZWNJ and records the remaining word as 13 px wide. Bidi render skips ZWNJ and fits two continuation Beh plus the hyphen (4 + 5 + 6 = 15).
  • Line 3 consequently starts at the first of those already-drawn Beh with a stale 13 px word width. Render fits those same first two Beh again, and the final 13 px Beh is never drawn because the render endpoint is ignored.

This also reproduces with the existing constant-width test_line_layout stubs: after two line_add_word() calls in a 2-advance box, the continuation pointer remains on the first Beh after ZWNJ instead of advancing to the next Beh. Please normalize a continuation start past filtered controls (while retaining the raw control as joining context), or make the standard path consume an initially filtered control with zero width and consistent word accounting, and add this regression now; it does not require the deferred real-font render harness to expose the bad endpoint.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head, including your regression. A hyphen-split continuation now normalizes its start past filtered formatting codepoints, so a line can never begin on a ZWNJ the iterator will not yield — the codepoint stays in the raw text, where the joining-context scans still see it break the join. Your Beh-ZWNJ-Beh-Beh-Beh recipe is in-tree as test_line_layout__continuation_skips_formatting_codepoint, asserting the continuation pointer advances offset 5 then 7 across two line_add_word calls — you were right that the constant-width stubs expose the endpoint bug without the render harness, and that removes one case from its docket. Also in this head, from the parallel review: the joining scan starts after the last newline before the position (identical result, cheaper), and the ZWJ observation is logged as a known deviation — Unicode classes it join-causing, all four stages here treat it as join-breaking consistently; changing that is a shaping-semantics follow-up. Suite green apart from the known put_bytes flake; whole-PR git diff --check is clean; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full review of current head 8d38d56: the continuation-start fix is sound and its regression passes. The focused bidi/shaping/layout suites pass (6/6), the full suite passes (331/331), the implicit resolver still matches all 100,038 applicable Unicode 17 BidiTest.txt runs, and Obelix builds. I found two remaining user-visible gaps in the shipped-codepoint tables:

  1. Several shipped number signs are still classified ON instead of ET. The CJK notification manifests include U+2030, U+2032, U+2033, U+FF03, U+FF04, U+FF05, U+FFE1 and U+FFE5, but none is in the ET branch of prv_class(). This is not directionally harmless: logical 05D0 0020 2030 0031 (א ‰1) currently resolves to visual 0031 2030 0020 05D0 (1‰ א), while Unicode/FriBidi gives 2030 0031 0020 05D0 (‰1 א). W5 must absorb the ET next to the EN so the sign stays with the number. Please add the shipped ET values and a regression for this case. Reference: Unicode 17 DerivedBidiClass.txt.

  2. L4 still omits shipped mirrored characters that are not paired brackets. Comparing the manifests with BidiMirroring.txt finds five misses: U+223D, U+226E/U+226F and U+FF1C/U+FF1E (both halves of the latter two pairs are shipped). For logical 05D0 0020 FF1C 05D1 (א <ב), current output is 05D1 FF1C 0020 05D0 (ב< א); the correct output mirrors the odd-level glyph to U+FF1E: 05D1 FF1E 0020 05D0 (ב> א). The paired-bracket helper cannot cover these non-bracket mirror pairs, so they need explicit mappings (ideally with a manifest-vs-UCD table test). Reference: Unicode 17 BidiMirroring.txt.

I would address these before merging.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Both addressed at the current head. The shipped number signs are ET — per-mille/prime family U+2030-2034, fullwidth U+FF03-FF05, and the fullwidth currency signs U+FFE0/FFE1/FFE5/FFE6 (the contiguous UCD ranges around your named set) — with a regression asserting W5 keeps the per-mille sign with its number in an RTL line. The five non-bracket mirror pairs are in L4 with explicit mappings (U+223C/223D, U+226E/226F, U+FF1C/FF1E), with a regression for the fullwidth angle mirroring on an odd level. Both rounds this cycle were hand-runs of exactly the manifest-vs-UCD comparison the agreed follow-up automates — the generated table plus conformance test will make this class of finding structural rather than iterative. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full review of current head aae943a: the ET and non-bracket mirroring fixes from the previous round are correct, and all 35 mirrored codepoints in the shipped manifests now match Unicode 17. The focused bidi/shaping/layout suites pass (6/6), the class-based resolver still matches all 100,038 applicable BidiTest.txt runs, both put_bytes targets pass standalone after the known full-suite parallelism flake, and the firmware build passes.

I found one remaining user-visible class mismatch in the shipped CJK fonts:

U+3220–U+3229 are L in Unicode but currently fall through to ON in prv_class(). These parenthesized ideograph list markers are present in both the zh_CN and en_TW notification manifests. In an automatically RTL paragraph, logical 05D0 0020 3220 0061 (א ㈠a) currently resolves to 0061 3220 0020 05D0 (a㈠ א), while Unicode/FriBidi gives 3220 0061 0020 05D0 (㈠a א). The marker should remain with the following LTR/CJK run instead of moving to its far side. Please classify U+3220–U+3229 as L and add this regression. Reference: Unicode 17 DerivedBidiClass.txt.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head: U+3220-3229 are L (your list-marker repro is a regression test — the marker stays with its following run), the combining marks for symbols U+20D0-20FF including the keycap are NSM (regression: the keycap stays attached to its digit in an RTL line), and the unimplemented isolate controls U+2066-2069 are classed BN so they stay transparent instead of joining neutral runs. The RI+VS16+RI note is accepted as-is: it is a malformed sequence, and measurement and draw agree on the two-flag rendering, so there is no accounting mismatch. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full review of current head a5c45ba: the U+3220–U+3229 fix is correct, the keycap NSM regression passes, and treating the unsupported isolate controls as transparent is internally consistent. The focused bidi/shaping/layout suites pass (6/6), the resolver matches all 100,038 applicable class-based Unicode tests, all 35 shipped mirrored codepoints match the UCD, both put_bytes targets pass standalone after the known full-suite parallelism flake, and the firmware build passes.

I found one remaining assigned class range in the locale resources:

U+0483–U+0489 are NSM in Unicode but still fall into the broad Cyrillic L range. They are listed in both the Russian and Ukrainian codepoint manifests (and U+0483 is present in the source fonts). This bypasses W1 and the N0-after-bracket NSM handling. For example, logical 05D0 0483 0061 (א҃a) currently resolves to 0483 0061 05D0 (҃aא), while Unicode/FriBidi gives 0061 05D0 0483 (aא҃): the combining mark should inherit the preceding R type and remain with it, but the current L class detaches it into the LTR run. Please classify U+0483–U+0489 as NSM before the broad U+0370–U+04FF range and add this regression. Reference: Unicode 17 DerivedBidiClass.txt.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head: U+0483-0489 are NSM (listed before the Cyrillic letter range) and U+1680/U+205F are WS. Your titlo repro is a regression test with one representation note: this engine's visual sequence carries a reversed mark before its base (a ҃ א), and the draw pass overlays the zero-width mark on the base at the same x position — pixel-identical to the mark-after-base sequence FriBidi reports (a א ҃), just a different internal convention for where the attached mark sits in the codepoint stream. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full review of current head 8a03e11: the U+0483–U+0489 and whitespace class fixes are correct, and the assigned shipped-codepoint class/mirror/bracket audit is now clean. The focused bidi/shaping/layout suites pass (6/6), the resolver matches all 100,038 applicable class-based Unicode cases, both put_bytes targets pass standalone after the known full-suite parallelism flake, and the firmware build passes.

I found one render bug in the new titlo regression:

The asserted 0061 0483 05D0 visual order is not pixel-equivalent to 0061 05D0 0483 with the shipped glyph metrics. The test comment assumes that zero advance makes the reversed mark overlay the following base at the same position. However, walk_line() gives both codepoints the same pen position, while render_glyph() places each bitmap at cursor.origin.x + left_offset_px. Using the same FreeType flags as fontgen.py, the Russian ROBOTO_CONDENSED_21_EXTENDED source gives U+0483 advance 0, left offset -8, and width 6; a advances 10. With the engine's order for logical 05D0 0483 0061, the mark is drawn from x=2..7 over the preceding a, then the Alef/fallback starts around x=11. In the base-then-mark order, the mark is drawn around x=10..15 over the base. At a clipped edge the current placement can lose it entirely.

This is the L3/render-attachment part of bidi, not the now-correct NSM classification. Please add a render-position/pixel regression using real mark metrics and either preserve base+mark order for marks whose bearings expect after-base drawing, or explicitly position a reversed mark relative to the following base's advance. A blind global swap may not be suitable because the Hebrew/Arabic fonts appear to rely on the existing mark-before-base convention. Reference: UAX #9 L3.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head with a real L3 implementation rather than a convention argument — you were right that the overlay assumption fails for negative-bearing marks. After L2, each marks+base cluster on an odd level is re-reversed, so the base leads and its combining marks follow in logical order: a zero-advance mark with negative bearing (your titlo metrics: advance 0, offset -8) now draws over its own base's cell, and the visual sequence matches the reference base-then-marks order outright — the previous representation caveat is retired. The titlo regression asserts the reference order, and a new harakat case covers Arabic marks mid-word on a reversed level. The pixel-position regression with real glyph metrics joins the render-harness docket, where the per-form advance fixtures it needs already live. Also from the parallel review: the embedding controls U+202A-202E are now BN like the isolates. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head 4521fab: the new L3 pass fixes the Cyrillic titlo case's internal order, the focused bidi/shaping/layout suites pass (6/6), the underlying resolver levels still match all 100,038 applicable class-based Unicode cases, the full suite is 330/331 with the known put_bytes parallelism flake passing standalone, and the firmware build passes. I found one render regression in the new fix:

The global marks+base re-reversal detaches Arabic harakat with the shipped Vazirmatn metrics. test_bidi__harakat_follow_base checks only codepoint order, but the render loop advances the pen after each base and render_glyph() adds the glyph's stored left_offset_px. fontgen.py stores standalone FreeType bitmap_left and advance values, without applying GPOS positioning.

For the test's logical Beh + fatha + Jeem, shaping produces FE91 064E FE9E. At the 20 px size used by Arabic GOTHIC_28_EXTENDED, the exact fontgen.py flags give:

  • U+FE9E: left 1, width 13, advance 14
  • U+FE91: left 0, width 5, advance 6
  • U+064E: left 1, width 4, advance 0

The new visual order FE9E FE91 064E draws Jeem at x=1..13, Beh at x=14..18, then fatha at x=21..24: the mark is visibly detached to the right of its base. The pre-L3 order FE9E 064E FE91 draws both fatha and Beh from pen x=14, with bitmaps x=15..18 and x=14..18 respectively, which is the convention this font's positive-bearing marks expect. This is the inverse of Roboto's U+0483, whose negative bearing expects base-then-mark.

Please add actual render-position/pixel regressions for both the Arabic and Cyrillic cases and anchor marks explicitly (or normalize their generated metrics). A single global codepoint-order convention cannot position both shipped metric conventions correctly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head with the anchoring you prescribed rather than another order flip. The draw loop now positions zero-advance combining marks explicitly on their base, choosing the pen by the mark's stored bearing: positive or zero left offset draws at the base's own pen (the convention your Vazirmatn fatha metrics expect), negative draws at the pen after the base's advance (your Roboto titlo). The L3 codepoint order stays reference-conformant — pixels are now order-independent for marks, which is exactly why anchoring is the right layer for this. The pixel-position regressions for both conventions join the render-harness docket, which now has real bearing fixtures to encode from your measurements. From the parallel review: the Greek ON signs and the Cyrillic thousands sign are carved out of the letter range, U+2329/232A pair and mirror with the CJK angles, and on the deviation question — yes, intentional: Arabic number signs alone are degenerate input, and right-aligning them is the harmless reading. Suite green apart from the known put_bytes flake; obelix firmware builds.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head 13940e3: the focused bidi/shaping/layout tests pass (6/6), the Obelix firmware builds, and the full suite is 330/331 with the pre-existing put_bytes parallel-run flake passing standalone. The previous fatha/titlo anchor repros are fixed, but I found three current-head correctness issues:

  1. U+0482 CYRILLIC THOUSANDS SIGN was changed to the wrong bidi class. Unicode 17 assigns U+0482 class L, not ON; it is also shipped in the Russian and Ukrainian codepoint manifests. With logical 05D0 0020 0482 0061, the current engine produces visual 0061 0482 0020 05D0, while Unicode/FriBidi produces 0482 0061 0020 05D0. Please leave U+0482 in the surrounding Cyrillic L range and add this regression. Reference: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt

  2. The bearing-sign anchor heuristic still detaches a shipped Arabic mark. U+06DC ARABIC SMALL HIGH SEEN is an NSM included by the Arabic manifest. In the shipped Vazirmatn Regular/Bold glyphs at the generated 20 px size it has zero advance but a negative left bearing (left=-2, width=11). For logical Beh + U+06DC + Jeem, shaping/L3 yields visual FE9E FE91 06DC; the new left_offset_px < 0 branch anchors the mark after Beh's advance, putting its bitmap at x=18..28 while Beh occupies x=14..18. Anchoring at the base pen instead puts it at x=12..22, centered on the base. HarfBuzz confirms that this font positions U+06DC from the base pen despite the negative bearing, so bearing sign is not a reliable convention discriminator even within Vazirmatn. Please add a pixel/position regression for this sequence and use explicit generated mark positioning (or equivalent per-glyph metadata) rather than inferring the anchor from bearing sign.

  3. The newly added canonical angle brackets do not match their canonical equivalents. Pair matching still requires exact equality: prv_paired_close(opener) == closer. UAX 9 requires U+2329/U+232A to pair with their canonical equivalents U+3008/U+3009 as well, so mixed 2329 ... 3009 and 3008 ... 232A pairs remain unmatched. Four Unicode 17 BidiCharacterTest.txt cases still fail for this reason. For example, logical 200F 0061 0020 2329 0062 002E 0031 3009 currently reorders to 3008 0061 0020 2329 0062 002E 0031 200F; the conformant result is 0061 0020 2329 0062 002E 0031 3009 200F. Please canonicalize these two pairs for BD16 matching and add all four mixed-equivalence cases. References: https://www.unicode.org/reports/tr9/#BD16 and https://www.unicode.org/Public/17.0.0/ucd/BidiCharacterTest.txt

I would address these before merging.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

All three addressed at the current head. U+0482 is back in the Cyrillic L range with your repro as a regression — that misclass was introduced last round by taking a parallel-review claim without checking DerivedBidiClass, which is on me; the Greek ON carve-outs it arrived with were verified and stay. The mark anchor no longer infers a convention from bearing sign: the draw pass computes the mark's bitmap span at both candidate pens and places it where more ink lands on the base's cell — by your own measurements that resolves fatha (base pen), titlo (after-advance pen) and U+06DC (base pen, matching HarfBuzz) with one rule and no per-glyph metadata; the three bearing fixtures go to the render-harness docket as the pixel regressions. BD16 now matches bracket pairs through canonical equivalence (U+2329/232A canonicalize to U+3008/3009 for pairing only; L4 still mirrors the literal forms), and both mixed-equivalence combinations are regression tests reproducing your conformant orders. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head 38154b7: the three findings from the previous review are fixed. The focused bidi/shaping/layout tests pass (6/6), the core resolver has zero level failures across all 100,038 applicable class-based Unicode runs, the shipped class/mirror audit is clean, the qemu_gabbro firmware builds, and the full suite is 330/331 with the known put_bytes parallel-run flake passing standalone. I found one remaining render regression:

  1. L3 stops at a retained boundary-neutral and attaches the mark to the wrong base. After L2, prv_apply() scans a consecutive NSM run and treats the very next odd-level codepoint as its base. A retained BN is not a base, but the scan stops there and reverses the mark with the BN instead of looking through it. Concrete shipped-codepoint repro: logical 0628 200C 064E 062C (Beh, ZWNJ, fatha, Jeem). W1 correctly resolves the fatha through the ZWNJ, but the current L3 result has visual indices 3 1 2 0 (062C 200C 064E 0628); FriBidi's L3/reordered-NSM result is 3 0 1 2 (062C 0628 200C 064E). Because ZWNJ is skipped by the draw loop, the current visible draw order is Jeem, fatha, Beh, so the new base tracker anchors the fatha on Jeem. With shipped Vazirmatn Regular at 20 px, Jeem advances 13 px and the fatha has left=1, width=4, advance=0: current placement is [1,5) over Jeem, while the conformant order places it at [14,18) over Beh. Please make the L3 cluster search treat retained BNs as transparent and reverse through the actual base, then add this sequence as an engine and pixel-position regression. Reference: https://www.unicode.org/reports/tr9/#L3

Small documentation cleanup in the same change: the comment above the ON exceptions still says “the Cyrillic thousands sign,” although U+0482 is now correctly back in the L range. The new exception is U+0387 GREEK ANO TELEIA, so the comment should name that instead.

I would address the L3 case before merging.

Replace the per-direction segment reversal in walk_line() with a UAX 9
engine implementing the Basic Display Algorithm: resolve character
classes, weak types (W1-W7), bracket pairs (N0/BD16), neutrals (N1/N2)
and implicit levels (I1/I2) once for the whole paragraph, then apply
the per-line rules (L1 whitespace reset, L2 reversal, L4 mirroring) to
each display line's slice. Weak, neutral and bracket resolution
therefore cross soft wraps exactly as in an unwrapped paragraph. The
old segment approach could not handle mixed-direction lines: brackets
split across an LTR/RTL segment boundary mirrored one side only, and
neutrals at run boundaries reordered wrongly.

Character classes follow the UCD for the codepoints the firmware
ships (including fullwidth forms, presentation forms and the CJK
bracket pairs), and the resolver matches the applicable class-based
Unicode BidiTest and BidiCharacterTest data. Boundary neutrals are
transparent to the weak and neutral rules per UAX 9 5.2, a resolved
bracket pair propagates its type to following combining marks, and
the truncation suffix joins the line at the base level so it lands at
the visual end of the reading direction.

The resolver handles one paragraph at a time (255 codepoints, so
every index fits a uint8_t). All working state - the ~3.6 KiB
BidiScratch - lives in a single heap block, because task stacks are
2-4 KiB. Line shaping folds Lam-Alef ligatures and regional-indicator
pairs with the paragraph as joining context, so Arabic letterforms
stay correct across wraps, and the resolved levels are carried
through the fold. Longer paragraphs, over-cap lines and allocation
failure all fall back to the standard logical-order path instead of
truncating.

Direction is resolved per paragraph, as UAX 9 requires: the RTL gate,
the base level (P2/P3) and the default alignment all scan exactly the
line's own paragraph (in Fill mode a newline renders as a space, so
the whole box is one paragraph). One deviation from P3: digit-only
Arabic-script text stays RTL, so times and verification codes keep
their right alignment. The old rtl_support helpers are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Khalid Nuaim <i@kalua.im>
@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed at the current head. The L3 cluster scan now looks through retained boundary neutrals, so a mark separated from its base by a ZWNJ re-reverses with the actual base — your Beh-ZWNJ-fatha-Jeem repro resolves to the FriBidi order (Jeem, Beh, ZWNJ, fatha) and is an engine regression; the draw anchor consequently lands the fatha on Beh's cell, and the pixel form of the case joins the render-harness fixtures. The ON carve-out comment now names ano teleia instead of the departed thousands sign, and from the parallel review U+2212 MINUS SIGN is ES, so a negative temperature keeps its sign attached in RTL. Suite green apart from the known put_bytes flake; obelix firmware builds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kaluaim

kaluaim commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Fresh full re-review of current head 4044f70: the retained-BN L3 repro is fixed, U+2212 now resolves as ES, all three CI checks are green, the six focused bidi/shaping/layout suites pass, and the qemu_gabbro firmware builds. The full suite is 330/331 only because the known parallel test_put_bytes_obelix flake; that target passes standalone. The resolver conformance and shipped-table audits otherwise match the prior head. I found two new issues:

  1. The bidi fit scan's equal-mirror-advance assumption is false for a shipped font, so an RTL line can be under-measured and overdraw its right edge. text_layout.c:767-771 measures the logical, unmirrored codepoints, while the draw loop advances over the L4-mirrored codepoints. In the actual ROBOTO_CONDENSED_21 resource, U+003C LESS-THAN SIGN has a 9 px advance, but its U+003E GREATER-THAN SIGN mirror has a 10 px advance. The logical sequence U+200F U+0026 U+003C U+003F measures as 29 px (11 + 9 + 9), but this engine reorders/mirrors it to U+003F U+003E U+0026 U+200F and draws 30 px (9 + 10 + 11). Because the RTL paragraph is right-aligned using the 29 px line width, the visual run extends past the box's right edge; it can clip/bleed at the boundary, and max_used_size is underreported. The fit and line-width calculations need to use the same mirrored advances as drawing (or apply the mirror delta), with a regression using this shipped font.

  2. L3 still reverses a leading NSM sequence when no base terminates the scan. In bidi.c:649-671, the tail is restored only when w < n. For logical U+064E U+0651 U+0628 (fatha, shadda, Beh), L2 leaves the mark run at the visual tail, w == n, and the current result is U+0628 U+0651 U+064E; FriBidi's reordered-NSM mode and ICU preserve it as U+0628 U+064E U+0651. With a leading retained BN (U+200C U+064E U+0651 U+0628), current output is U+0628 U+0651 U+064E U+200C, versus FriBidi's U+0628 U+200C U+064E U+0651. Since this implementation opts into L3, the no-following-base branch should restore the tail run's logical order too; please cover both forms in regression tests.

The mirror-width mismatch is user-visible and should be fixed before merging. The leading-mark case is a smaller L3 correctness edge.

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.

2 participants