From 4044f702254ea629b2417dc5400da22290ad175b Mon Sep 17 00:00:00 2001 From: Khalid Nuaim Date: Sun, 19 Jul 2026 13:46:11 +0300 Subject: [PATCH] applib: render bidirectional text with the Unicode bidi algorithm 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 Signed-off-by: Khalid Nuaim --- src/fw/applib/fonts/codepoint.c | 5 - src/fw/applib/fonts/codepoint.h | 3 - src/fw/applib/graphics/arabic_shaping.c | 16 +- src/fw/applib/graphics/arabic_shaping.h | 10 +- src/fw/applib/graphics/bidi.c | 745 ++++++++++++++++++ src/fw/applib/graphics/bidi.h | 146 ++++ src/fw/applib/graphics/rtl_support.c | 212 ----- src/fw/applib/graphics/rtl_support.h | 43 - src/fw/applib/graphics/text_layout.c | 715 ++++++++++------- .../fw/apps/system_apps/health/wscript_build | 2 +- .../apps/system_apps/launcher/wscript_build | 2 +- tests/fw/apps/system_apps/music/wscript_build | 2 +- .../apps/system_apps/timeline/wscript_build | 2 +- .../fw/apps/system_apps/weather/wscript_build | 2 +- .../fw/apps/system_apps/workout/wscript_build | 2 +- tests/fw/apps/watch/kickstart/wscript_build | 2 +- tests/fw/graphics/wscript_build | 2 +- tests/fw/services/timeline/wscript_build | 2 +- tests/fw/test_arabic_shaping.c | 3 +- tests/fw/test_bidi.c | 659 ++++++++++++++++ tests/fw/test_line_layout.c | 37 + tests/fw/test_rtl_support.c | 153 ---- tests/fw/ui/wscript_build | 4 +- tests/fw/wscript_build | 19 +- 24 files changed, 2041 insertions(+), 747 deletions(-) create mode 100644 src/fw/applib/graphics/bidi.c create mode 100644 src/fw/applib/graphics/bidi.h delete mode 100644 src/fw/applib/graphics/rtl_support.c delete mode 100644 src/fw/applib/graphics/rtl_support.h create mode 100644 tests/fw/test_bidi.c delete mode 100644 tests/fw/test_rtl_support.c diff --git a/src/fw/applib/fonts/codepoint.c b/src/fw/applib/fonts/codepoint.c index c0c916f263..8a4b2dddc8 100644 --- a/src/fw/applib/fonts/codepoint.c +++ b/src/fw/applib/fonts/codepoint.c @@ -218,8 +218,3 @@ bool codepoint_is_special(const Codepoint codepoint) { return (codepoint >= MIN_SPECIAL_CODEPOINT && codepoint <= MAX_SPECIAL_CODEPOINT); } - -bool codepoint_is_rtl(const Codepoint codepoint) { - return (codepoint >= MIN_ARABIC_CODEPOINT && codepoint <= MAX_ARABIC_CODEPOINT) || - (codepoint >= MIN_HEBREW_CODEPOINT && codepoint <= MAX_HEBREW_CODEPOINT); -} diff --git a/src/fw/applib/fonts/codepoint.h b/src/fw/applib/fonts/codepoint.h index 6b6277e144..e89cbcf766 100644 --- a/src/fw/applib/fonts/codepoint.h +++ b/src/fw/applib/fonts/codepoint.h @@ -70,6 +70,3 @@ Codepoint emoji_shape_pair(const Codepoint curr_cp, const Codepoint next_cp, // This is a least dirty hack to enable special rendering when a special codepoint is hit in the // text being rendered bool codepoint_is_special(const Codepoint codepoint); - -// Check if a codepoint is from a right-to-left script (Arabic, Hebrew) -bool codepoint_is_rtl(const Codepoint codepoint); diff --git a/src/fw/applib/graphics/arabic_shaping.c b/src/fw/applib/graphics/arabic_shaping.c index e945c775fe..d84211acaa 100644 --- a/src/fw/applib/graphics/arabic_shaping.c +++ b/src/fw/applib/graphics/arabic_shaping.c @@ -5,10 +5,6 @@ #include -// Caps codepoints per shaping call. Sized to match walk_line's 128-byte -// shape buffer (~64 Arabic codepoints at 2 UTF-8 bytes each). -#define MAX_SHAPE_CODEPOINTS 64 - // Connectivity flags for Arabic letters #define JOIN_NONE 0x00 // Does not connect (space, punctuation) #define JOIN_RIGHT_ONLY 0x01 // Connects only to the right (non-connecting letters) @@ -304,13 +300,13 @@ bool arabic_is_transparent(Codepoint cp) { } size_t arabic_shape_text(const utf8_t *src, size_t src_len, - utf8_t *dest, size_t dest_size) { - if (src == NULL || dest == NULL || src_len == 0 || dest_size == 0) { + utf8_t *dest, size_t dest_size, Codepoint *cp_scratch) { + if (src == NULL || dest == NULL || cp_scratch == NULL || src_len == 0 || dest_size == 0) { return 0; } - // First pass: collect all codepoints into an array - Codepoint codepoints[MAX_SHAPE_CODEPOINTS]; + // First pass: collect all codepoints into the caller's scratch array + Codepoint *codepoints = cp_scratch; size_t num_codepoints = 0; utf8_t *ptr = (utf8_t *)src; @@ -355,7 +351,9 @@ size_t arabic_shape_text(const utf8_t *src, size_t src_len, } } - // Lam-Alef collapses two letters into one ligature glyph. + // Lam-Alef collapses two letters into one ligature glyph. Emoji pairs are + // NOT folded here: regional indicators must reach the bidi engine as their + // UCD class L, so the draw pass folds them after reordering. bool consumed_next = false; Codepoint shaped_cp = arabic_shape_pair(prev_cp, curr_cp, next_cp, &consumed_next); diff --git a/src/fw/applib/graphics/arabic_shaping.h b/src/fw/applib/graphics/arabic_shaping.h index 515cf1e1e4..8fde0d6645 100644 --- a/src/fw/applib/graphics/arabic_shaping.h +++ b/src/fw/applib/graphics/arabic_shaping.h @@ -48,6 +48,11 @@ Codepoint arabic_shape_codepoint(Codepoint prev_cp, Codepoint curr_cp, Codepoint Codepoint arabic_shape_pair(Codepoint prev_cp, Codepoint curr_cp, Codepoint next_cp, bool *consumed_next); +//! Caps codepoints per arabic_shape_text() call. The bidi render path shapes +//! per codepoint with paragraph context and no longer uses this helper; the +//! cap covers its remaining (test and utility) callers. +#define MAX_SHAPE_CODEPOINTS 96 + //! Shape Arabic text by converting basic Arabic letters to their //! contextual presentation forms based on position in words. //! @@ -59,7 +64,10 @@ Codepoint arabic_shape_pair(Codepoint prev_cp, Codepoint curr_cp, Codepoint next //! @param src_len Length of source string in bytes //! @param dest Destination buffer for shaped text //! @param dest_size Size of destination buffer in bytes +//! @param cp_scratch Working array for at least MAX_SHAPE_CODEPOINTS +//! codepoints. Caller-provided so the render path can keep it off the +//! 2-4 KiB task stacks (it reuses the bidi heap scratch). //! @return Number of bytes written to dest (excluding null terminator), //! or 0 on failure size_t arabic_shape_text(const utf8_t *src, size_t src_len, - utf8_t *dest, size_t dest_size); + utf8_t *dest, size_t dest_size, Codepoint *cp_scratch); diff --git a/src/fw/applib/graphics/bidi.c b/src/fw/applib/graphics/bidi.c new file mode 100644 index 0000000000..76ad31ef20 --- /dev/null +++ b/src/fw/applib/graphics/bidi.c @@ -0,0 +1,745 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "bidi.h" + +#include + +// Bidi character classes (UAX 9). Only the classes that occur in watch text +// are distinguished; everything else falls back to ON (other neutral). +typedef enum { + L, R, AL, // strong + EN, ES, ET, AN, CS, // weak (numbers and number punctuation) + NSM, BN, // marks and boundary neutrals + ON, WS, B, S, // neutrals and separators +} BidiClass; + +static bool prv_in(Codepoint cp, Codepoint lo, Codepoint hi) { + return cp >= lo && cp <= hi; +} + +// Assign a bidi class to a codepoint (the subset relevant to watch text). +static BidiClass prv_class(Codepoint cp) { + if (cp == 0x000A || cp == 0x000D || cp == 0x0085 || cp == 0x2029 || + prv_in(cp, 0x001C, 0x001E)) return B; + if (cp == 0x0009 || cp == 0x000B || cp == 0x001F) return S; + if (cp == 0x0020 || cp == 0x000C || cp == 0x2028 || cp == 0x1680 || + prv_in(cp, 0x2000, 0x200A) || cp == 0x205F || cp == 0x3000) return WS; + + // Directional marks: zero-width but strong. + if (cp == 0x200E) return L; // LEFT-TO-RIGHT MARK + if (cp == 0x200F) return R; // RIGHT-TO-LEFT MARK + + // Latin-1 letters outside the contiguous letter ranges, and superscript + // digits (EN per the UCD). + if (cp == 0x00AA || cp == 0x00B5 || cp == 0x00BA) return L; // ª µ º + if (cp == 0x00B2 || cp == 0x00B3 || cp == 0x00B9) return EN; // ² ³ ¹ + + // Regional indicators are strong L per the UCD. They pass through the + // reorder as a same-level pair (L never lands on an odd level alone between + // its partner), so the draw pass can fold a pair into one flag glyph after + // reordering. The literal flag emoji itself stays ON like other symbols. + if (prv_in(cp, 0x1F1E6, 0x1F1FF)) return L; + + // European numbers and number punctuation. Extended Arabic-Indic (Persian) + // digits and fullwidth digits are EN per the UCD; inside Arabic text W2 + // turns them into AN. + if (prv_in(cp, 0x0030, 0x0039) || prv_in(cp, 0x06F0, 0x06F9) || + prv_in(cp, 0xFF10, 0xFF19)) return EN; + if (cp == 0x002B || cp == 0x002D || cp == 0x2212 || + cp == 0xFF0B || cp == 0xFF0D) return ES; + if (cp == 0x0023 || cp == 0x0024 || cp == 0x0025 || + prv_in(cp, 0x00A2, 0x00A5) || cp == 0x00B0 || cp == 0x00B1 || + cp == 0x0609 || cp == 0x060A || cp == 0x066A || + cp == 0x20AA || cp == 0x20AC || prv_in(cp, 0x2030, 0x2034) || + prv_in(cp, 0xFF03, 0xFF05) || prv_in(cp, 0xFFE0, 0xFFE1) || + prv_in(cp, 0xFFE5, 0xFFE6)) return ET; + if (cp == 0x002C || cp == 0x002E || cp == 0x002F || cp == 0x003A || + cp == 0x00A0 || cp == 0x060C || cp == 0x202F || + cp == 0xFF0C || cp == 0xFF0E || cp == 0xFF0F || cp == 0xFF1A) return CS; + + // Arabic numbers and number signs. + if (prv_in(cp, 0x0660, 0x0669) || cp == 0x08E2 || + prv_in(cp, 0x066B, 0x066C) || prv_in(cp, 0x0600, 0x0605) || cp == 0x06DD) return AN; + + // Non-spacing marks (Arabic harakat, Hebrew points, generic combining). + if (prv_in(cp, 0x0610, 0x061A) || prv_in(cp, 0x064B, 0x065F) || cp == 0x0670 || + prv_in(cp, 0x06D6, 0x06DC) || prv_in(cp, 0x06DF, 0x06E4) || + prv_in(cp, 0x06E7, 0x06E8) || prv_in(cp, 0x06EA, 0x06ED) || + prv_in(cp, 0x0591, 0x05BD) || cp == 0x05BF || cp == 0x05C1 || cp == 0x05C2 || + cp == 0x05C4 || cp == 0x05C5 || cp == 0x05C7 || cp == 0xFB1E || + prv_in(cp, 0x08D3, 0x08FF) || prv_in(cp, 0xFE00, 0xFE0F) || + prv_in(cp, 0x20D0, 0x20FF) || // combining marks for symbols (incl. keycap) + prv_in(cp, 0x0483, 0x0489) || // Cyrillic combining marks (before the L range) + prv_in(cp, 0x0300, 0x036F)) return NSM; + + // Arabic-block signs that are neutral (ON) in the UCD, not letters: roots, + // poetic/verse marks, rub-el-hizb and place-of-sajdah. Listed before the + // broad Arabic AL range below so they resolve as neutrals. + if (cp == 0x0606 || cp == 0x0607 || cp == 0x060E || cp == 0x060F || + cp == 0x06DE || cp == 0x06E9 || cp == 0xFD3E || cp == 0xFD3F) return ON; + + // Zero-width and other boundary neutrals. The isolate controls (FSI/RLI/ + // LRI/PDI) are unimplemented; treating them as transparent BN is closer to + // ignoring them than letting them join neutral runs as ON. + if (cp == 0x200B || cp == 0x200C || cp == 0x200D || cp == 0xFEFF || + prv_in(cp, 0x2066, 0x2069) || prv_in(cp, 0x202A, 0x202E) || + prv_in(cp, 0x0000, 0x0008) || prv_in(cp, 0x000E, 0x001B)) return BN; + + // Strong RTL: Hebrew including presentation forms, and NKo (R); Arabic + // including supplements and presentation forms (AL). + if (prv_in(cp, 0x0590, 0x05FF) || prv_in(cp, 0x07C0, 0x07FF) || + prv_in(cp, 0xFB1D, 0xFB4F)) return R; + if (prv_in(cp, 0x0600, 0x06FF) || prv_in(cp, 0x0750, 0x077F) || + prv_in(cp, 0x08A0, 0x08FF) || prv_in(cp, 0xFB50, 0xFDFF) || + prv_in(cp, 0xFE70, 0xFEFF)) return AL; + + // Signs inside the letter ranges that the UCD classes ON: multiplication + // and division, the Greek numeral/accent signs, the Greek question mark and + // ano teleia. + if (cp == 0x00D7 || cp == 0x00F7 || cp == 0x0374 || cp == 0x037E || + cp == 0x0384 || cp == 0x0385 || cp == 0x0387) return ON; + + // Strong LTR: Latin (including fullwidth forms), Greek, Cyrillic, and the + // major LTR CJK/Kana/Hangul blocks. + if (prv_in(cp, 0x0041, 0x005A) || prv_in(cp, 0x0061, 0x007A) || + prv_in(cp, 0x00C0, 0x02AF) || prv_in(cp, 0x0370, 0x04FF) || + prv_in(cp, 0x3040, 0x30FF) || prv_in(cp, 0x3400, 0x9FFF) || + prv_in(cp, 0xAC00, 0xD7AF) || prv_in(cp, 0xF900, 0xFAFF) || + prv_in(cp, 0x3220, 0x3229) || // parenthesized ideograph list markers + prv_in(cp, 0xFF21, 0xFF3A) || prv_in(cp, 0xFF41, 0xFF5A)) return L; + + // Brackets, punctuation and symbols are neutral. + return ON; +} + +static Codepoint prv_paired_close(Codepoint cp); +static Codepoint prv_paired_open(Codepoint cp); + +Codepoint bidi_mirror(Codepoint cp) { + // Every paired bracket mirrors to its partner, derived from the pair tables + // so the pairing and mirroring sets cannot drift apart. + Codepoint other = prv_paired_close(cp); + if (other != 0) return other; + other = prv_paired_open(cp); + if (other != 0) return other; + // Mirrored codepoints that are not paired brackets. + switch (cp) { + case '<': return '>'; + case '>': return '<'; + case 0x00AB: return 0x00BB; // « -> » + case 0x00BB: return 0x00AB; // » -> « + case 0x2039: return 0x203A; // ‹ -> › + case 0x203A: return 0x2039; // › -> ‹ + case 0x2264: return 0x2265; // <= -> >= + case 0x2265: return 0x2264; + case 0x2329: return 0x232A; // deprecated angle brackets (literal mirror) + case 0x232A: return 0x2329; + case 0x223C: return 0x223D; // tilde operator <-> reversed tilde + case 0x223D: return 0x223C; + case 0x226E: return 0x226F; // not-less-than <-> not-greater-than + case 0x226F: return 0x226E; + case 0xFF1C: return 0xFF1E; // fullwidth < -> > + case 0xFF1E: return 0xFF1C; + default: return cp; + } +} + +// BD16 matches bracket pairs by canonical equivalence: the deprecated angle +// brackets pair with their CJK canonical equivalents in either combination. +// Only pairing uses this; L4 mirrors the literal codepoint. +static Codepoint prv_bracket_canonical(Codepoint cp) { + if (cp == 0x2329) return 0x3008; + if (cp == 0x232A) return 0x3009; + return cp; +} + +// Canonical closing bracket for an opening one, or 0 if not a paired bracket. +// The Bidi_Paired_Bracket set that occurs in watch text: round, square and +// curly brackets, plus the CJK angle brackets (in CJK notification fonts). +// ASCII '<'/'>' and the guillemets are mirrored (L4) but are not paired +// brackets, so they are not listed here. +static Codepoint prv_paired_close(Codepoint cp) { + switch (cp) { + case '(': return ')'; + case '[': return ']'; + case '{': return '}'; + case 0x3008: return 0x3009; // 〈 〉 (and canonical U+2329 via canonicalization) + case 0x300A: return 0x300B; // 《 》 + case 0x300C: return 0x300D; // 「 」 + case 0x3010: return 0x3011; // 【 】 + case 0x3014: return 0x3015; // 〔 〕 + case 0x3016: return 0x3017; // 〖 〗 + case 0xFF08: return 0xFF09; // fullwidth ( ) + case 0xFF3B: return 0xFF3D; // fullwidth [ ] + case 0xFF5B: return 0xFF5D; // fullwidth { } + case 0xFF62: return 0xFF63; // halfwidth corner brackets + default: return 0; + } +} + +// Opening bracket for a closing one - the inverse of prv_paired_close(). +static Codepoint prv_paired_open(Codepoint cp) { + switch (cp) { + case ')': return '('; + case ']': return '['; + case '}': return '{'; + case 0x3009: return 0x3008; + case 0x300B: return 0x300A; + case 0x300D: return 0x300C; + case 0x3011: return 0x3010; + case 0x3015: return 0x3014; + case 0x3017: return 0x3016; + case 0xFF09: return 0xFF08; + case 0xFF3D: return 0xFF3B; + case 0xFF5D: return 0xFF5B; + case 0xFF63: return 0xFF62; + default: return 0; + } +} + +// True for the closing half of any pair prv_paired_close() knows. +static bool prv_is_paired_close(Codepoint cp) { + return prv_paired_open(cp) != 0; +} + +int bidi_base_level(const Codepoint *cps, size_t n) { + for (size_t i = 0; i < n; i++) { + BidiClass c = prv_class(cps[i]); + if (c == L) return 0; + if (c == R || c == AL) return 1; + } + return 0; +} + +int bidi_base_level_utf8(const utf8_t *start, const utf8_t *end) { + if (start == NULL || end == NULL || start >= end) { + return 0; + } + bool saw_arabic_number = false; + utf8_t *ptr = (utf8_t *)start; + while (ptr < end && *ptr != '\0') { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) { + break; + } + BidiClass c = prv_class(cp); + if (c == L) return 0; + if (c == R || c == AL) return 1; + if (c == AN || prv_in(cp, 0x06F0, 0x06F9)) saw_arabic_number = true; + ptr = next; + } + // Deviation from P3: Arabic-script numbers (Arabic-Indic or Extended + // Arabic-Indic digits, Arabic number signs) with no strong character read + // RTL, so digit-only text in an Arabic notification stays right-aligned. + return saw_arabic_number ? 1 : 0; +} + +bool bidi_contains_rtl(const utf8_t *start, const utf8_t *end) { + if (start == NULL || end == NULL || start >= end) { + return false; + } + utf8_t *ptr = (utf8_t *)start; + while (ptr < end && *ptr != '\0') { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) { + break; + } + BidiClass c = prv_class(cp); + if (c == R || c == AL) { + return true; + } + ptr = next; + } + return false; +} + +int bidi_last_strong_utf8(const utf8_t *start, const utf8_t *end) { + int found = BIDI_BOUNDARY_AUTO; + if (start == NULL || end == NULL || start >= end) return found; + utf8_t *ptr = (utf8_t *)start; + while (ptr < end && *ptr != '\0') { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) break; + BidiClass c = prv_class(cp); + if (c == L) found = BIDI_BOUNDARY_L; + else if (c == R) found = BIDI_BOUNDARY_R; + else if (c == AL) found = BIDI_BOUNDARY_AL; + ptr = next; + } + return found; +} + +int bidi_boundary_ndir_utf8(const utf8_t *start, const utf8_t *end, int base_level, + BidiScratch *ws) { + if (start == NULL || end == NULL || start >= end || ws == NULL) return BIDI_BOUNDARY_AUTO; + // A raw class scan cannot represent N0: a bracket pair resolved to L leaves + // L at the boundary even though a strong R sits inside it. Run the resolver + // over the paragraph prefix (its own sos IS the base direction, since the + // prefix starts the paragraph) and read the direction the last character + // resolved to. For a prefix longer than the resolver's capacity, resolve + // the trailing window - the boundary direction is a local property. + size_t total = 0; + utf8_t *ptr = (utf8_t *)start; + while (ptr < end && *ptr != '\0') { + utf8_t *next = NULL; + if (utf8_peek_codepoint(ptr, &next) == 0 || next == NULL) break; + total++; + ptr = next; + } + if (total == 0) return BIDI_BOUNDARY_AUTO; + utf8_t *from = (utf8_t *)start; + for (size_t skip = (total > BIDI_MAX_CODEPOINTS) ? total - BIDI_MAX_CODEPOINTS : 0; skip > 0; + skip--) { + utf8_t *next = NULL; + utf8_peek_codepoint(from, &next); + from = next; + } + size_t n = 0; + ptr = from; + while (ptr < end && *ptr != '\0' && n < BIDI_MAX_CODEPOINTS) { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) break; + ws->cps[n++] = cp; + ptr = next; + } + if (bidi_reorder_line_ctx(ws->cps, n, base_level, BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, + BIDI_BOUNDARY_AUTO, ws->visual, ws) == 0) { + return BIDI_BOUNDARY_AUTO; + } + for (size_t k = n; k > 0; k--) { + uint8_t t = ws->type[k - 1]; + if (t == BN) continue; + return (t == R || t == EN || t == AN) ? BIDI_BOUNDARY_R : BIDI_BOUNDARY_L; + } + return BIDI_BOUNDARY_AUTO; +} + +int bidi_first_strong_utf8(const utf8_t *start, const utf8_t *end, int prev_strong) { + if (start == NULL || end == NULL || start >= end) return BIDI_BOUNDARY_AUTO; + utf8_t *ptr = (utf8_t *)start; + while (ptr < end && *ptr != '\0') { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) break; + BidiClass c = prv_class(cp); + if (c == L) return BIDI_BOUNDARY_L; + if (c == R || c == AL) return BIDI_BOUNDARY_R; + // Numbers count for the N rules: AN is always R context; EN resolves per + // W2/W7 from the strong type before it (the caller's last strong, since + // no strong type precedes it within this range). + if (c == AN) return BIDI_BOUNDARY_R; + if (c == EN) { + if (prev_strong == BIDI_BOUNDARY_AL) return BIDI_BOUNDARY_R; // W2: AN + if (prev_strong == BIDI_BOUNDARY_L) return BIDI_BOUNDARY_L; // W7: L + return BIDI_BOUNDARY_R; + } + ptr = next; + } + return BIDI_BOUNDARY_AUTO; +} + +// Map a resolved type to its strong direction for neutral/bracket resolution, +// where European and Arabic numbers count as R. +static BidiClass prv_dir_of(BidiClass t) { + return (t == R || t == EN || t == AN) ? R : L; +} + +size_t bidi_reorder_line(const Codepoint *cps, size_t n, int base_level, Codepoint *out, + BidiScratch *ws) { + return bidi_reorder_line_ctx(cps, n, base_level, BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, + BIDI_BOUNDARY_AUTO, out, ws); +} + +// Resolution phase (classes, W1-W7, N0-N2, I1/I2, BN levels) - fills +// ws->orig, ws->type and ws->level for cps[0..n). +static void prv_resolve(const Codepoint *cps, size_t n, int base_level, int sos_hint, + int sos_n_hint, int eos_hint, BidiScratch *ws) { + uint8_t *const type = ws->type; + uint8_t *const orig = ws->orig; + uint8_t *const level = ws->level; + + // Boundary strong types: what the text just outside this line resolved to. + // For a full paragraph both default to the base direction (UAX 9 X10); for a + // wrapped display line the caller passes the adjacent strong context, so + // weak and neutral runs straddling the soft wrap resolve as they would have + // in the whole paragraph. + const BidiClass auto_b = (base_level & 1) ? R : L; + const BidiClass sos = + (sos_hint == BIDI_BOUNDARY_L) ? L : + (sos_hint == BIDI_BOUNDARY_R) ? R : + (sos_hint == BIDI_BOUNDARY_AL) ? AL : auto_b; + const BidiClass eos = + (eos_hint == BIDI_BOUNDARY_L) ? L : + (eos_hint == BIDI_BOUNDARY_R) ? R : auto_b; + // The neutral-rule left boundary: the resolved direction adjacent to this + // slice (numbers count as R/L per W2/W7), which can differ from the last + // strong type the weak rules need (e.g. "a .. 1 | , X": W7 context is L, + // but the digit supplies R to the neutral run across the wrap). + const BidiClass sos_n = + (sos_n_hint == BIDI_BOUNDARY_L) ? L : + (sos_n_hint == BIDI_BOUNDARY_R) ? R : ((sos == AL) ? R : sos); + const BidiClass e = auto_b; // embedding direction + + for (size_t i = 0; i < n; i++) { + orig[i] = type[i] = (uint8_t)prv_class(cps[i]); + level[i] = (uint8_t)base_level; + } + + // W1: a non-spacing mark takes the type of the preceding character (looking + // through boundary neutrals), or the boundary type at the start. BN itself + // stays BN: X9-retained characters are transparent to the weak rules + // (UAX 9 5.2) and get their level from the preceding character later. + BidiClass prev = sos; + for (size_t i = 0; i < n; i++) { + if (type[i] == NSM) { + type[i] = (uint8_t)prev; + } else if (type[i] != BN) { + prev = type[i]; + } + } + + // W2: European number becomes Arabic number if the last strong type is AL. + BidiClass strong = sos; + for (size_t i = 0; i < n; i++) { + if (type[i] == L || type[i] == R || type[i] == AL) strong = type[i]; + else if (type[i] == EN && strong == AL) type[i] = AN; + } + + // W3: AL becomes R. + for (size_t i = 0; i < n; i++) { + if (type[i] == AL) type[i] = R; + } + + // W4: a single ES between two EN, or a single CS between two numbers of the + // same kind, joins them. Neighbors are the nearest non-BN characters. + for (size_t i = 1; i + 1 < n; i++) { + if (type[i] != ES && type[i] != CS) continue; + size_t a = i; + while (a > 0 && type[a - 1] == BN) a--; + if (a == 0) continue; + size_t b = i + 1; + while (b < n && type[b] == BN) b++; + if (b == n) continue; + BidiClass before = type[a - 1], after = type[b]; + if (type[i] == ES && before == EN && after == EN) type[i] = EN; + else if (type[i] == CS && before == EN && after == EN) type[i] = EN; + else if (type[i] == CS && before == AN && after == AN) type[i] = AN; + } + + // W5: a run of ET adjacent to EN becomes EN, treating BN as transparent on + // both sides and within the run. + for (size_t i = 0; i < n; i++) { + if (type[i] != ET) continue; + size_t j = i; + while (j < n && (type[j] == ET || type[j] == BN)) j++; + size_t a = i; + while (a > 0 && type[a - 1] == BN) a--; + bool en_before = (a > 0 && type[a - 1] == EN); + bool en_after = (j < n && type[j] == EN); + if (en_before || en_after) { + for (size_t k = i; k < j; k++) { + if (type[k] == ET) type[k] = EN; + } + } + i = j - 1; + } + + // W6: remaining number punctuation becomes neutral. + for (size_t i = 0; i < n; i++) { + if (type[i] == ES || type[i] == ET || type[i] == CS) type[i] = ON; + } + + // W7: European number becomes L if the last strong type is L. + strong = (sos == AL) ? R : sos; + for (size_t i = 0; i < n; i++) { + if (type[i] == L || type[i] == R) strong = type[i]; + else if (type[i] == EN && strong == L) type[i] = L; + } + + // N0: resolve paired brackets (UAX 9 BD16/N0). Match pairs with a stack, then + // resolve them in order of their opening bracket - outer before inner - so a + // parenthesised island stays together and nested pairs resolve outward. (The + // stack alone would resolve on the closing bracket, i.e. inner first, which + // disagrees with BD16 for nested pairs.) + { + // BD16 specifies a 63-entry stack with overflow aborting bracket + // processing; this stack holds every possible opener instead and simply + // stops pushing when full, which can only differ on lines of 60+ nested + // brackets - unreachable in watch text. + int sp = 0; // bracket stack depth + int np = 0; // matched pairs collected + for (size_t i = 0; i < n; i++) { + if (type[i] != ON) continue; + if (prv_paired_close(prv_bracket_canonical(cps[i])) != 0) { + if (sp < (int)BIDI_MAX_CODEPOINTS) { + ws->open_pos[sp] = (uint8_t)i; + sp++; + } + } else if (prv_is_paired_close(prv_bracket_canonical(cps[i]))) { + for (int s = sp - 1; s >= 0; s--) { + // Match by deriving the opener's canonical closer, so a bracket + // codepoint above 0xFF is compared in full and the deprecated angle + // brackets pair with their canonical equivalents. + if (prv_paired_close(prv_bracket_canonical(cps[ws->open_pos[s]])) == + prv_bracket_canonical(cps[i])) { + ws->pair_open[np] = ws->open_pos[s]; + ws->pair_close[np] = (uint8_t)i; + np++; + sp = s; // pop this and everything above (BD16) + break; + } + } + } + } + // Insertion-sort the pairs by opening position (n is tiny). + for (int a = 1; a < np; a++) { + uint8_t po = ws->pair_open[a], pc = ws->pair_close[a]; + int b = a - 1; + while (b >= 0 && ws->pair_open[b] > po) { + ws->pair_open[b + 1] = ws->pair_open[b]; + ws->pair_close[b + 1] = ws->pair_close[b]; + b--; + } + ws->pair_open[b + 1] = po; + ws->pair_close[b + 1] = pc; + } + // Resolve each pair outer-first. + for (int pidx = 0; pidx < np; pidx++) { + size_t o = ws->pair_open[pidx]; + size_t i = ws->pair_close[pidx]; + // Strong directions strictly inside the pair. + bool found_e = false, found_o = false; + for (size_t k = o + 1; k < i; k++) { + if (type[k] == L || type[k] == R || type[k] == EN || type[k] == AN) { + if (prv_dir_of(type[k]) == e) found_e = true; + else found_o = true; + } + } + BidiClass set = ON; + if (found_e) { + set = e; + } else if (found_o) { + // Opposite direction inside: use it only if the context before the + // opening bracket is also opposite, else the embedding direction. + BidiClass before = sos_n; + for (size_t k = o; k > 0; k--) { + BidiClass t = type[k - 1]; + if (t == L || t == R || t == EN || t == AN) { before = prv_dir_of(t); break; } + } + set = (before != e) ? before : e; + } + if (set != ON) { + type[o] = (uint8_t)set; + type[i] = (uint8_t)set; + // Characters that were NSM before W1 and immediately follow a bracket + // that changed type take that bracket's type too (N0, looking through + // retained boundary neutrals). + for (size_t k = o + 1; k < n; k++) { + if (orig[k] == NSM) type[k] = (uint8_t)set; + else if (orig[k] != BN) break; + } + for (size_t k = i + 1; k < n; k++) { + if (orig[k] == NSM) type[k] = (uint8_t)set; + else if (orig[k] != BN) break; + } + } + } + } + + // N1/N2: a run of neutrals takes the surrounding direction if both sides + // agree, otherwise the embedding direction. BN is transparent: it joins the + // run for contiguity (a ZWNJ between two spaces must not split them into + // runs that then read a neutral as their context), keeps its BN type, and + // the left context scans past any BN just before the run. + for (size_t i = 0; i < n; i++) { + if (!(type[i] == ON || type[i] == WS || type[i] == B || type[i] == S)) continue; + size_t j = i; + while (j < n && (type[j] == ON || type[j] == WS || type[j] == B || type[j] == S || + type[j] == BN)) j++; + size_t a = i; + while (a > 0 && type[a - 1] == BN) a--; + BidiClass left = (a > 0) ? prv_dir_of(type[a - 1]) : sos_n; + BidiClass right = (j < n) ? prv_dir_of(type[j]) : eos; + BidiClass set = (left == right) ? left : e; + for (size_t k = i; k < j; k++) { + if (type[k] != BN) type[k] = (uint8_t)set; + } + i = j - 1; + } + + // I1/I2: implicit levels. BN is skipped, then takes the level of the + // preceding character (UAX 9 5.2), so a zero-width joiner inside a number + // does not split the number's level run. + for (size_t i = 0; i < n; i++) { + if (type[i] == BN) continue; + uint8_t lv = (uint8_t)base_level; + if ((base_level & 1) == 0) { // even (LTR) base + if (type[i] == R) lv = base_level + 1; + else if (type[i] == EN || type[i] == AN) lv = base_level + 2; + } else { // odd (RTL) base + if (type[i] == L || type[i] == EN || type[i] == AN) lv = base_level + 1; + } + level[i] = lv; + } + for (size_t i = 0; i < n; i++) { + if (type[i] == BN) { + level[i] = (i > 0) ? level[i - 1] : (uint8_t)base_level; + } + } +} + +// Per-line phase (L1, L2, L4) over one display line's codepoints and levels. +// L1 needs only "is this a separator or whitespace", which the codepoint +// itself answers, so folded/shaped lines need no original-class array. +static size_t prv_apply(const Codepoint *cps, const uint8_t *levels, size_t n, int base_level, + Codepoint *out, uint8_t *level_scratch, uint8_t *vis_scratch) { + // L1 mutates levels, so work on the caller's scratch copy - not 255 bytes + // of render-path stack (task stacks are 2-4 KiB). + memcpy(level_scratch, levels, n); + uint8_t *const level = level_scratch; + + // L1: reset separators and trailing whitespace to the base level. + for (size_t i = 0; i < n; i++) { + BidiClass ci = prv_class(cps[i]); + if (ci == B || ci == S) { + level[i] = (uint8_t)base_level; + for (size_t k = i; k > 0; k--) { + BidiClass ck = prv_class(cps[k - 1]); + if (ck == WS || ck == BN) level[k - 1] = (uint8_t)base_level; + else break; + } + } + } + for (size_t k = n; k > 0; k--) { + BidiClass ck = prv_class(cps[k - 1]); + if (ck == WS || ck == BN) level[k - 1] = (uint8_t)base_level; + else break; + } + + // L2: from the highest level down to the lowest odd level, reverse any + // contiguous run of positions at that level or higher. + uint8_t *const vis = vis_scratch; + for (size_t i = 0; i < n; i++) vis[i] = (uint8_t)i; + uint8_t max_level = 0, min_odd = 255; + for (size_t i = 0; i < n; i++) { + if (level[i] > max_level) max_level = level[i]; + if ((level[i] & 1) && level[i] < min_odd) min_odd = level[i]; + } + for (int lv = max_level; lv >= (int)min_odd && lv >= 1; lv--) { + size_t v = 0; + while (v < n) { + if (level[vis[v]] >= lv) { + size_t v2 = v; + while (v2 < n && level[vis[v2]] >= lv) v2++; + for (size_t a = v, b = v2 - 1; a < b; a++, b--) { + uint8_t tmp = vis[a]; vis[a] = vis[b]; vis[b] = tmp; + } + v = v2; + } else { + v++; + } + } + } + + // L3: on odd (reversed) levels a combining mark now precedes its base; + // the renderer draws marks after their base (zero advance, bearings over + // the previous pen position), so re-reverse each marks+base cluster. This + // also makes the visual sequence match the reference base-then-marks order. + for (size_t v = 0; v < n; v++) { + if (!(level[vis[v]] & 1) || prv_class(cps[vis[v]]) != NSM) continue; + // Extend across marks and retained boundary neutrals (a ZWNJ between a + // mark and its base is transparent, UAX 9 5.2): the cluster's base is the + // next odd-level codepoint that is neither. + size_t w = v; + while (w < n && (level[vis[w]] & 1)) { + BidiClass cw = prv_class(cps[vis[w]]); + if (cw != NSM && cw != BN) break; + w++; + } + if (w < n && (level[vis[w]] & 1)) { + // Reverse vis[v..w] so the base leads and the marks (and any retained + // neutrals between them) follow in logical order. + for (size_t a2 = v, b2 = w; a2 < b2; a2++, b2--) { + uint8_t tmp = vis[a2]; vis[a2] = vis[b2]; vis[b2] = tmp; + } + } + v = w; + } + + // L4: emit visual order, mirroring glyphs that resolved to an odd level. + for (size_t v = 0; v < n; v++) { + uint8_t li = vis[v]; + Codepoint c = cps[li]; + if (level[li] & 1) c = bidi_mirror(c); + out[v] = c; + } + return n; +} + +size_t bidi_reorder_line_ctx(const Codepoint *cps, size_t n, int base_level, int sos_hint, + int sos_n_hint, int eos_hint, Codepoint *out, BidiScratch *ws) { + if (cps == NULL || out == NULL || ws == NULL || n == 0) return 0; + if (n > BIDI_MAX_CODEPOINTS) n = BIDI_MAX_CODEPOINTS; + prv_resolve(cps, n, base_level, sos_hint, sos_n_hint, eos_hint, ws); + return prv_apply(cps, ws->level, n, base_level, out, ws->orig, ws->vis); +} + +size_t bidi_resolve_paragraph(const Codepoint *cps, size_t n, int base_level, BidiScratch *ws) { + if (cps == NULL || ws == NULL || n == 0) return 0; + if (n > BIDI_MAX_CODEPOINTS) n = BIDI_MAX_CODEPOINTS; + if (ws->cps != cps) { + memcpy(ws->cps, cps, n * sizeof(Codepoint)); + } + prv_resolve(ws->cps, n, base_level, BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, + BIDI_BOUNDARY_AUTO, ws); + return n; +} + +size_t bidi_apply_line(const Codepoint *cps, const uint8_t *levels, size_t n, int base_level, + Codepoint *out, BidiScratch *ws) { + if (cps == NULL || levels == NULL || out == NULL || ws == NULL || n == 0) return 0; + if (n > BIDI_MAX_CODEPOINTS) n = BIDI_MAX_CODEPOINTS; + // ws->orig is free by apply time (L1 derives classes from the codepoints). + return prv_apply(cps, levels, n, base_level, out, ws->orig, ws->vis); +} + +size_t bidi_reorder_utf8(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size, + int base_level, BidiScratch *ws) { + return bidi_reorder_utf8_ctx(src, src_len, dest, dest_size, base_level, BIDI_BOUNDARY_AUTO, + BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, ws); +} + +size_t bidi_reorder_utf8_ctx(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size, + int base_level, int sos_hint, int sos_n_hint, int eos_hint, + BidiScratch *ws) { + if (src == NULL || dest == NULL || ws == NULL || src_len == 0 || dest_size == 0) return 0; + + size_t n = 0; + utf8_t *ptr = (utf8_t *)src; + const utf8_t *end = src + src_len; + while (ptr < end && *ptr != '\0' && n < BIDI_MAX_CODEPOINTS) { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint(ptr, &next); + if (cp == 0 || next == NULL) break; + ws->cps[n++] = cp; + ptr = next; + } + + size_t vn = bidi_reorder_line_ctx(ws->cps, n, base_level, sos_hint, sos_n_hint, eos_hint, + ws->visual, ws); + + size_t off = 0; + for (size_t i = 0; i < vn; i++) { + if (off + 4 >= dest_size) break; + size_t w = utf8_encode_codepoint(ws->visual[i], dest + off); + if (w == 0) continue; + off += w; + } + if (off < dest_size) dest[off] = '\0'; + return off; +} diff --git a/src/fw/applib/graphics/bidi.h b/src/fw/applib/graphics/bidi.h new file mode 100644 index 0000000000..56a8ba4c4f --- /dev/null +++ b/src/fw/applib/graphics/bidi.h @@ -0,0 +1,146 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#pragma once + +#include "utf8.h" + +#include +#include +#include + +//! Minimal implementation of the Unicode Bidirectional Algorithm (UAX 9), +//! implicit levels only (no explicit RLE/LRE/RLI/... overrides, which do not +//! occur in watch text). Resolves embedding levels for a single display line, +//! reorders it to visual order, and mirrors paired punctuation. Lets a line +//! that mixes Arabic/Hebrew with Latin words, numbers and parenthesised islands +//! lay out correctly, which the previous per-direction-segment reversal could +//! not do. + +//! Maximum codepoints resolved at once - a whole paragraph, so weak, neutral +//! and bracket resolution cross soft wraps exactly as UAX 9 requires. 255 +//! keeps every index in a uint8_t; watch notification paragraphs are well +//! under it, and longer ones fall back to logical-order rendering. +#define BIDI_MAX_CODEPOINTS 255 + +//! Boundary-context hints for a wrapped display line: what the strong context +//! adjacent to this slice of the paragraph is. AUTO derives from the base +//! level (UAX 9 X10, correct for a whole paragraph); L/R/AL carry the strong +//! type across a soft wrap so weak and neutral runs straddling the wrap +//! resolve as they would have in the full paragraph. +#define BIDI_BOUNDARY_AUTO (-1) +#define BIDI_BOUNDARY_L 0 +#define BIDI_BOUNDARY_R 1 +#define BIDI_BOUNDARY_AL 2 + +//! Working storage for one paragraph resolution (~3.7 KiB). Task stacks are +//! 2-4 KiB, so the render path must heap-allocate this rather than keep it on +//! the stack; tests may use a static instance. After bidi_resolve_paragraph(), +//! cps and level hold the paragraph state; visual/type/open_pos are free for +//! the caller to reuse as line-building scratch, and bidi_apply_line() uses +//! orig and vis as its own scratch. +typedef struct BidiScratch { + Codepoint cps[BIDI_MAX_CODEPOINTS]; //!< decoded logical-order codepoints + Codepoint visual[BIDI_MAX_CODEPOINTS]; //!< reordered visual-order codepoints + uint8_t type[BIDI_MAX_CODEPOINTS]; //!< resolved bidi class per position + uint8_t orig[BIDI_MAX_CODEPOINTS]; //!< original bidi class per position + uint8_t level[BIDI_MAX_CODEPOINTS]; //!< resolved embedding level + uint8_t vis[BIDI_MAX_CODEPOINTS]; //!< visual-to-logical position map + uint8_t open_pos[BIDI_MAX_CODEPOINTS]; //!< bracket stack: opening positions + uint8_t pair_open[BIDI_MAX_CODEPOINTS]; //!< matched bracket pairs: open pos + uint8_t pair_close[BIDI_MAX_CODEPOINTS]; //!< matched bracket pairs: close pos +} BidiScratch; + +//! Paragraph embedding level from the first strong character (UAX 9 P2/P3): +//! 1 if the first strong character is R or AL, otherwise 0. +int bidi_base_level(const Codepoint *cps, size_t n); + +//! UTF-8 variant of bidi_base_level() with one deviation from P2/P3: a +//! paragraph with no strong character but at least one Arabic-script number +//! codepoint (Arabic-Indic digits or Arabic number signs, class AN) is RTL, +//! so digit-only text (times, verification codes) in an Arabic notification +//! keeps its right alignment. +int bidi_base_level_utf8(const utf8_t *start, const utf8_t *end); + +//! 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. +//! Distinct from bidi_base_level_utf8(): a digit-only Arabic-Indic paragraph +//! has no strong codepoint, so this returns false, but its base level is 1. +//! The renderer enters the bidi path on either condition, so a suffix on such +//! a line still reorders to the visual start. +bool bidi_contains_rtl(const utf8_t *start, const utf8_t *end); + +//! Reorder a logical-order codepoint array into visual (left-to-right display) +//! order, applying implicit bidi level resolution (weak types, bracket pairs, +//! neutrals) and mirroring glyphs that resolve to an odd (RTL) level. +//! @param cps Logical-order codepoints (already Arabic-shaped) +//! @param n Number of codepoints (clamped to BIDI_MAX_CODEPOINTS) +//! @param base_level 0 for an LTR line, 1 for an RTL line +//! @param out Destination for visual-order codepoints (must hold >= n) +//! @param ws Working storage (cps/visual are the caller's; the rest is internal) +//! @return Number of codepoints written +size_t bidi_reorder_line(const Codepoint *cps, size_t n, int base_level, Codepoint *out, + BidiScratch *ws); + +//! bidi_reorder_line() with explicit boundary context (BIDI_BOUNDARY_*). +//! Test/conformance surface: the renderer resolves whole paragraphs via +//! bidi_resolve_paragraph()/bidi_apply_line() and does not use these hints. sos_hint is the last strong +//! type before the slice (weak-rule context, W1/W2/W7); sos_n_hint is the +//! resolved direction adjacent to the slice where numbers count (neutral-rule +//! context, N0/N1); eos_hint is the first such direction after the slice. +size_t bidi_reorder_line_ctx(const Codepoint *cps, size_t n, int base_level, int sos_hint, + int sos_n_hint, int eos_hint, Codepoint *out, BidiScratch *ws); + +//! UTF-8 wrapper around bidi_reorder_line: decode, reorder, re-encode. +//! @return Number of bytes written to dest (excluding any terminator) +size_t bidi_reorder_utf8(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size, + int base_level, BidiScratch *ws); + +//! bidi_reorder_utf8() with explicit boundary context (BIDI_BOUNDARY_*). +size_t bidi_reorder_utf8_ctx(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size, + int base_level, int sos_hint, int sos_n_hint, int eos_hint, + BidiScratch *ws); + +//! Last strong type (BIDI_BOUNDARY_L/R/AL) in the range, or BIDI_BOUNDARY_AUTO +//! if none. Test/conformance surface, as above. +int bidi_last_strong_utf8(const utf8_t *start, const utf8_t *end); + +//! Resolved direction (BIDI_BOUNDARY_L/R) at the end of a paragraph prefix: +//! runs the resolver over the prefix and reads what the last character +//! resolved to, so N0 bracket resolution and W7 number resolution are both +//! represented. Feeds the sos_n (neutral-rule) hint for a wrapped line. +//! Clobbers ws. +int bidi_boundary_ndir_utf8(const utf8_t *start, const utf8_t *end, int base_level, + BidiScratch *ws); + +//! First direction-determining type in the range for N-rule context: strong +//! L/R/AL (AL reports as R), or a number (AN is R; EN resolves per W2/W7 from +//! prev_strong, the last strong type before this range). BIDI_BOUNDARY_AUTO +//! if none. Feeds the eos hint for a wrapped line. +int bidi_first_strong_utf8(const utf8_t *start, const utf8_t *end, int prev_strong); + +//! Resolve embedding levels for a whole paragraph (classes, weak rules, +//! bracket pairs, neutrals, implicit levels - UAX 9 P/W/N/I). Fills ws->cps +//! (a copy of cps), ws->level and ws->orig for n codepoints; no reordering is +//! performed. Lines are then extracted with bidi_apply_line(), which is the +//! UAX 9 Basic Display Algorithm: resolve once per paragraph, reorder per +//! display line. +//! @return n clamped to BIDI_MAX_CODEPOINTS +size_t bidi_resolve_paragraph(const Codepoint *cps, size_t n, int base_level, BidiScratch *ws); + +//! Apply the per-line rules (L1 trailing-whitespace reset, L2 reversal, L4 +//! mirroring) to one display line's codepoints and their paragraph-resolved +//! levels, writing the visual-order codepoints to out. +//! @param cps The line's codepoints (shaped; ligatures already folded) +//! @param levels The matching resolved level per codepoint +//! @param n Codepoint count (at most BIDI_MAX_CODEPOINTS) +//! @param out Destination for visual order (may not alias cps) +//! @param ws Scratch: uses orig (level working copy) and vis (order map) +size_t bidi_apply_line(const Codepoint *cps, const uint8_t *levels, size_t n, int base_level, + Codepoint *out, BidiScratch *ws); + +//! Mirror the paired punctuation that occurs in watch text: parentheses, +//! square and curly brackets, angle brackets and guillemets. Other codepoints +//! with the Unicode Bidi_Mirrored property pass through unchanged. +Codepoint bidi_mirror(Codepoint cp); diff --git a/src/fw/applib/graphics/rtl_support.c b/src/fw/applib/graphics/rtl_support.c deleted file mode 100644 index 5095d5c955..0000000000 --- a/src/fw/applib/graphics/rtl_support.c +++ /dev/null @@ -1,212 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Ahmed Hussein */ -/* SPDX-License-Identifier: Apache-2.0 */ - -#include "rtl_support.h" - -#include "applib/fonts/codepoint.h" -#include "utf8.h" - -#include - -// Maximum number of codepoints we can handle in a single reversal. -// 32 is sufficient for real Hebrew words including long morphological forms. -#define MAX_RTL_CODEPOINTS 32 - -bool utf8_contains_rtl(const utf8_t *start, const utf8_t *end) { - if (start == NULL || end == NULL || start >= end) { - return false; - } - - utf8_t *ptr = (utf8_t *)start; - while (ptr < end && *ptr != '\0') { - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint(ptr, &next); - if (cp == 0 || next == NULL) { - break; - } - if (codepoint_is_rtl(cp)) { - return true; - } - ptr = next; - } - - return false; -} - -//! Check if a codepoint is a shapeable Arabic letter (U+0621-U+064A) -static bool prv_codepoint_is_arabic_letter(Codepoint cp) { - // Arabic letters that require contextual shaping - // Excludes diacritics (U+064B-U+065F) and numerals (U+0660-U+0669) - return (cp >= 0x0621 && cp <= 0x064A); -} - -bool utf8_contains_arabic(const utf8_t *start, const utf8_t *end) { - if (start == NULL || end == NULL || start >= end) { - return false; - } - - utf8_t *ptr = (utf8_t *)start; - while (ptr < end && *ptr != '\0') { - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint(ptr, &next); - if (cp == 0 || next == NULL) { - break; - } - if (prv_codepoint_is_arabic_letter(cp)) { - return true; - } - ptr = next; - } - - return false; -} - -// Weak-LTR digits: Western (0x30-0x39, as used in Arabic, Hebrew and other RTL -// text) and Arabic-Indic (0x0660-0x0669, 0x06F0-0x06F9). Inside an RTL run -// these keep their left-to-right order; reversing them with the run would turn -// a number such as 2026 into 6202. -static bool prv_codepoint_is_digit(Codepoint cp) { - return (cp >= 0x30 && cp <= 0x39) || - (cp >= 0x0660 && cp <= 0x0669) || - (cp >= 0x06F0 && cp <= 0x06F9); -} - -// Separators that stay inside a numeric run when flanked by digits, so a time -// or date such as 12:34 or 2026/06/22 keeps its left-to-right group order. -static bool prv_codepoint_is_numeric_separator(Codepoint cp) { - return cp == ':' || cp == '/' || cp == '.' || cp == ','; -} - -utf8_t *rtl_segment_content_end(utf8_t *start, utf8_t *end) { - utf8_t *content_end = start; - utf8_t *scan = start; - while (scan < end) { - utf8_t *scan_next = NULL; - Codepoint scan_cp = utf8_peek_codepoint(scan, &scan_next); - if (scan_cp == 0 || scan_next == NULL) { - return end; - } - if (scan_cp != SPACE_CODEPOINT) { - content_end = scan_next; - } - scan = scan_next; - } - return content_end; -} - -size_t utf8_reverse_for_rtl(const utf8_t *src, size_t src_len, - utf8_t *dest, size_t dest_size) { - if (src == NULL || dest == NULL || src_len == 0 || dest_size == 0) { - return 0; - } - - // First pass: find the end of the bounded input we will reverse. - size_t num_codepoints = 0; - utf8_t *ptr = (utf8_t *)src; - const utf8_t *end = src + src_len; - - while (ptr < end && *ptr != '\0' && num_codepoints < MAX_RTL_CODEPOINTS) { - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint(ptr, &next); - if (cp == 0 || next == NULL) { - break; - } - ptr = next; - num_codepoints++; - } - - if (num_codepoints == 0) { - return 0; - } - - const utf8_t *reverse_ptr = ptr; - size_t dest_offset = 0; - - // Second pass: walk backward over UTF-8 sequence starts and write each - // codepoint to the destination. This avoids a stack array in the render path. - while (reverse_ptr > src) { - const utf8_t *cp_start = reverse_ptr - 1; - while (cp_start > src && ((*cp_start & 0xC0) == 0x80)) { - cp_start--; - } - - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint((utf8_t *)cp_start, &next); - if (cp == 0 || next == NULL || next > reverse_ptr) { - break; - } - - if (prv_codepoint_is_digit(cp)) { - // Weak-LTR: emit a contiguous digit run in logical (left-to-right) - // order instead of reversing it. - const utf8_t *run_start = cp_start; - while (run_start > src) { - const utf8_t *prev_start = run_start - 1; - while (prev_start > src && ((*prev_start & 0xC0) == 0x80)) { - prev_start--; - } - utf8_t *prev_next = NULL; - Codepoint prev_cp = utf8_peek_codepoint((utf8_t *)prev_start, &prev_next); - if (prev_cp == 0 || prev_next == NULL) { - break; - } - if (prv_codepoint_is_digit(prev_cp)) { - run_start = prev_start; - continue; - } - // A separator joins the run only between two digits. run_start already - // points at a digit (so the separator is followed by one); require a - // digit before it too, then pull both into the run in one step. - if (prv_codepoint_is_numeric_separator(prev_cp) && prev_start > src) { - const utf8_t *before = prev_start - 1; - while (before > src && ((*before & 0xC0) == 0x80)) { - before--; - } - utf8_t *before_next = NULL; - Codepoint before_cp = utf8_peek_codepoint((utf8_t *)before, &before_next); - if (before_cp != 0 && before_next != NULL && prv_codepoint_is_digit(before_cp)) { - run_start = before; - continue; - } - } - break; - } - - const utf8_t *fwd = run_start; - while (fwd < reverse_ptr) { - utf8_t *fwd_next = NULL; - Codepoint dcp = utf8_peek_codepoint((utf8_t *)fwd, &fwd_next); - if (dcp == 0 || fwd_next == NULL || dest_offset + 4 >= dest_size) { - break; - } - size_t n = utf8_encode_codepoint(dcp, dest + dest_offset); - if (n != 0) { - dest_offset += n; - } - fwd = fwd_next; - } - reverse_ptr = run_start; - continue; - } - - // Make sure we have room for at least 4 bytes + null terminator - if (dest_offset + 4 >= dest_size) { - break; - } - - size_t bytes_written = utf8_encode_codepoint(cp, dest + dest_offset); - if (bytes_written == 0) { - reverse_ptr = cp_start; - continue; - } - dest_offset += bytes_written; - reverse_ptr = cp_start; - } - - // Null-terminate if we have space - if (dest_offset < dest_size) { - dest[dest_offset] = '\0'; - } - - return dest_offset; -} diff --git a/src/fw/applib/graphics/rtl_support.h b/src/fw/applib/graphics/rtl_support.h deleted file mode 100644 index 91e83e234a..0000000000 --- a/src/fw/applib/graphics/rtl_support.h +++ /dev/null @@ -1,43 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Ahmed Hussein */ -/* SPDX-License-Identifier: Apache-2.0 */ - -#pragma once - -#include "utf8.h" - -#include -#include - -//! Check if a UTF-8 string range contains any RTL (right-to-left) characters. -//! This includes Arabic (U+0600-U+06FF) and Hebrew (U+0590-U+05FF) scripts. -//! @param start Pointer to start of UTF-8 string -//! @param end Pointer to end of UTF-8 string (exclusive) -//! @return true if the range contains at least one RTL character -bool utf8_contains_rtl(const utf8_t *start, const utf8_t *end); - -//! Check if a UTF-8 string range contains any shapeable Arabic letters. -//! This checks for Arabic letters in range U+0621-U+064A which require -//! contextual shaping (excludes diacritics and numerals). -//! @param start Pointer to start of UTF-8 string -//! @param end Pointer to end of UTF-8 string (exclusive) -//! @return true if the range contains at least one shapeable Arabic letter -bool utf8_contains_arabic(const utf8_t *start, const utf8_t *end); - -//! Reverse UTF-8 codepoints in a buffer for RTL display. -//! This performs a simple character-level reversal without complex text shaping. -//! @param src Source UTF-8 string -//! @param src_len Length of source string in bytes -//! @param dest Destination buffer for reversed string -//! @param dest_size Size of destination buffer in bytes -//! @return Number of bytes written to dest (excluding null terminator), or 0 on failure -size_t utf8_reverse_for_rtl(const utf8_t *src, size_t src_len, - utf8_t *dest, size_t dest_size); - -//! Find the end of a bidi segment's content, i.e. the position just past its -//! last non-space codepoint (the start of any trailing spaces), or `start` if -//! the range is all spaces. The line layout peels trailing spaces into their -//! own neutral segment so they reorder correctly between an RTL and an LTR run. -//! @param start Pointer to start of the segment range -//! @param end Pointer to end of the segment range (exclusive) -//! @return Pointer in [start, end] just past the last non-space codepoint -utf8_t *rtl_segment_content_end(utf8_t *start, utf8_t *end); diff --git a/src/fw/applib/graphics/text_layout.c b/src/fw/applib/graphics/text_layout.c index c2ce171ffe..e8f47c645f 100644 --- a/src/fw/applib/graphics/text_layout.c +++ b/src/fw/applib/graphics/text_layout.c @@ -15,10 +15,10 @@ #include "text_layout_private.h" #include "arabic_shaping.h" +#include "bidi.h" #include "graphics.h" #include "graphics_private.h" #include "gtypes.h" -#include "rtl_support.h" #include "text_render.h" #include "text_resources.h" #include "utf8.h" @@ -48,49 +48,84 @@ static bool prv_codepoint_is_invisible(Codepoint cp) { return codepoint_is_formatting_indicator(cp) || codepoint_should_skip(cp); } -//! Check if a codepoint is punctuation (should be ignored for RTL detection) -static bool prv_codepoint_is_punctuation(Codepoint cp) { - // ASCII punctuation - if ((cp >= 0x21 && cp <= 0x2F) || // ! " # $ % & ' ( ) * + , - . / - (cp >= 0x3A && cp <= 0x40) || // : ; < = > ? @ - (cp >= 0x5B && cp <= 0x60) || // [ \ ] ^ _ ` - (cp >= 0x7B && cp <= 0x7E)) { // { | } ~ - return true; +//! Last non-transparent codepoint in the raw bytes [from, to), or 0 if none. +//! The character iterator filters formatting codepoints (ZWNJ, directional +//! marks) out of the stream, but they still break Arabic joining, so any +//! shaping context that tracks the previous codepoint from the iterator must +//! check the raw gap it skipped. +static Codepoint prv_last_raw_cp_between(const utf8_t *from, const utf8_t *to) { + Codepoint found = 0; + utf8_t *q = (utf8_t *)from; + while (q != NULL && to != NULL && q < to && *q != '\0') { + utf8_t *qnext = NULL; + Codepoint qcp = utf8_peek_codepoint(q, &qnext); + if (qcp == 0 || qnext == NULL) break; + if (!arabic_is_transparent(qcp)) found = qcp; + q = qnext; + } + return found; +} + +//! Arabic joining context for text starting at pos: the last non-transparent +//! codepoint before it in the box, or 0 at the very start. A newline or any +//! non-Arabic codepoint breaks the join naturally, so this is safe across +//! paragraphs. Measurement, the bidi fit scan and the bidi line builder must +//! all use this same context, or a hyphenated intraword wrap measures a +//! different form (and width) than it draws - losing or doubling a glyph at +//! the wrap. +static Codepoint prv_joining_context_before(const TextBoxParams *const text_box_params, + const utf8_t *pos) { + Codepoint prev = 0; + if (text_box_params->utf8_bounds == NULL || pos == NULL) return 0; + utf8_t *q = (utf8_t *)text_box_params->utf8_bounds->start; + // A join never survives a newline, so start at the last one before pos. + for (utf8_t *r = (utf8_t *)pos; r > q; r--) { + if (r[-1] == '\n') { + q = r; + break; + } } - // General punctuation block (U+2000-U+206F) - includes dashes, quotes, etc. - if (cp >= 0x2000 && cp <= 0x206F) { - return true; + while (q != NULL && q < pos && *q != '\0') { + utf8_t *qnext = NULL; + Codepoint qcp = utf8_peek_codepoint(q, &qnext); + if (qcp == 0 || qnext == NULL) break; + if (!arabic_is_transparent(qcp)) prev = qcp; + q = qnext; } - return false; + return prev; } -//! Check if text starts with an RTL (right-to-left) character -//! Skips leading whitespace, newlines, and punctuation to find the first letter -static bool prv_utf8_starts_with_rtl(const utf8_t *start, const utf8_t *end) { - if (start == NULL || end == NULL || start >= end) { - return false; +//! Bounds of the paragraph containing pos: from just past the previous newline +//! (or the box start) to the next newline (or the box end). The bidi base +//! direction and the RTL gate are paragraph properties (UAX 9 P2/P3), so both +//! must look at exactly this range - not the whole text box. +//! The backward scan is per line, so a paragraph of length N costs O(N) per +//! wrapped line. Deliberate: watch text boxes are at most a few hundred bytes +//! and multi-paragraph text exits at the nearest newline, so caching paragraph +//! bounds across lines is not worth the state it would add. +static void prv_paragraph_bounds(const utf8_t *box_start, const utf8_t *box_end, + const utf8_t *pos, bool nl_as_space, + utf8_t **para_start, utf8_t **para_end) { + // In Fill mode a newline is rendered as a space, not a paragraph break, so + // the whole box is one paragraph for base-direction purposes. + if (nl_as_space) { + *para_start = (utf8_t *)box_start; + *para_end = (utf8_t *)box_end; + return; } - - utf8_t *ptr = (utf8_t *)start; - while (ptr < end && *ptr != '\0') { - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint(ptr, &next); - if (cp == 0 || next == NULL) { + utf8_t *start = (utf8_t *)box_start; + for (utf8_t *q = (utf8_t *)pos; q > (utf8_t *)box_start; q--) { + if (q[-1] == '\n') { + start = q; break; } - // Skip whitespace, newlines, punctuation, and invisible codepoints - if (cp == SPACE_CODEPOINT || cp == NEWLINE_CODEPOINT || - codepoint_is_zero_width(cp) || prv_codepoint_is_punctuation(cp) || - prv_codepoint_is_invisible(cp)) { - ptr = next; - continue; - } - // Found first letter character, check if RTL - return codepoint_is_rtl(cp); } - return false; + utf8_t *end = memchr(pos, '\n', (size_t)(box_end - pos)); + *para_start = start; + *para_end = (end != NULL) ? end : (utf8_t *)box_end; } + // PBL-23045 Eventually remove perimeter debugging void graphics_text_perimeter_debugging_enable(bool enable) { app_state_set_text_perimeter_debugging_enabled(enable); @@ -299,8 +334,10 @@ bool word_init(GContext* ctx, Word* word, const TextBoxParams* const text_box_pa word->width_px += prv_codepoint_get_horizontal_advance(&ctx->font_cache, text_box_params->font, curr_cp); } else { - Codepoint shape_next = - arabic_is_transparent(next_cp) ? prv_peek_next_letter(curr_pos, bounds_end) : next_cp; + // Raw peek, not the iterator's next: a filtered-out formatting + // codepoint (ZWNJ) between two letters must break the join here just + // as it does in the render fit scan and line builder. + Codepoint shape_next = prv_peek_next_letter(curr_pos, bounds_end); bool consumed_next = false; Codepoint width_cp = prv_shape_pair(prev_cp, curr_cp, shape_next, &consumed_next); word->width_px += prv_codepoint_get_horizontal_advance(&ctx->font_cache, @@ -312,6 +349,16 @@ bool word_init(GContext* ctx, Word* word, const TextBoxParams* const text_box_pa if (!arabic_is_transparent(curr_cp)) { prev_cp = curr_cp; } + { + // If the iterator skipped formatting codepoints, the last of them is + // the real joining predecessor. + utf8_t *after_curr = NULL; + utf8_peek_codepoint(curr_pos, &after_curr); + Codepoint gap_cp = prv_last_raw_cp_between(after_curr, utf8_iter_state->current); + if (gap_cp != 0) { + prev_cp = gap_cp; + } + } curr_cp = next_cp; state = word_state_update(state, curr_cp); } while (state != WordStateEnd); @@ -686,302 +733,335 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box return NULL; } - // RTL support: segment-based rendering for mixed RTL/LTR text - // Each RTL segment is reversed individually, LTR segments render normally - // For RTL paragraphs, segment order is reversed (BiDi line-level reordering) + // RTL support: lines whose paragraph contains RTL characters take the bidi + // path during the render pass (measurement shares the standard path below). bool is_rendering = (char_visitor_cb == render_chars_char_visitor_cb); - - // For segment-based RTL rendering during render pass + bool take_bidi_path = false; + utf8_t *para_start = NULL; + utf8_t *para_end = NULL; 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)) { - - // Segment descriptor for BiDi reordering - // Headroom for splitting boundary spaces into their own neutral segments. - #define MAX_BIDI_SEGMENTS 16 - typedef struct { - utf8_t *start; - utf8_t *end; - bool is_rtl; - } BiDiSegment; - - BiDiSegment segments[MAX_BIDI_SEGMENTS]; - int num_segments = 0; - - utf8_t *ptr = (utf8_t *)line->start; + text_box_params->utf8_bounds->end > line->start) { + // 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. + const bool nl_as_space = (text_box_params->overflow_mode == GTextOverflowModeFill); + prv_paragraph_bounds(text_box_params->utf8_bounds->start, text_box_params->utf8_bounds->end, + line->start, nl_as_space, ¶_start, ¶_end); + // A paragraph with strong RTL, or a digit-only paragraph that resolves RTL + // (Arabic-Indic numbers): the latter reorders to the identity, but a + // truncation suffix still has to land at the visual start. + take_bidi_path = bidi_contains_rtl(para_start, para_end) || + bidi_base_level_utf8(para_start, para_end) == 1; + } + if (take_bidi_path) { + const bool nl_as_space = (text_box_params->overflow_mode == GTextOverflowModeFill); + + // Render the line with the bidirectional algorithm: find the logical range + // that fits, Arabic-shape it, reorder to visual order and draw left to right. utf8_t *line_end = (utf8_t *)text_box_params->utf8_bounds->end; - int total_width_px = 0; - - // Pass 1: Collect all segments with their boundaries and directions - while (ptr < line_end && *ptr != '\0' && *ptr != '\n' && - total_width_px + suffix_width_px <= available_horiz_px && - num_segments < MAX_BIDI_SEGMENTS) { - - utf8_t *segment_start = ptr; - utf8_t *next = NULL; - Codepoint first_cp = utf8_peek_codepoint(ptr, &next); - if (first_cp == 0 || next == NULL) break; - - // Skip leading punctuation/spaces to determine segment type - bool segment_is_rtl = false; - utf8_t *check_ptr = ptr; - while (check_ptr < line_end && *check_ptr != '\0' && *check_ptr != '\n') { - utf8_t *check_next = NULL; - Codepoint check_cp = utf8_peek_codepoint(check_ptr, &check_next); - if (check_cp == 0 || check_next == NULL) break; - if (!prv_codepoint_is_punctuation(check_cp) && - check_cp != SPACE_CODEPOINT && !codepoint_is_zero_width(check_cp)) { - segment_is_rtl = codepoint_is_rtl(check_cp); - break; - } - check_ptr = check_next; - } - - // Collect segment (until we hit opposite script type or end) - utf8_t *segment_end = ptr; - int segment_width_px = 0; - // Track previous codepoint within the segment so Arabic letters are - // measured using their contextual presentation form. Without this, - // segment width here disagrees with the shaped width used by the - // layout (word_init) and by the actual draw pass below — letters at - // the line edge get truncated and a gap appears. - Codepoint prev_seg_cp = 0; - // prv_shape_pair() may combine this codepoint with the next into one - // glyph and report the next as consumed; its advance is then already - // counted, so skip it on the following iteration. - bool skip_ligature_member = false; - while (segment_end < line_end && *segment_end != '\0' && *segment_end != '\n') { - utf8_t *seg_next = NULL; - Codepoint seg_cp = utf8_peek_codepoint(segment_end, &seg_next); - if (seg_cp == 0 || seg_next == NULL) break; - - // Skip invisible codepoints (ZWJ, variation selectors, skin tone modifiers) - if (prv_codepoint_is_invisible(seg_cp)) { - segment_end = seg_next; - continue; - } - // Check if this character changes the segment type - if (!prv_codepoint_is_punctuation(seg_cp) && - seg_cp != SPACE_CODEPOINT && !codepoint_is_zero_width(seg_cp)) { - bool char_is_rtl = codepoint_is_rtl(seg_cp); - if (char_is_rtl != segment_is_rtl) { - break; // End of segment - } - } - - // Trailing spaces are kept in the run here and split out after the loop - // (see the trailing-space peel below) so they reorder between runs. - - if (skip_ligature_member && !arabic_is_transparent(seg_cp)) { - // Folded into the preceding pair: already counted. - skip_ligature_member = false; + // 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. Brackets are measured + // unmirrored; every shipped font gives a bracket and its mirror the same + // advance, so the mirrored draw matches the measured 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; + // Reserve one codepoint of reorder capacity for the suffix, which joins + // the sequence below so it lands on the correct visual side. + const size_t fit_cap = BIDI_MAX_CODEPOINTS - (line->suffix_codepoint ? 1 : 0); + size_t fit_cps = 0; + // Joining context shared with the standard measurement path and the line + // builder: an intraword wrap must measure the same (medial) form it draws. + Codepoint prev_cp = prv_joining_context_before(text_box_params, line->start); + 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' && (nl_as_space || *p != '\n') && fit_cps < fit_cap) { + utf8_t *pnext = NULL; + Codepoint cp = utf8_peek_codepoint(p, &pnext); + if (cp == 0 || pnext == NULL) { + break; + } + if (nl_as_space && cp == '\n') { + cp = ' '; // Fill mode: newline measures and reorders as a space. + } + fit_cps++; + if (prv_codepoint_is_invisible(cp)) { + // Formatting codepoints (ZWNJ, directional marks, ...) are not + // transparent to joining: the builder and the standard path make them + // the previous codepoint, breaking the join, so measurement must too. + prev_cp = cp; + last_cp_start = p; + p = pnext; + fit_end = p; + continue; + } + if (skip_ligature_member && !arabic_is_transparent(cp)) { + // Alef already counted in the preceding Lam-Alef ligature. + skip_ligature_member = false; + } else { + int glyph_width; + if (arabic_is_transparent(cp)) { + glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, + text_box_params->font, cp); } else { - Codepoint width_cp; - if (arabic_is_transparent(seg_cp)) { - // A mark keeps its own width but is not reshaped. - width_cp = seg_cp; - } else { - Codepoint next_seg_cp = prv_peek_next_letter(segment_end, line_end); - bool consumed_next = false; - width_cp = prv_shape_pair(prev_seg_cp, seg_cp, next_seg_cp, &consumed_next); - skip_ligature_member = consumed_next; - } - int glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, - text_box_params->font, width_cp); - if (total_width_px + segment_width_px + glyph_width + suffix_width_px > available_horiz_px) { - break; - } - segment_width_px += glyph_width; + Codepoint next_cp = prv_peek_next_letter(p, line_end); + Codepoint shaped_cp; + glyph_width = prv_shaped_glyph_advance(ctx, text_box_params, prev_cp, cp, next_cp, + &skip_ligature_member, &shaped_cp); } - - if (!arabic_is_transparent(seg_cp)) { - prev_seg_cp = seg_cp; + if (content_width_px + glyph_width + suffix_width_px > available_horiz_px) { + break; } - segment_end = seg_next; + content_width_px += glyph_width; } - size_t segment_len = segment_end - segment_start; - if (segment_len == 0) break; - - // Peel trailing spaces into their own neutral segment. A space between - // two runs is direction-neutral: if it stays inside a run it is reversed - // with that run and the segment reorder then carries it to the run's far - // edge, so the gap separating the two runs collapses. As its own segment - // it stays put between the runs it separates. - utf8_t *content_end = rtl_segment_content_end(segment_start, segment_end); - - if (content_end > segment_start && content_end < segment_end) { - // strong-direction content, then the trailing space(s) as a neutral - segments[num_segments++] = (BiDiSegment){ - .start = segment_start, .end = content_end, .is_rtl = segment_is_rtl, - }; - if (num_segments < MAX_BIDI_SEGMENTS) { - segments[num_segments++] = (BiDiSegment){ - .start = content_end, .end = segment_end, .is_rtl = false, - }; - } - } else { - segments[num_segments++] = (BiDiSegment){ - .start = segment_start, .end = segment_end, .is_rtl = segment_is_rtl, - }; + if (!arabic_is_transparent(cp)) { + prev_cp = cp; } - total_width_px += segment_width_px; - ptr = segment_end; + last_cp_start = p; + p = pnext; + fit_end = p; } - // Pass 2: Reorder segments for RTL paragraph direction - // When the line starts with RTL text, the visual order of segments must be - // reversed so the first logical segment appears on the right (reading start) - bool line_is_rtl = prv_utf8_starts_with_rtl(line->start, text_box_params->utf8_bounds->end); - if (line_is_rtl && num_segments > 1) { - for (int i = 0; i < num_segments / 2; i++) { - BiDiSegment temp = segments[i]; - segments[i] = segments[num_segments - 1 - i]; - segments[num_segments - 1 - i] = temp; - } + // If the codepoint cap stopped the scan before the width or the paragraph + // did, reordering would silently drop the tail. Fall through to the + // standard path instead: complete logical-order text beats truncated + // reordered text. (Reaching the cap inside one display line takes + // hundreds of codepoints - adversarial, not real notifications.) + const bool cap_truncated = + (fit_cps >= fit_cap) && p < line_end && *p != '\0' && (nl_as_space || *p != '\n'); + + // Trim trailing spaces like the standard path: they must not consume + // width or sit between the content and its suffix. In Fill mode a trailing + // newline is a space too. (last_cp_start may point at a trimmed space + // afterwards - the trimmed codepoints were visited, and the render-pass + // caller discards the return value.) + while (fit_end > line->start && + (fit_end[-1] == ' ' || (nl_as_space && fit_end[-1] == '\n'))) { + fit_end--; } - // Pass 3: Render segments in (possibly reordered) visual order + // UAX 9 Basic Display Algorithm: resolve the whole paragraph once, then + // apply the per-line rules (L1/L2/L4) to this display line's slice. Weak, + // neutral and bracket resolution therefore cross soft wraps exactly as + // they would in an unwrapped paragraph. On allocation failure, paragraph + // overflow or the codepoint cap, fall through to the standard path so the + // line degrades to complete logical-order rendering. (On privileged tasks + // applib_malloc() croaks on OOM rather than returning NULL, so that + // fallback only ever runs for third-party app tasks.) int walked_width_px = 0; - utf8_t *last_visited_char = NULL; - utf8_t rtl_buffer[128]; - const size_t rtl_buffer_size = sizeof(rtl_buffer); - - for (int seg_idx = 0; seg_idx < num_segments; seg_idx++) { - BiDiSegment *seg = &segments[seg_idx]; - size_t seg_len = seg->end - seg->start; - - if (seg->is_rtl) { - // Shape, reverse, render. Buffers sized to fit any single line on - // 200-260 px displays; shaping expands Arabic basic-block (2 UTF-8 - // bytes) to presentation forms (3 bytes). - size_t render_len = seg_len; - size_t reversed_len = 0; - - if (render_len > rtl_buffer_size - 4) { - render_len = rtl_buffer_size - 4; + bool bidi_rendered = false; + bool suffix_in_visual = false; + size_t fit_len = (size_t)(fit_end - line->start); + BidiScratch *bidi_ws = NULL; + if (!cap_truncated && fit_len > 0) { + bidi_ws = applib_malloc(sizeof(BidiScratch)); + } + if (bidi_ws) { + // Decode the paragraph, tracking where this line's slice begins and + // ends. In Fill mode a newline reads as a space. A paragraph beyond the + // resolver's capacity falls back to the standard path. + size_t para_n = 0; + size_t lo = 0, hi = 0; + bool para_overflow = false; + utf8_t *dp = para_start; + while (dp < para_end && *dp != '\0') { + if (dp == line->start) lo = para_n; + if (dp == fit_end) hi = para_n; + utf8_t *dnext = NULL; + Codepoint dcp = utf8_peek_codepoint(dp, &dnext); + if (dcp == 0 || dnext == NULL) break; + if (para_n >= BIDI_MAX_CODEPOINTS) { + // More paragraph remains: resolving a truncated prefix would give + // early lines wrong context (and mix strategies within one + // paragraph), so every line of an over-cap paragraph falls back. + para_overflow = true; + break; } - - if (utf8_contains_arabic(seg->start, seg->end)) { - utf8_t *shaped_buffer = applib_malloc(rtl_buffer_size); - if (shaped_buffer) { - size_t shaped_len = arabic_shape_text(seg->start, render_len, - shaped_buffer, rtl_buffer_size - 1); - if (shaped_len > 0) { - shaped_buffer[shaped_len] = '\0'; - reversed_len = utf8_reverse_for_rtl(shaped_buffer, shaped_len, - rtl_buffer, rtl_buffer_size - 1); - } - applib_free(shaped_buffer); + if (nl_as_space && dcp == '\n') dcp = ' '; + bidi_ws->cps[para_n++] = dcp; + dp = dnext; + } + if (dp == line->start) lo = para_n; + if (dp == fit_end) hi = para_n; + + if (!para_overflow && hi > lo && + (hi - lo) + (line->suffix_codepoint ? 1u : 0u) <= BIDI_MAX_CODEPOINTS) { + int base_level = bidi_base_level_utf8(para_start, para_end); + bidi_resolve_paragraph(bidi_ws->cps, para_n, base_level, bidi_ws); + + // Build this line's shaped codepoints with their paragraph-resolved + // levels, folding pairs exactly like the measurement pass: regional + // indicators to one flag, Lam-Alef to one ligature (a pair member + // beyond the line end shapes contextually but is not folded away). + // Joining context comes from the paragraph, not the slice, so Arabic + // forms stay correct across the wrap. Scratch reuse: paragraph state + // lives in cps/level; the folded line goes to visual (codepoints) and + // type (levels); apply writes visual order over cps, which is no + // longer needed by then. + Codepoint *const line_cps = bidi_ws->visual; + uint8_t *const line_lvl = bidi_ws->type; + size_t m = 0; + Codepoint prev_sh = 0; + for (size_t k = lo; k > 0; k--) { + if (!arabic_is_transparent(bidi_ws->cps[k - 1])) { + prev_sh = bidi_ws->cps[k - 1]; + break; } } - - if (reversed_len == 0) { - reversed_len = utf8_reverse_for_rtl(seg->start, render_len, - rtl_buffer, rtl_buffer_size - 1); - } - - if (reversed_len > 0) { - rtl_buffer[reversed_len] = '\0'; - - utf8_t *rptr = rtl_buffer; - while (*rptr != '\0') { - utf8_t *rnext = NULL; - Codepoint rcp = utf8_peek_codepoint(rptr, &rnext); - if (rcp == 0 || rnext == NULL) break; - if (prv_codepoint_is_invisible(rcp)) { rptr = rnext; continue; } - - int glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, - text_box_params->font, rcp); - - GRect cursor = { - .origin = line->origin, - .size.w = glyph_width, - .size.h = fonts_get_font_height(text_box_params->font) - }; - cursor.origin.x += walked_width_px; - - if (!codepoint_is_zero_width(rcp)) { - text_resources_get_glyph(&ctx->font_cache, rcp, text_box_params->font); - render_glyph(ctx, rcp, text_box_params->font, cursor); + size_t k = lo; + while (k < hi) { + Codepoint cur = bidi_ws->cps[k]; + Codepoint nxt = 0; + size_t nxt_idx = para_n; + for (size_t j = k + 1; j < para_n; j++) { + if (!arabic_is_transparent(bidi_ws->cps[j])) { + nxt = bidi_ws->cps[j]; + nxt_idx = j; + break; } - - walked_width_px += glyph_width; - rptr = rnext; } - } - } else { - // LTR segment: render folding pairs exactly like the pass-1 width - // measurement above, so both passes agree on what gets drawn. - utf8_t *sptr = seg->start; - Codepoint prev_seg_cp = 0; - bool skip_pair_member = false; - while (sptr < seg->end) { - utf8_t *snext = NULL; - Codepoint scp = utf8_peek_codepoint(sptr, &snext); - if (scp == 0 || snext == NULL) break; - if (prv_codepoint_is_invisible(scp)) { sptr = snext; continue; } - - if (skip_pair_member && !arabic_is_transparent(scp)) { - // Folded into the preceding pair: already drawn, adds no width. - skip_pair_member = false; + bool consumed = false; + Codepoint sh; + if (codepoint_is_regional_indicator(cur)) { + sh = FLAG_CODEPOINT; + consumed = (nxt_idx < hi) && codepoint_is_regional_indicator(nxt); + } else if (arabic_is_transparent(cur)) { + sh = cur; // a mark keeps its own glyph } else { - Codepoint draw_cp; - if (arabic_is_transparent(scp)) { - // A mark keeps its own width and glyph. - draw_cp = scp; - } else { - Codepoint next_seg_cp = prv_peek_next_letter(sptr, seg->end); - bool consumed_next = false; - draw_cp = prv_shape_pair(prev_seg_cp, scp, next_seg_cp, &consumed_next); - skip_pair_member = consumed_next; + sh = arabic_shape_pair(prev_sh, cur, nxt, &consumed); + if (consumed && nxt_idx >= hi) { + // Pair partner is on the next line: shape contextually, no fold. + consumed = false; + sh = arabic_shape_codepoint(prev_sh, cur, nxt); } + } + line_cps[m] = sh; + line_lvl[m] = bidi_ws->level[k]; + m++; + if (consumed) { + // Emit any marks sitting between the pair, then skip the partner. + // The joining predecessor for the next letter is the consumed + // partner (the Alef, which does not connect forward) - not the + // Lam - matching what the fitting pass and arabic_shape_text() + // see when they walk every codepoint. + for (size_t j = k + 1; j < nxt_idx; j++) { + line_cps[m] = bidi_ws->cps[j]; + line_lvl[m] = bidi_ws->level[j]; + m++; + } + prev_sh = nxt; + k = nxt_idx + 1; + } else { + if (!arabic_is_transparent(cur)) { + prev_sh = cur; + } + k++; + } + } + // The suffix is logically after the truncated content; at the base + // level it lands at the visual end of the reading direction (the left + // edge of an RTL line). + if (line->suffix_codepoint && m < BIDI_MAX_CODEPOINTS) { + line_cps[m] = line->suffix_codepoint; + line_lvl[m] = (uint8_t)base_level; + m++; + suffix_in_visual = true; + } + size_t n_vis = bidi_apply_line(line_cps, line_lvl, m, base_level, bidi_ws->cps, bidi_ws); + if (n_vis > 0) { + bidi_rendered = true; + // Pen position of the last full-advance glyph, for anchoring the + // zero-advance combining marks that follow it. + int base_x = 0; + int base_adv = 0; + for (size_t v = 0; v < n_vis; v++) { + Codepoint vcp = bidi_ws->cps[v]; + if (prv_codepoint_is_invisible(vcp)) { + continue; + } int glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, - text_box_params->font, draw_cp); - + text_box_params->font, vcp); GRect cursor = { .origin = line->origin, .size.w = glyph_width, - .size.h = fonts_get_font_height(text_box_params->font) + .size.h = fonts_get_font_height(text_box_params->font), }; cursor.origin.x += walked_width_px; - - if (!codepoint_is_zero_width(draw_cp)) { - text_resources_get_glyph(&ctx->font_cache, draw_cp, text_box_params->font); - render_glyph(ctx, draw_cp, text_box_params->font, cursor); + if (!codepoint_is_zero_width(vcp) && !codepoint_is_unicode_space(vcp)) { + const GlyphData *glyph = + text_resources_get_glyph(&ctx->font_cache, vcp, text_box_params->font); + if (glyph_width == 0 && glyph != NULL) { + // A zero-advance combining mark: anchor it on its base. The + // shipped fonts carry both pen conventions (mark drawn from + // the base's own pen, or from the pen after its advance), and + // bearing sign alone does not discriminate them - so place + // the mark at whichever pen lands more of its bitmap on the + // base's cell. + const int m_left = glyph->header.left_offset_px; + const int m_w = glyph->header.width_px; + int best_pen = base_x; + int best_overlap = -1; + const int pens[2] = { base_x, base_x + base_adv }; + for (int pi = 0; pi < 2; pi++) { + int lo = pens[pi] + m_left; + int hi = lo + m_w; + int o_lo = (lo > base_x) ? lo : base_x; + int o_hi = (hi < base_x + base_adv) ? hi : base_x + base_adv; + int overlap = (o_hi > o_lo) ? (o_hi - o_lo) : 0; + if (overlap > best_overlap) { + best_overlap = overlap; + best_pen = pens[pi]; + } + } + cursor.origin.x = line->origin.x + best_pen; + } + render_glyph(ctx, vcp, text_box_params->font, cursor); + } + if (glyph_width > 0) { + base_x = walked_width_px; + base_adv = glyph_width; } - walked_width_px += glyph_width; } - - if (!arabic_is_transparent(scp)) { - prev_seg_cp = scp; - } - sptr = snext; } } - - // Track last visited char (furthest position in original text) - if (last_visited_char == NULL || seg->start > last_visited_char) { - last_visited_char = seg->start; - } + applib_free(bidi_ws); + } else if (!cap_truncated && fit_len == 0) { + bidi_rendered = true; // nothing fits: nothing to draw beyond the suffix } + // Any other way here (cap, paragraph overflow, allocation failure) falls + // through to the standard logical-order path below. + + if (bidi_rendered) { + // Suffix not reordered with the content (empty line): draw it after. + if (line->suffix_codepoint && !suffix_in_visual) { + GRect cursor = { + .origin = line->origin, + .size.w = suffix_width_px, + .size.h = fonts_get_font_height(text_box_params->font), + }; + 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); + } - // Handle suffix if present - if (line->suffix_codepoint) { - GRect cursor = { - .origin = line->origin, - .size.w = suffix_width_px, - .size.h = fonts_get_font_height(text_box_params->font) - }; - 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); + // Per the function contract: the start of the last visited codepoint, + // or NULL when nothing fit on the line. + return last_cp_start; } - - return last_visited_char; + // Bidi render did not run: continue into the standard path (which draws + // the suffix itself). } // Standard rendering path (no RTL or not rendering) @@ -1004,7 +1084,9 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box (text_box_params->utf8_bounds != NULL) ? text_box_params->utf8_bounds->end : NULL; int walked_width_px = 0; - Codepoint prev_shaped_cp = 0; // previous codepoint, for Arabic joining context + // Arabic joining context from the text before this line, matching the bidi + // fit scan and line builder, so layout and render always agree on forms. + Codepoint prev_shaped_cp = prv_joining_context_before(text_box_params, line->start); bool skip_ligature_member = false; // current codepoint was folded into a preceding pair bool consumed_next = false; bool current_folded = false; // current codepoint was folded into the preceding pair @@ -1049,9 +1131,19 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box prev_shaped_cp = current_codepoint; } + utf8_t *pos_before_advance = utf8_iter_state->current; if (!iter_next(&char_iter)) { break; } + { + // Formatting codepoints the iterator skipped still break the join. + utf8_t *after_prev = NULL; + utf8_peek_codepoint(pos_before_advance, &after_prev); + Codepoint gap_cp = prv_last_raw_cp_between(after_prev, utf8_iter_state->current); + if (gap_cp != 0) { + prev_shaped_cp = gap_cp; + } + } current_codepoint = utf8_iter_state->codepoint; if (current_codepoint == NEWLINE_CODEPOINT) { @@ -1432,6 +1524,20 @@ bool line_add_word(GContext* ctx, Line* line, Word* word, const TextBoxParams* c PBL_ASSERTN(word->width_px >= truncated_word_length_px); word->width_px -= truncated_word_length_px; word->start = utf8_get_next(last_visited); + // Never start a continuation line on a filtered formatting codepoint + // (ZWNJ, directional marks): the iterator never yields them, but the + // standard measurement path takes a line's first codepoint raw and would + // price it as a visible wildcard glyph, splitting layout and render + // accounting. They remain in the raw text, so the joining-context scans + // still see them break the join. + while (word->start != NULL) { + utf8_t *skip_next = NULL; + Codepoint skip_cp = utf8_peek_codepoint(word->start, &skip_next); + if (skip_cp == 0 || skip_next == NULL || !prv_codepoint_is_invisible(skip_cp)) { + break; + } + word->start = skip_next; + } return false; } @@ -1456,9 +1562,20 @@ static void prv_line_justify(Line* line, const TextBoxParams* const text_box_par // Determine effective alignment - RTL text defaults to right alignment GTextAlignment effective_alignment = text_box_params->alignment; - // If alignment is left (default) and text starts with RTL, switch to right - if (effective_alignment == GTextAlignmentLeft && line->start != NULL) { - if (prv_utf8_starts_with_rtl(line->start, text_box_params->utf8_bounds->end)) { + // If alignment is left (default) and the paragraph base direction is RTL, + // switch to right. Scan the line's whole paragraph with the same base-level + // rule as the bidi render path, so every wrapped line of an RTL paragraph + // aligns the same way (including a digit-only Arabic-Indic paragraph, which + // the renderer also gates into the bidi path so a suffix reorders left). + if (effective_alignment == GTextAlignmentLeft && 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_t *para_start = NULL; + utf8_t *para_end = NULL; + const bool nl_as_space = (text_box_params->overflow_mode == GTextOverflowModeFill); + prv_paragraph_bounds(text_box_params->utf8_bounds->start, text_box_params->utf8_bounds->end, + line->start, nl_as_space, ¶_start, ¶_end); + if (bidi_base_level_utf8(para_start, para_end) == 1) { effective_alignment = GTextAlignmentRight; } } diff --git a/tests/fw/apps/system_apps/health/wscript_build b/tests/fw/apps/system_apps/health/wscript_build index 07a3735a26..0c58a4ab01 100644 --- a/tests/fw/apps/system_apps/health/wscript_build +++ b/tests/fw/apps/system_apps/health/wscript_build @@ -26,10 +26,10 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ " src/fw/applib/graphics/utf8.c" \ - " src/fw/applib/graphics/rtl_support.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/ui/kino/kino_reel.c" \ " src/fw/applib/ui/kino/kino_reel_gbitmap.c" \ diff --git a/tests/fw/apps/system_apps/launcher/wscript_build b/tests/fw/apps/system_apps/launcher/wscript_build index 4f7d95bd38..2210166f62 100644 --- a/tests/fw/apps/system_apps/launcher/wscript_build +++ b/tests/fw/apps/system_apps/launcher/wscript_build @@ -22,7 +22,7 @@ common_sources_ant_glob = ( " src/fw/applib/graphics/graphics_private_raw.c" " src/fw/applib/graphics/gtypes.c" " src/fw/applib/graphics/text_layout.c" - " src/fw/applib/graphics/rtl_support.c" + " src/fw/applib/graphics/bidi.c" " src/fw/applib/graphics/arabic_shaping.c" " src/fw/applib/graphics/text_render.c" " src/fw/applib/graphics/text_resources.c" diff --git a/tests/fw/apps/system_apps/music/wscript_build b/tests/fw/apps/system_apps/music/wscript_build index fc38fcc210..037a251e26 100644 --- a/tests/fw/apps/system_apps/music/wscript_build +++ b/tests/fw/apps/system_apps/music/wscript_build @@ -26,7 +26,7 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/apps/system_apps/timeline/wscript_build b/tests/fw/apps/system_apps/timeline/wscript_build index f27fad99c0..6332156e60 100644 --- a/tests/fw/apps/system_apps/timeline/wscript_build +++ b/tests/fw/apps/system_apps/timeline/wscript_build @@ -26,7 +26,7 @@ rendering_sources = ( "src/fw/applib/graphics/gtypes.c " "src/fw/applib/graphics/perimeter.c " "src/fw/applib/graphics/text_layout.c " - "src/fw/applib/graphics/rtl_support.c " + "src/fw/applib/graphics/bidi.c " "src/fw/applib/graphics/arabic_shaping.c " "src/fw/applib/graphics/text_render.c " "src/fw/applib/graphics/text_resources.c " diff --git a/tests/fw/apps/system_apps/weather/wscript_build b/tests/fw/apps/system_apps/weather/wscript_build index 5192fa091d..be2f53f818 100644 --- a/tests/fw/apps/system_apps/weather/wscript_build +++ b/tests/fw/apps/system_apps/weather/wscript_build @@ -26,7 +26,7 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/apps/system_apps/workout/wscript_build b/tests/fw/apps/system_apps/workout/wscript_build index 5e221ab5f5..8d89f9e075 100644 --- a/tests/fw/apps/system_apps/workout/wscript_build +++ b/tests/fw/apps/system_apps/workout/wscript_build @@ -26,7 +26,7 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/apps/watch/kickstart/wscript_build b/tests/fw/apps/watch/kickstart/wscript_build index 5f855733df..e2db3e8691 100644 --- a/tests/fw/apps/watch/kickstart/wscript_build +++ b/tests/fw/apps/watch/kickstart/wscript_build @@ -26,7 +26,7 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/graphics/wscript_build b/tests/fw/graphics/wscript_build index b55573a777..4010c54ed2 100644 --- a/tests/fw/graphics/wscript_build +++ b/tests/fw/graphics/wscript_build @@ -114,7 +114,7 @@ templated_graphics_draw_text_sources_ant_glob = \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/utf8.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/services/timeline/wscript_build b/tests/fw/services/timeline/wscript_build index 2c3c4ea6fa..f501b7906b 100644 --- a/tests/fw/services/timeline/wscript_build +++ b/tests/fw/services/timeline/wscript_build @@ -140,7 +140,7 @@ rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/test_arabic_shaping.c b/tests/fw/test_arabic_shaping.c index 1ec0723df8..db0cd5d022 100644 --- a/tests/fw/test_arabic_shaping.c +++ b/tests/fw/test_arabic_shaping.c @@ -20,7 +20,8 @@ // Shape `in`, decode the result into `cps`, return the codepoint count. static size_t prv_shape(const char *in, Codepoint *cps, size_t max) { utf8_t out[128]; - size_t len = arabic_shape_text((const utf8_t *)in, strlen(in), out, sizeof(out) - 1); + static Codepoint cp_scratch[MAX_SHAPE_CODEPOINTS]; + size_t len = arabic_shape_text((const utf8_t *)in, strlen(in), out, sizeof(out) - 1, cp_scratch); out[len] = '\0'; size_t count = 0; utf8_t *ptr = out; diff --git a/tests/fw/test_bidi.c b/tests/fw/test_bidi.c new file mode 100644 index 0000000000..833de006ff --- /dev/null +++ b/tests/fw/test_bidi.c @@ -0,0 +1,659 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "applib/graphics/bidi.h" + +#include "clar.h" + +#include + +/////////////////////////////////////////////////////////// +// Stubs + +#include "stubs_logging.h" +#include "stubs_passert.h" + +/////////////////////////////////////////////////////////// +// Helpers + +static BidiScratch s_ws; + +// Reorder logical codepoints to visual order at the given base level. +static size_t prv_visual(const Codepoint *in, size_t n, int base, Codepoint *out) { + return bidi_reorder_line(in, n, base, out, &s_ws); +} + +static void prv_assert_eq(const Codepoint *got, const Codepoint *want, size_t n) { + for (size_t i = 0; i < n; i++) { + cl_assert_equal_i(got[i], want[i]); + } +} + +static int prv_base_utf8(const char *s) { + return bidi_base_level_utf8((const utf8_t *)s, (const utf8_t *)s + strlen(s)); +} + +void test_bidi__initialize(void) {} +void test_bidi__cleanup(void) {} + +/////////////////////////////////////////////////////////// +// Tests + +// Base level comes from the first strong character. +void test_bidi__base_level(void) { + const Codepoint rtl[] = {0x0627, 'a'}; // Alef first -> RTL + const Codepoint ltr[] = {'a', 0x0627}; // 'a' first -> LTR + const Codepoint num[] = {'1', 0x0627}; // number is weak; Alef is first strong + cl_assert_equal_i(bidi_base_level(rtl, 2), 1); + cl_assert_equal_i(bidi_base_level(ltr, 2), 0); + cl_assert_equal_i(bidi_base_level(num, 2), 1); +} + +// UTF-8 base level: weak digits are skipped to the first strong character, and +// Arabic-Indic digits with no strong character at all still read RTL. +void test_bidi__base_level_utf8(void) { + cl_assert_equal_i(prv_base_utf8("123 \xD9\x85\xD8\xB1"), 1); // 123 Meem Reh -> RTL + cl_assert_equal_i(prv_base_utf8("abc \xD9\x85"), 0); // Latin first -> LTR + cl_assert_equal_i(prv_base_utf8("123"), 0); // Western digits -> LTR + cl_assert_equal_i(prv_base_utf8("\xD9\xA2\xD9\xA0\xD9\xA2\xD9\xA6"), 1); // ٢٠٢٦ -> RTL +} + +// Strong RTL detection spans the engine's full class table: Hebrew, Arabic, +// and Arabic presentation forms (pre-shaped text); Latin and digits do not +// count. This gates the bidi layout path. +void test_bidi__contains_rtl(void) { + const char heb[] = "hi \xD7\x90"; // Hebrew Alef after Latin + const char pres[] = "\xEF\xBA\x8D"; // U+FE8D, Alef final form + const char latin[] = "hello 123 (test)"; + cl_assert(bidi_contains_rtl((const utf8_t *)heb, (const utf8_t *)heb + strlen(heb))); + cl_assert(bidi_contains_rtl((const utf8_t *)pres, (const utf8_t *)pres + strlen(pres))); + cl_assert(!bidi_contains_rtl((const utf8_t *)latin, (const utf8_t *)latin + strlen(latin))); + cl_assert(!bidi_contains_rtl((const utf8_t *)latin, (const utf8_t *)latin)); +} + +// Hebrew is a strong RTL run like Arabic: it reverses to visual order. +void test_bidi__hebrew_strong_run(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x05D1, 0x05D2}; // Alef Bet Gimel + const Codepoint want[] = {0x05D2, 0x05D1, 0x05D0}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); + cl_assert_equal_i(bidi_base_level(in, 3), 1); +} + +// Pure Arabic reverses to visual order; pure Latin is untouched. +void test_bidi__strong_runs(void) { + Codepoint out[8]; + const Codepoint ar[] = {0x0627, 0x0628, 0x062C}; // Alef Beh Jeem + const Codepoint ar_want[] = {0x062C, 0x0628, 0x0627}; + prv_visual(ar, 3, 1, out); + prv_assert_eq(out, ar_want, 3); + + const Codepoint la[] = {'a', 'b', 'c'}; + const Codepoint la_want[] = {'a', 'b', 'c'}; + prv_visual(la, 3, 0, out); + prv_assert_eq(out, la_want, 3); +} + +// Western digits stay left-to-right inside an RTL run. +void test_bidi__western_digits_ltr(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, '1', '2', '3'}; // Alef 1 2 3 + const Codepoint want[] = {'1', '2', '3', 0x0627}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// Arabic-Indic digits stay left-to-right too: they are in the Arabic block, +// so the engine must type them AN, not strong R. +void test_bidi__arabic_indic_digits_ltr(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x0662, 0x0660}; // Alef ٢ ٠ + const Codepoint want[] = {0x0662, 0x0660, 0x0627}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// A time keeps its colon-separated groups in order inside RTL: ١٢:٣٤. +void test_bidi__time_digits_in_order(void) { + Codepoint out[12]; + // Alef SP ١ ٢ : ٣ ٤ + const Codepoint in[] = {0x0627, 0x20, 0x0661, 0x0662, ':', 0x0663, 0x0664}; + size_t n = prv_visual(in, 7, 1, out); + cl_assert_equal_i(n, 7); + // Collect the digit/colon subsequence in visual order; it must read ١٢:٣٤. + const Codepoint want_num[] = {0x0661, 0x0662, ':', 0x0663, 0x0664}; + size_t k = 0; + for (size_t i = 0; i < n; i++) { + if (out[i] == 0x0627 || out[i] == 0x20) continue; + cl_assert(k < 5); + cl_assert_equal_i(out[i], want_num[k++]); + } + cl_assert_equal_i(k, 5); +} + +// A date keeps its slash-separated groups in order (W4 joins a single CS +// between two Arabic numbers): ٢٠٢٦/٠٦/٢٢, not ٢٢/٠٦/٢٠٢٦. +void test_bidi__date_groups_in_order(void) { + Codepoint out[12]; + const Codepoint in[] = {0x0662, 0x0660, 0x0662, 0x0666, '/', + 0x0660, 0x0666, '/', 0x0662, 0x0662}; // ٢٠٢٦/٠٦/٢٢ + size_t n = prv_visual(in, 10, 1, out); + cl_assert_equal_i(n, 10); + prv_assert_eq(out, in, 10); +} + +// A separator only joins a number with a digit on both sides. Here "/" has a +// letter on one side, so it reorders as a neutral: ا/٢ -> visual ٢ / ا. +void test_bidi__separator_needs_two_digits(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, '/', 0x0662}; // Alef / ٢ + const Codepoint want[] = {0x0662, '/', 0x0627}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// Extended Arabic-Indic (Persian) digits are EN per the UCD; after an Arabic +// letter W2 turns them AN, so they stay in order like Arabic-Indic digits. +void test_bidi__persian_digits(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x06F1, 0x06F2}; // Alef ۱ ۲ + const Codepoint want[] = {0x06F1, 0x06F2, 0x0627}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); + // Digit-only Persian text still falls back to RTL for alignment. + cl_assert_equal_i(prv_base_utf8("\xDB\xB1\xDB\xB2"), 1); // ۱۲ +} + +// The Arabic comma is CS, not strong AL: in an LTR paragraph it must not drag +// the following digits into an RTL run. "abc، 123" stays in logical order. +void test_bidi__arabic_comma_is_weak(void) { + Codepoint out[10]; + const Codepoint in[] = {'a', 'b', 'c', 0x060C, 0x20, '1', '2', '3'}; + prv_visual(in, 8, 0, out); + prv_assert_eq(out, in, 8); +} + +// Directional marks are zero-width but strong: they set the base direction. +void test_bidi__directional_marks(void) { + const Codepoint rlm[] = {0x200F, '1'}; + const Codepoint lrm[] = {0x200E, 0x0627}; + cl_assert_equal_i(bidi_base_level(rlm, 2), 1); + cl_assert_equal_i(bidi_base_level(lrm, 2), 0); +} + +// NARROW NO-BREAK SPACE is CS: it joins two number groups (W4), so a Hebrew +// line keeps "12 34" ordered instead of swapping the groups. +void test_bidi__nnbsp_joins_numbers(void) { + Codepoint out[10]; + const Codepoint in[] = {0x05D0, 0x20, '1', '2', 0x202F, '3', '4'}; + const Codepoint want[] = {'1', '2', 0x202F, '3', '4', 0x20, 0x05D0}; + prv_visual(in, 7, 1, out); + prv_assert_eq(out, want, 7); +} + +// Boundary neutrals are transparent to the weak rules (UAX 9 5.2): a ZWNJ +// inside "1.11" must not stop W4 from joining the number, and must not split +// the number's level run. +void test_bidi__zwnj_transparent_in_number(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, '1', '.', 0x200C, '1', '1'}; + const Codepoint want[] = {'1', '.', 0x200C, '1', '1', 0x05D0}; + prv_visual(in, 6, 1, out); + prv_assert_eq(out, want, 6); +} + +// A ZWNJ between two spaces must not split the neutral run: the spaces around +// it take the direction of the surrounding strong text, so the whole RTL +// island reverses as one block even at an LTR base. +void test_bidi__zwnj_between_neutrals(void) { + Codepoint out[8]; + const Codepoint in[] = {'a', 0x05D0, 0x20, 0x200C, 0x20, 0x05D1, 'b'}; + const Codepoint want[] = {'a', 0x05D1, 0x20, 0x200C, 0x20, 0x05D0, 'b'}; + prv_visual(in, 7, 0, out); + prv_assert_eq(out, want, 7); +} + +// N0 propagates a resolved bracket type to immediately following NSMs: the +// fathatan after ")" takes the brackets' L, so the whole "(b)ً" island stays +// together (Unicode BidiCharacterTest bracket+NSM cases). +void test_bidi__n0_nsm_propagation(void) { + Codepoint out[10]; + const Codepoint in[] = {0x0627, 0x20, 'a', '(', 'b', ')', 0x064B}; + const Codepoint want[] = {'a', '(', 'b', ')', 0x064B, 0x20, 0x0627}; + prv_visual(in, 7, 1, out); + prv_assert_eq(out, want, 7); +} + +// A wrapped line resolves its boundary neutrals with the strong context of +// the adjacent paragraph text (sos/eos hints), not the base direction: in a +// base-L paragraph "a XA, XB" wrapped after the comma, the comma joins the +// Hebrew run exactly as it does in the unwrapped paragraph. +void test_bidi__softwrap_boundary_context(void) { + static BidiScratch ws; + Codepoint out[8]; + const Codepoint slice[] = {'a', 0x20, 0x05D0, ','}; + const Codepoint want[] = {'a', 0x20, ',', 0x05D0}; + size_t n = bidi_reorder_line_ctx(slice, 4, 0, BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, + BIDI_BOUNDARY_R, out, &ws); + cl_assert_equal_i(n, 4); + prv_assert_eq(out, want, 4); +} + +// The micro sign is a Latin-1 letter (L), not a neutral: it joins the Latin +// island instead of travelling with the Hebrew run. +void test_bidi__micro_sign_is_letter(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0x00B5, 0x20, 'a', 'b', 'c'}; + const Codepoint want[] = {0x00B5, 0x20, 'a', 'b', 'c', 0x20, 0x05D0}; + prv_visual(in, 7, 1, out); + prv_assert_eq(out, want, 7); +} + +// CJK double angle brackets pair and keep an enclosed Latin island together +// in an RTL paragraph (RLM base): "a 《b.1》" keeps its logical order. +void test_bidi__cjk_double_angle_pairs(void) { + Codepoint out[10]; + const Codepoint in[] = {0x200F, 'a', 0x20, 0x300A, 'b', '.', '1', 0x300B}; + const Codepoint want[] = {'a', 0x20, 0x300A, 'b', '.', '1', 0x300B, 0x200F}; + prv_visual(in, 8, 1, out); + prv_assert_eq(out, want, 8); +} + +// Fullwidth digits are EN (shipped in the CJK notification fonts): after an +// RLM they keep their order instead of reversing as strong text. +void test_bidi__fullwidth_digits_en(void) { + Codepoint out[8]; + const Codepoint in[] = {0x200F, 0xFF11, 0xFF12}; // RLM 1 2 + const Codepoint want[] = {0xFF11, 0xFF12, 0x200F}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// Corner brackets pair and mirror like the other CJK brackets. +void test_bidi__corner_brackets_pair(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x300C, 0x0628, 0x300D}; // Alef 「 Beh 」 + const Codepoint want[] = {0x300C, 0x0628, 0x300D, 0x0627}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// Mirror-only codepoints outside the bracket pairs: <= flips to >= on an odd +// level, and the multiplication sign stays a neutral (not a Latin letter). +void test_bidi__le_mirrors_and_times_neutral(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x2264, 0x0628}; // Alef <= Beh + const Codepoint want[] = {0x0628, 0x2265, 0x0627}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); + + // x is ON between two ANs: per UAX the neutral takes the run direction and + // the whole expression reverses (٣x٤ displays ٤x٣), unlike a letter would. + const Codepoint mul[] = {0x0663, 0x00D7, 0x0664}; + const Codepoint mul_want[] = {0x0664, 0x00D7, 0x0663}; + prv_visual(mul, 3, 1, out); + prv_assert_eq(out, mul_want, 3); +} + +// The eos scanner weighs numbers as N-rule context: a digit right after the +// wrap makes the slice-final comma join the Hebrew run, as in the paragraph. +void test_bidi__softwrap_number_context(void) { + static BidiScratch ws; + Codepoint out[8]; + const Codepoint slice[] = {'a', 0x20, 0x05D0, ','}; + const Codepoint want[] = {'a', 0x20, ',', 0x05D0}; + const char after[] = " 1"; + int eos = bidi_first_strong_utf8((const utf8_t *)after, (const utf8_t *)after + 2, + BIDI_BOUNDARY_R); + cl_assert_equal_i(eos, BIDI_BOUNDARY_R); + size_t n = bidi_reorder_line_ctx(slice, 4, 0, BIDI_BOUNDARY_AUTO, BIDI_BOUNDARY_AUTO, + eos, out, &ws); + cl_assert_equal_i(n, 4); + prv_assert_eq(out, want, 4); +} + +// A continuation line uses its OWN last strong type as the context for a +// digit after the wrap - not a staler strong from earlier in the paragraph. +// Para "a XA, 1" wrapped as [a]/[XA ,]/[1]: line 2's comma joins the Hebrew +// run because the digit after the wrap resolves R against the line's Hebrew. +void test_bidi__continuation_line_context(void) { + static BidiScratch ws; + Codepoint out[4]; + const Codepoint slice[] = {0x05D0, ','}; + const Codepoint want[] = {',', 0x05D0}; + size_t n = bidi_reorder_line_ctx(slice, 2, 0, BIDI_BOUNDARY_L, BIDI_BOUNDARY_R, + BIDI_BOUNDARY_R, out, &ws); + cl_assert_equal_i(n, 2); + prv_assert_eq(out, want, 2); +} + +// The weak-rule and neutral-rule boundary contexts can differ: after +// "a .. AN |", the wrap's W7 context is the strong L but the adjacent AN +// supplies R to the neutral run, so "[, XA]" keeps the comma with the Hebrew. +void test_bidi__split_sos_contexts(void) { + static BidiScratch ws; + Codepoint out[4]; + const Codepoint slice[] = {',', 0x05D0}; + const Codepoint want[] = {0x05D0, ','}; + size_t n = bidi_reorder_line_ctx(slice, 2, 0, BIDI_BOUNDARY_L, BIDI_BOUNDARY_R, + BIDI_BOUNDARY_AUTO, out, &ws); + cl_assert_equal_i(n, 2); + prv_assert_eq(out, want, 2); +} + +// Fullwidth plus/comma join fullwidth digits (ES/CS like their ASCII forms): +// a CJK expression after Hebrew keeps its order. +void test_bidi__fullwidth_expression(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0xFF11, 0xFF0B, 0xFF12}; // XA SP 1+2 + const Codepoint want[] = {0xFF11, 0xFF0B, 0xFF12, 0x20, 0x05D0}; + prv_visual(in, 5, 1, out); + prv_assert_eq(out, want, 5); +} + +// The neutral-boundary scan is resolver-backed: a leading EN in a base-L +// paragraph resolves L (W7), so the wrapped continuation keeps its order. +void test_bidi__boundary_en_resolves_by_base(void) { + static BidiScratch ws; + const char prefix[] = "1 "; + int ndir = bidi_boundary_ndir_utf8((const utf8_t *)prefix, (const utf8_t *)prefix + 2, 0, &ws); + cl_assert_equal_i(ndir, BIDI_BOUNDARY_L); + Codepoint out[6]; + const Codepoint slice[] = {'!', 0x0661, 'a', 0x05D0}; + size_t n = bidi_reorder_line_ctx(slice, 4, 0, BIDI_BOUNDARY_AUTO, ndir, BIDI_BOUNDARY_AUTO, + out, &ws); + cl_assert_equal_i(n, 4); + prv_assert_eq(out, slice, 4); +} + +// A raw scan cannot see N0: "a(XA)" resolves its brackets L, so the boundary +// after the closing bracket is L even though a strong R sits inside the pair. +// The wrapped "!XB" line then keeps the bang on the left. +void test_bidi__boundary_sees_bracket_resolution(void) { + static BidiScratch ws; + const char prefix[] = "a(\xD7\x90)"; // a ( Hebrew-Alef ) + int ndir = bidi_boundary_ndir_utf8((const utf8_t *)prefix, (const utf8_t *)prefix + 5, 0, &ws); + cl_assert_equal_i(ndir, BIDI_BOUNDARY_L); + Codepoint out[4]; + const Codepoint slice[] = {'!', 0x05D1}; + size_t n = bidi_reorder_line_ctx(slice, 2, 0, BIDI_BOUNDARY_R, ndir, BIDI_BOUNDARY_AUTO, + out, &ws); + cl_assert_equal_i(n, 2); + prv_assert_eq(out, slice, 2); +} + +// Paragraph-wide resolution, per-line application (UAX 9 Basic Display +// Algorithm): the four soft-wrap cases that no scalar boundary hint could +// represent all resolve as in the unwrapped paragraph. + +// Wrap space + punctuation are one neutral run between two strong Rs: the +// second line renders reversed even though its own slice starts neutral. +void test_bidi__para_wrap_neutral_run(void) { + static BidiScratch ws; + Codepoint out[8]; + const Codepoint para[] = {'a', 0x05D0, 0x20, '!', 0x05D1}; + bidi_resolve_paragraph(para, 5, 0, &ws); + const Codepoint want2[] = {0x05D1, '!'}; + size_t n = bidi_apply_line(&ws.cps[3], &ws.level[3], 2, 0, out, &ws); + cl_assert_equal_i(n, 2); + prv_assert_eq(out, want2, 2); +} + +// A long digit run keeps the strong context from before the resolver window +// because the whole paragraph resolves at once (98 codepoints here). +void test_bidi__para_long_digit_run(void) { + static BidiScratch ws; + static Codepoint para[98]; + static Codepoint out[96]; + para[0] = 'a'; + para[1] = 0x05D0; + for (int i = 0; i < 96; i++) para[2 + i] = '1'; + bidi_resolve_paragraph(para, 98, 0, &ws); + size_t n = bidi_apply_line(&ws.cps[2], &ws.level[2], 96, 0, out, &ws); + cl_assert_equal_i(n, 96); + for (int i = 0; i < 96; i++) cl_assert_equal_i(out[i], '1'); +} + +// The bracket pair after the wrap resolves to L (it encloses an L with L +// before it), so the first line's trailing "!" stays put - a raw scan of the +// suffix would have seen the enclosed R instead. +void test_bidi__para_bracket_after_wrap(void) { + static BidiScratch ws; + Codepoint out[8]; + const Codepoint para[] = {'a', 0x05D0, '!', 0x20, '(', 0x05D1, 'c', ')'}; + bidi_resolve_paragraph(para, 8, 0, &ws); + const Codepoint want1[] = {'a', 0x05D0, '!'}; + size_t n = bidi_apply_line(&ws.cps[0], &ws.level[0], 3, 0, out, &ws); + cl_assert_equal_i(n, 3); + prv_assert_eq(out, want1, 3); +} + +// W4 joins a comma between two digits even when the wrap splits the number: +// both halves keep logical order because the levels were resolved before the +// split. +void test_bidi__para_w4_across_wrap(void) { + static BidiScratch ws; + Codepoint out[8]; + const Codepoint para[] = {'a', '1', ',', '2', 0x05D0}; + bidi_resolve_paragraph(para, 5, 0, &ws); + const Codepoint want1[] = {'a', '1', ','}; + size_t n = bidi_apply_line(&ws.cps[0], &ws.level[0], 3, 0, out, &ws); + cl_assert_equal_i(n, 3); + prv_assert_eq(out, want1, 3); + const Codepoint want2[] = {'2', 0x05D0}; + n = bidi_apply_line(&ws.cps[3], &ws.level[3], 2, 0, out, &ws); + cl_assert_equal_i(n, 2); + prv_assert_eq(out, want2, 2); +} + +// Shipped number signs are ET: W5 absorbs the per-mille sign into the +// following number, so the sign stays with its digits in an RTL line. +void test_bidi__per_mille_stays_with_number(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0x2030, '1'}; + const Codepoint want[] = {0x2030, '1', 0x20, 0x05D0}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// Non-bracket mirrored pairs shipped in the CJK fonts: a fullwidth < after +// Hebrew mirrors to fullwidth > on its odd level. +void test_bidi__fullwidth_angle_mirrors(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0xFF1C, 0x05D1}; + const Codepoint want[] = {0x05D1, 0xFF1E, 0x20, 0x05D0}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// Parenthesized ideograph markers are L: the CJK list marker stays with its +// following LTR run instead of moving to the far side. +void test_bidi__parenthesized_ideograph_l(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0x3220, 'a'}; + const Codepoint want[] = {0x3220, 'a', 0x20, 0x05D0}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// The keycap combining mark is NSM: it stays attached to its digit inside an +// RTL line instead of splitting the group as a neutral. +void test_bidi__keycap_mark_attaches(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, '1', 0x20E3}; + const Codepoint want[] = {'1', 0x20E3, 0x0627}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// Cyrillic combining marks are NSM, not letters: after a Hebrew letter the +// titlo inherits R (W1) and travels with it instead of joining the Latin run. +// L3 re-reverses the marks+base cluster, so the base leads and the mark +// follows - matching the reference order, and placing a zero-advance mark +// with negative bearing over its base at draw time. +void test_bidi__cyrillic_combining_nsm(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x0483, 'a'}; + const Codepoint want[] = {'a', 0x05D0, 0x0483}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// L3 looks through a retained ZWNJ between mark and base: the fatha re-joins +// its Beh across the boundary neutral instead of attaching to the next glyph. +void test_bidi__l3_through_boundary_neutral(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0628, 0x200C, 0x064E, 0x062C}; // Beh ZWNJ fatha Jeem + const Codepoint want[] = {0x062C, 0x0628, 0x200C, 0x064E}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// L3 with Arabic harakat: a fatha on the middle letter of a reversed word +// follows its base in the visual sequence. +void test_bidi__harakat_follow_base(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0628, 0x064E, 0x062C}; // Beh fatha Jeem + const Codepoint want[] = {0x062C, 0x0628, 0x064E}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// The Cyrillic thousands sign is L per the UCD (not a neutral): it stays with +// the following Latin run. +void test_bidi__cyrillic_thousands_l(void) { + Codepoint out[8]; + const Codepoint in[] = {0x05D0, 0x20, 0x0482, 'a'}; + const Codepoint want[] = {0x0482, 'a', 0x20, 0x05D0}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// BD16 pairs brackets by canonical equivalence: the deprecated angle bracket +// pairs with the CJK closer, and vice versa, in either mixed combination. +void test_bidi__canonical_bracket_equivalence(void) { + Codepoint out[10]; + const Codepoint mixed1[] = {0x200F, 'a', 0x20, 0x2329, 'b', '.', '1', 0x3009}; + const Codepoint want1[] = {'a', 0x20, 0x2329, 'b', '.', '1', 0x3009, 0x200F}; + prv_visual(mixed1, 8, 1, out); + prv_assert_eq(out, want1, 8); + const Codepoint mixed2[] = {0x200F, 'a', 0x20, 0x3008, 'b', '.', '1', 0x232A}; + const Codepoint want2[] = {'a', 0x20, 0x3008, 'b', '.', '1', 0x232A, 0x200F}; + prv_visual(mixed2, 8, 1, out); + prv_assert_eq(out, want2, 8); +} + +// Nested brackets resolve outer-before-inner (BD16), not on closing order. +// Base LTR: "a(( 1)b)" - the outer pair sees the trailing 'b' (L=e) and +// resolves to L, and the inner pair then resolves to L from that context, so +// the space keeps its place rather than flipping around the digit. +void test_bidi__nested_brackets_bd16(void) { + Codepoint out[12]; + // a AR ( ( SP 1 ) b ) + const Codepoint in[] = {'a', 0x0627, '(', '(', ' ', '1', ')', 'b', ')'}; + const Codepoint want[] = {'a', 0x0627, '(', '(', ' ', '1', ')', 'b', ')'}; + prv_visual(in, 9, 0, out); + prv_assert_eq(out, want, 9); +} + +// The Arabic percent sign is ET (like '%'), not a strong Arabic letter: in an +// LTR line it does not drag the following number into an RTL run. +void test_bidi__arabic_percent_is_et(void) { + Codepoint out[10]; + const Codepoint in[] = {'a', 'b', 'c', ' ', 0x066A, ' ', '1', '2'}; // abc ٪ 12 + prv_visual(in, 8, 0, out); + prv_assert_eq(out, in, 8); +} + +// CJK angle brackets are paired brackets: they pair (N0) and mirror (L4) like +// ASCII brackets. Around Arabic in an RTL line, "ا〈ب〉" reverses and the +// angles mirror so they keep wrapping the enclosed word. +void test_bidi__cjk_angle_brackets(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x3008, 0x0628, 0x3009}; // Alef 〈 Beh 〉 + const Codepoint want[] = {0x3008, 0x0628, 0x3009, 0x0627}; // 〈 Beh 〉 Alef, mirrored + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// Arabic-block signs that the UCD classes ON (roots, verse marks) are neutral, +// not strong letters: in an LTR line they do not start an RTL run. +void test_bidi__arabic_on_signs(void) { + Codepoint out[8]; + const Codepoint in[] = {'a', 'b', 0x060E, 'c', 'd'}; // U+060E poetic verse sign + prv_visual(in, 5, 0, out); + prv_assert_eq(out, in, 5); +} + +// A digit-only RTL line with a trailing suffix: the digits keep their order +// but the ellipsis moves to the visual left (reading end), which the standard +// renderer could not do - this is why the digit-only case enters the bidi path. +void test_bidi__digit_only_suffix_rtl(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0661, 0x0662, 0x2026}; // ١ ٢ … + const Codepoint want[] = {0x2026, 0x0661, 0x0662}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// A regional-indicator pair is strong L (UCD): it stays adjacent and in order +// through an RTL reorder, so the draw pass can fold it into one flag after. +void test_bidi__ri_pair_survives_rtl(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x20, 0x1F1FA, 0x1F1F8}; // Alef SP RI-U RI-S + const Codepoint want[] = {0x1F1FA, 0x1F1F8, 0x20, 0x0627}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// A literal flag emoji is a neutral symbol (UCD ON), not a strong character: +// it does not decide the base direction and travels with the surrounding run. +void test_bidi__literal_flag_is_neutral(void) { + const Codepoint in[] = {0x1F3F3, 0x0627}; // white flag, Alef + cl_assert_equal_i(bidi_base_level(in, 2), 1); + Codepoint out[4]; + const Codepoint run[] = {0x0627, 0x1F3F3, 0x0628}; // Alef flag Beh + const Codepoint want[] = {0x0628, 0x1F3F3, 0x0627}; // reversed with the run + prv_visual(run, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// A trailing neutral (ellipsis/hyphen suffix) on an RTL line lands at the +// visual left edge - the reading-order end - not the right. +void test_bidi__trailing_neutral_rtl(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0646, 0x0635, 0x2026}; // Noon Sad … + const Codepoint want[] = {0x2026, 0x0635, 0x0646}; + prv_visual(in, 3, 1, out); + prv_assert_eq(out, want, 3); +} + +// Brackets on a purely-RTL line: mirror and wrap the word. "(نص)" -> '(' ص ن ')'. +void test_bidi__brackets_rtl(void) { + Codepoint out[8]; + const Codepoint in[] = {'(', 0x0646, 0x0635, ')'}; // ( Noon Sad ) + const Codepoint want[] = {'(', 0x0635, 0x0646, ')'}; + prv_visual(in, 4, 1, out); + prv_assert_eq(out, want, 4); +} + +// The Codex case: an LTR island in RTL keeps its parentheses wrapping it. +// "ا (en)" -> visual "(en) ا". +void test_bidi__ltr_island_in_rtl(void) { + Codepoint out[8]; + const Codepoint in[] = {0x0627, 0x20, '(', 'e', 'n', ')'}; + const Codepoint want[] = {'(', 'e', 'n', ')', 0x20, 0x0627}; + size_t n = prv_visual(in, 6, 1, out); + cl_assert_equal_i(n, 6); + prv_assert_eq(out, want, 6); +} + +// An RTL island in LTR: parentheses are NOT mirrored (LTR context). "ab (ج)". +void test_bidi__rtl_island_in_ltr(void) { + Codepoint out[8]; + const Codepoint in[] = {'a', 'b', 0x20, '(', 0x062C, ')'}; + const Codepoint want[] = {'a', 'b', 0x20, '(', 0x062C, ')'}; + prv_visual(in, 6, 0, out); + prv_assert_eq(out, want, 6); +} diff --git a/tests/fw/test_line_layout.c b/tests/fw/test_line_layout.c index a4589dc508..cb021f66df 100644 --- a/tests/fw/test_line_layout.c +++ b/tests/fw/test_line_layout.c @@ -498,3 +498,40 @@ void test_line_layout__test_walk_lines_down(void) { } } + +// A hyphen-split continuation must not start on a filtered formatting +// codepoint: ZWNJ between letters stays in the raw text (it breaks the join) +// but the next line begins on the letter after it. Before the fix, the +// continuation pointer stayed on the ZWNJ, the standard path priced it as a +// visible wildcard, and the final letter of the word was consumed but never +// drawn. Word: Beh ZWNJ Beh Beh Beh in a two-advance box. +void test_line_layout__continuation_skips_formatting_codepoint(void) { + Iterator word_iter = ITERATOR_EMPTY; + WordIterState word_iter_state = WORD_ITER_STATE_EMPTY; + Line line = { 0 }; + + bool success = false; + const Utf8Bounds utf8_bounds = + utf8_get_bounds(&success, "\xD8\xA8\xE2\x80\x8C\xD8\xA8\xD8\xA8\xD8\xA8"); + cl_assert(success); + + const TextBoxParams text_box_params = (TextBoxParams) { + .utf8_bounds = &utf8_bounds, + .box = (GRect) { GPointZero, (GSize) { 2 * HORIZ_ADVANCE_PX + 1, 22 } } + }; + line.max_width_px = text_box_params.box.size.w; + line.height_px = text_box_params.box.size.h; + + word_iter_init(&word_iter, &word_iter_state, &s_ctx, &text_box_params, utf8_bounds.start); + + // First display line: hyphen split; the continuation starts on the second + // Beh (byte offset 5), past the ZWNJ at offset 2. + line_reset(&line, (utf8_t *)utf8_bounds.start); + cl_assert(!line_add_word(&s_ctx, &line, &word_iter_state.current, &text_box_params)); + cl_assert_equal_i((int)(word_iter_state.current.start - utf8_bounds.start), 5); + + // Second display line: the split advances again, to the third Beh. + line_reset(&line, word_iter_state.current.start); + cl_assert(!line_add_word(&s_ctx, &line, &word_iter_state.current, &text_box_params)); + cl_assert_equal_i((int)(word_iter_state.current.start - utf8_bounds.start), 7); +} diff --git a/tests/fw/test_rtl_support.c b/tests/fw/test_rtl_support.c deleted file mode 100644 index a4e36f7f21..0000000000 --- a/tests/fw/test_rtl_support.c +++ /dev/null @@ -1,153 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ -/* SPDX-License-Identifier: Apache-2.0 */ - -#include "applib/graphics/rtl_support.h" -#include "applib/graphics/utf8.h" - -#include "clar.h" - -#include - -/////////////////////////////////////////////////////////// -// Stubs - -#include "stubs_logging.h" -#include "stubs_passert.h" - -/////////////////////////////////////////////////////////// -// Helpers - -// Reverse `in` for RTL, decode the result into `cps`, return codepoint count. -static size_t prv_reverse(const char *in, Codepoint *cps, size_t max) { - utf8_t out[128]; - size_t len = utf8_reverse_for_rtl((const utf8_t *)in, strlen(in), out, sizeof(out) - 1); - out[len] = '\0'; - size_t count = 0; - utf8_t *ptr = out; - while (*ptr != '\0' && count < max) { - utf8_t *next = NULL; - Codepoint cp = utf8_peek_codepoint(ptr, &next); - if (cp == 0 || next == NULL) { - break; - } - cps[count++] = cp; - ptr = next; - } - return count; -} - -void test_rtl_support__initialize(void) {} -void test_rtl_support__cleanup(void) {} - -/////////////////////////////////////////////////////////// -// Tests - -// Pure Arabic letters reverse to visual order: "ابج" -> ج ب ا. -void test_rtl_support__reverses_letters(void) { - Codepoint cps[8]; - size_t n = prv_reverse("\xD8\xA7\xD8\xA8\xD8\xAC", cps, 8); // Alef Beh Jeem - cl_assert_equal_i(n, 3); - cl_assert_equal_i(cps[0], 0x062C); // Jeem - cl_assert_equal_i(cps[1], 0x0628); // Beh - cl_assert_equal_i(cps[2], 0x0627); // Alef -} - -// Arabic-Indic digit runs keep left-to-right order (weak-LTR), not mirrored. -void test_rtl_support__arabic_indic_digits_preserved(void) { - Codepoint cps[8]; - size_t n = prv_reverse("\xD9\xA2\xD9\xA0\xD9\xA2\xD9\xA6", cps, 8); // ٢٠٢٦ - cl_assert_equal_i(n, 4); - cl_assert_equal_i(cps[0], 0x0662); // ٢ - cl_assert_equal_i(cps[1], 0x0660); // ٠ - cl_assert_equal_i(cps[2], 0x0662); // ٢ - cl_assert_equal_i(cps[3], 0x0666); // ٦ -} - -// Western digits are weak-LTR too. -void test_rtl_support__western_digits_preserved(void) { - Codepoint cps[8]; - size_t n = prv_reverse("123", cps, 8); - cl_assert_equal_i(n, 3); - cl_assert_equal_i(cps[0], '1'); - cl_assert_equal_i(cps[1], '2'); - cl_assert_equal_i(cps[2], '3'); -} - -// A digit run embedded in Arabic: letters reverse, the number stays in order. -// "ا٢٣" -> the digit run ٢٣ is emitted first (it ends up left of the letter), -// in logical order, then the Alef. -void test_rtl_support__digits_in_arabic(void) { - Codepoint cps[8]; - size_t n = prv_reverse("\xD8\xA7\xD9\xA2\xD9\xA3", cps, 8); // Alef ٢ ٣ - cl_assert_equal_i(n, 3); - cl_assert_equal_i(cps[0], 0x0662); // ٢ - cl_assert_equal_i(cps[1], 0x0663); // ٣ - cl_assert_equal_i(cps[2], 0x0627); // Alef -} - -// A date keeps its slash-separated groups in order: the separators travel with -// the numeric run rather than reversing the groups (٢٠٢٦/٠٦/٢٢, not ٢٢/٠٦/٢٠٢٦). -void test_rtl_support__date_separators_preserved(void) { - Codepoint cps[16]; - size_t n = prv_reverse("\xD9\xA2\xD9\xA0\xD9\xA2\xD9\xA6/\xD9\xA0\xD9\xA6/" - "\xD9\xA2\xD9\xA2", cps, 16); // ٢٠٢٦/٠٦/٢٢ - cl_assert_equal_i(n, 10); - const Codepoint expect[] = {0x0662, 0x0660, 0x0662, 0x0666, '/', - 0x0660, 0x0666, '/', 0x0662, 0x0662}; - for (size_t i = 0; i < n; i++) { - cl_assert_equal_i(cps[i], expect[i]); - } -} - -// A time keeps its colon-separated groups in order (١٢:٣٤, not ٣٤:١٢). -void test_rtl_support__time_separators_preserved(void) { - Codepoint cps[8]; - size_t n = prv_reverse("\xD9\xA1\xD9\xA2:\xD9\xA3\xD9\xA4", cps, 8); // ١٢:٣٤ - cl_assert_equal_i(n, 5); - const Codepoint expect[] = {0x0661, 0x0662, ':', 0x0663, 0x0664}; - for (size_t i = 0; i < n; i++) { - cl_assert_equal_i(cps[i], expect[i]); - } -} - -// A separator only joins the run between two digits. Here "/" has a letter on -// one side, so it is not part of the number and reverses normally: ا/٢ -> ٢ / ا. -void test_rtl_support__separator_needs_two_digits(void) { - Codepoint cps[8]; - size_t n = prv_reverse("\xD8\xA7/\xD9\xA2", cps, 8); // Alef / ٢ - cl_assert_equal_i(n, 3); - cl_assert_equal_i(cps[0], 0x0662); // ٢ - cl_assert_equal_i(cps[1], '/'); - cl_assert_equal_i(cps[2], 0x0627); // Alef -} - -// rtl_segment_content_end: byte offset of the trailing-space boundary. -static size_t prv_content_len(const char *str) { - utf8_t *start = (utf8_t *)str; - utf8_t *end = start + strlen(str); - return (size_t)(rtl_segment_content_end(start, end) - start); -} - -void test_rtl_support__content_end_no_trailing_space(void) { - cl_assert_equal_i(prv_content_len("abc"), 3); -} - -void test_rtl_support__content_end_single_trailing_space(void) { - cl_assert_equal_i(prv_content_len("abc "), 3); -} - -// Trailing spaces peel; interior spaces stay with content. -void test_rtl_support__content_end_interior_vs_trailing(void) { - cl_assert_equal_i(prv_content_len("ab cd "), 5); // boundary after 'd' -} - -// All-space run has no content -> boundary is the start (segment kept whole). -void test_rtl_support__content_end_all_spaces(void) { - cl_assert_equal_i(prv_content_len(" "), 0); - cl_assert_equal_i(prv_content_len(""), 0); -} - -// Boundary lands on a codepoint start, not mid-sequence. "بر " = Beh Reh SP. -void test_rtl_support__content_end_multibyte(void) { - cl_assert_equal_i(prv_content_len("\xD8\xA8\xD8\xB1 "), 4); // two 2-byte cps, then space -} diff --git a/tests/fw/ui/wscript_build b/tests/fw/ui/wscript_build index b16734b67b..76d738d3a0 100644 --- a/tests/fw/ui/wscript_build +++ b/tests/fw/ui/wscript_build @@ -40,7 +40,7 @@ clar(ctx, " src/fw/applib/graphics/graphics_circle.c" " src/fw/applib/graphics/graphics_line.c" " src/fw/applib/graphics/text_layout.c" - " src/fw/applib/graphics/rtl_support.c" + " src/fw/applib/graphics/bidi.c" " src/fw/applib/graphics/arabic_shaping.c" " src/fw/applib/graphics/utf8.c" " src/fw/applib/graphics/text_render.c" @@ -196,7 +196,7 @@ menu_layer_system_cell_rendering_sources = \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/perimeter.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/text_render.c" \ " src/fw/applib/graphics/text_resources.c" \ diff --git a/tests/fw/wscript_build b/tests/fw/wscript_build index 072c2edc6a..418e474d49 100644 --- a/tests/fw/wscript_build +++ b/tests/fw/wscript_build @@ -42,23 +42,22 @@ clar(ctx, sources_ant_glob = "src/fw/applib/fonts/codepoint.c", test_sources_ant_glob = "test_codepoint.c") -clar(ctx, - sources_ant_glob = "src/fw/applib/graphics/rtl_support.c" \ - " src/fw/applib/graphics/utf8.c" \ - " src/fw/applib/fonts/codepoint.c", - test_sources_ant_glob = "test_rtl_support.c") - clar(ctx, sources_ant_glob = "src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/graphics/utf8.c", test_sources_ant_glob = "test_arabic_shaping.c") +clar(ctx, + sources_ant_glob = "src/fw/applib/graphics/bidi.c" \ + " src/fw/applib/graphics/utf8.c", + test_sources_ant_glob = "test_bidi.c") + clar(ctx, sources_ant_glob = "src/fw/applib/graphics/utf8.c" \ " src/fw/applib/graphics/framebuffer.c" \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/fonts/codepoint.c" \ " tests/fakes/fake_gbitmap_png.c", @@ -69,7 +68,7 @@ clar(ctx, " src/fw/applib/graphics/framebuffer.c" \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/fonts/codepoint.c" \ " tests/fakes/fake_gbitmap_png.c", @@ -88,7 +87,7 @@ clar(ctx, " src/fw/applib/graphics/framebuffer.c" \ " src/fw/applib/graphics/gtypes.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " src/fw/applib/fonts/codepoint.c" \ " tests/fakes/fake_gbitmap_png.c", @@ -308,7 +307,7 @@ templated_sources_ant_glob = \ " src/fw/applib/graphics/bitblt.c" \ " src/fw/applib/graphics/{depth_dir}/bitblt_private.c" \ " src/fw/applib/graphics/text_layout.c" \ - " src/fw/applib/graphics/rtl_support.c" \ + " src/fw/applib/graphics/bidi.c" \ " src/fw/applib/graphics/arabic_shaping.c" \ " tests/fakes/fake_gbitmap_png.c" \ " src/fw/applib/fonts/codepoint.c" \