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" \