diff --git a/src/fw/applib/graphics/bidi.c b/src/fw/applib/graphics/bidi.c new file mode 100644 index 0000000000..618df2febf --- /dev/null +++ b/src/fw/applib/graphics/bidi.c @@ -0,0 +1,668 @@ +/* SPDX-FileCopyrightText: 2026 Ahmed Hussein */ +/* SPDX-FileCopyrightText: 2026 Khalid Nuaim (kaluaim) */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "bidi.h" + +#include "arabic_shaping.h" +#include "utf8.h" + +#include "applib/fonts/codepoint.h" +#include "pbl/util/size.h" + +#include + +// Bidirectional character classes, coarsened from UAX 9 to the ones that +// change the outcome for a single line of text. +typedef enum { + BidiClassL, // Strong left-to-right + BidiClassR, // Strong right-to-left + BidiClassEN, // European number + BidiClassAN, // Arabic-Indic number + BidiClassES, // European separator, binds two European numbers + BidiClassCS, // Common separator, binds two numbers of the same class + BidiClassET, // European terminator, binds to an adjacent European number + BidiClassNSM, // Non-spacing mark, inherits the class of its base + BidiClassB, // Paragraph separator, ends the range direction is resolved over + BidiClassON, // Other neutral +} BidiClass; + +// Mirrored pairs from the Unicode BidiMirroring table, limited to the ones +// that turn up in watch text. +typedef struct { + uint16_t first; + uint16_t second; +} BidiMirrorPair; + +static const BidiMirrorPair s_mirror_pairs[] = { + { 0x0028, 0x0029 }, // Parentheses + { 0x003C, 0x003E }, // Less-than, greater-than + { 0x005B, 0x005D }, // Square brackets + { 0x007B, 0x007D }, // Curly brackets + { 0x00AB, 0x00BB }, // Double angle quotation marks + { 0x2039, 0x203A }, // Single angle quotation marks + { 0x2045, 0x2046 }, // Square brackets with quill + { 0x207D, 0x207E }, // Superscript parentheses + { 0x208D, 0x208E }, // Subscript parentheses + { 0x2264, 0x2265 }, // Less-than or equal, greater-than or equal +}; + +#define MAX_MIRRORED_CODEPOINT 0x2265 + +static BidiClass prv_ascii_class(Codepoint cp) { + if (cp >= '0' && cp <= '9') { + return BidiClassEN; + } + if ((cp >= 'A' && cp <= 'Z') || (cp >= 'a' && cp <= 'z')) { + return BidiClassL; + } + switch (cp) { + case '\n': + case '\r': + return BidiClassB; + case '#': + case '$': + case '%': + return BidiClassET; + case '+': + case '-': + return BidiClassES; + case ',': + case '.': + case '/': + case ':': + return BidiClassCS; + default: + return BidiClassON; + } +} + +//! Non-spacing marks across every block this engine can meet. A mark must never +//! resolve on its own: W1 gives it the class of its base, and the reversal keeps +//! it behind that base. Kept in one place so a new block cannot be half-covered. +static bool prv_is_combining_mark(Codepoint cp) { + return (cp >= 0x0300 && cp <= 0x036F) || // Combining diacritical marks + // Hebrew points and cantillation, minus the punctuation sharing the range + (cp >= 0x0591 && cp <= 0x05C7 && cp != 0x05BE && cp != 0x05C0 && + cp != 0x05C3 && cp != 0x05C6) || + (cp >= 0x0610 && cp <= 0x061A) || // Arabic honorifics + (cp >= 0x064B && cp <= 0x065F) || // Arabic harakat + (cp == 0x0670) || + (cp >= 0x06D6 && cp <= 0x06DC) || (cp >= 0x06DF && cp <= 0x06E4) || + (cp >= 0x06E7 && cp <= 0x06E8) || (cp >= 0x06EA && cp <= 0x06ED) || + (cp >= 0x0730 && cp <= 0x074A) || // Syriac points + (cp >= 0x07A6 && cp <= 0x07B0) || // Thaana vowel signs + (cp >= 0x07EB && cp <= 0x07F3) || (cp == 0x07FD) || // NKo marks + (cp >= 0x0898 && cp <= 0x089F) || // Arabic Extended-B marks + (cp >= 0x08CA && cp <= 0x08E1) || (cp >= 0x08E3 && cp <= 0x08FF) || + (cp >= 0x1AB0 && cp <= 0x1AFF) || // Combining marks extended + (cp >= 0x1DC0 && cp <= 0x1DFF) || // Combining marks supplement + (cp >= 0x20D0 && cp <= 0x20FF) || // Combining marks for symbols + (cp >= 0xFE00 && cp <= 0xFE0F) || // Variation selectors + (cp >= 0xFE20 && cp <= 0xFE2F); // Combining half marks +} + +static BidiClass prv_class(Codepoint cp) { + if (cp < 0x0080) { + return prv_ascii_class(cp); + } + if (prv_is_combining_mark(cp)) { + return BidiClassNSM; + } + if (cp >= 0x0590 && cp <= 0x05FF) { // Hebrew + return BidiClassR; + } + if (cp >= 0x0600 && cp <= 0x06FF) { // Arabic + if ((cp >= 0x0600 && cp <= 0x0605) || (cp >= 0x0660 && cp <= 0x0669) || + cp == 0x066B || cp == 0x066C || cp == 0x06DD) { + return BidiClassAN; + } + if (cp >= 0x06F0 && cp <= 0x06F9) { // Extended Arabic-Indic digits + return BidiClassEN; + } + if (cp == 0x060C) { // Arabic comma + return BidiClassCS; + } + if (cp == 0x0609 || cp == 0x060A || cp == 0x066A) { // Per mille, per ten thousand, percent + return BidiClassET; + } + return BidiClassR; + } + if (cp >= 0x0700 && cp <= 0x08FF) { // Syriac, Thaana, NKo, Arabic Extended-A/B + // These blocks carry Arabic number signs among their letters. + if (cp == 0x0890 || cp == 0x0891 || cp == 0x08E2) { + return BidiClassAN; + } + return BidiClassR; + } + if ((cp >= 0xFB1D && cp <= 0xFDFF) || (cp >= 0xFE70 && cp <= 0xFEFC)) { + return BidiClassR; // Hebrew and Arabic presentation forms + } + switch (cp) { + case 0x00A0: // No-break space + return BidiClassCS; + case 0x00B0: // Degree sign + case 0x00B1: // Plus-minus sign + case 0x20AC: // Euro sign + return BidiClassET; + case 0x2212: // Minus sign + return BidiClassES; + case 0x200E: // Left-to-right mark + return BidiClassL; + case 0x200F: // Right-to-left mark + return BidiClassR; + default: + break; + } + if (cp >= 0x00A1 && cp <= 0x00BF) { // Latin-1 punctuation and symbols + return BidiClassON; + } + if (cp >= 0x2000 && cp <= 0x2BFF) { // Punctuation, symbols, arrows, math + return BidiClassON; + } + if (codepoint_is_emoji(cp) || codepoint_is_regional_indicator(cp)) { + return BidiClassON; + } + return BidiClassL; +} + +//! Decode the codepoint at @p pos and return its class, setting @p next to the +//! following codepoint. @p next is NULL when nothing could be decoded. +static BidiClass prv_class_at(const utf8_t *pos, const utf8_t *end, utf8_t **next) { + *next = NULL; + if (pos == NULL || pos >= end || *pos == '\0') { + return BidiClassON; + } + + Codepoint cp = utf8_peek_codepoint((utf8_t *)pos, next); + if (cp == 0 || *next == NULL || *next > end) { + *next = NULL; + return BidiClassON; + } + return prv_class(cp); +} + +//! Direction a class contributes when a neutral looks at it (N1). Numbers act +//! as right-to-left for this purpose even though they are laid out the other way. +static bool prv_side_is_rtl(BidiClass cls) { + return (cls == BidiClassR) || (cls == BidiClassEN) || (cls == BidiClassAN); +} + +static bool prv_class_has_side(BidiClass cls) { + return (cls == BidiClassL) || prv_side_is_rtl(cls); +} + +//! W7: a European number takes the direction of the strong character before it. +//! Returns true when that character is left-to-right, so the number stops acting +//! as right-to-left towards the neutrals around it. +static bool prv_number_follows_ltr(const utf8_t *line_start, const utf8_t *pos, + const utf8_t *end, bool para_is_rtl) { + utf8_t *cur = (utf8_t *)pos; + while (cur > line_start) { + cur = utf8_get_previous((utf8_t *)line_start, cur); + if (cur == NULL) { + break; + } + utf8_t *next = NULL; + BidiClass cls = prv_class_at(cur, end, &next); + if (next == NULL) { + break; + } + if (cls == BidiClassL) { + return true; + } + if ((cls == BidiClassR) || (cls == BidiClassB)) { + return false; + } + } + // Nothing strong precedes it, so the paragraph direction decides. + return !para_is_rtl; +} + +//! Direction the class at @p pos contributes to a neighbouring neutral, with W7 +//! applied to European numbers. +static bool prv_resolved_side_is_rtl(const utf8_t *line_start, const utf8_t *pos, + const utf8_t *end, bool para_is_rtl, BidiClass cls) { + if (cls == BidiClassEN) { + return !prv_number_follows_ltr(line_start, pos, end, para_is_rtl); + } + return prv_side_is_rtl(cls); +} + +//! Direction of the last strong class or number before @p pos. +static bool prv_prev_side(const utf8_t *line_start, const utf8_t *pos, + const utf8_t *end, bool para_is_rtl, bool *is_rtl) { + utf8_t *cur = (utf8_t *)pos; + while (cur > line_start) { + cur = utf8_get_previous((utf8_t *)line_start, cur); + if (cur == NULL) { + break; + } + utf8_t *next = NULL; + BidiClass cls = prv_class_at(cur, end, &next); + if ((next == NULL) || (cls == BidiClassB)) { + break; + } + if (prv_class_has_side(cls)) { + *is_rtl = prv_resolved_side_is_rtl(line_start, cur, end, para_is_rtl, cls); + return true; + } + } + return false; +} + +//! Direction of the first strong class or number at or after @p pos. +static bool prv_next_side(const utf8_t *line_start, const utf8_t *pos, + const utf8_t *end, bool para_is_rtl, bool *is_rtl) { + utf8_t *cur = (utf8_t *)pos; + while (cur < end && *cur != '\0') { + utf8_t *next = NULL; + BidiClass cls = prv_class_at(cur, end, &next); + if ((next == NULL) || (cls == BidiClassB)) { + break; + } + if (prv_class_has_side(cls)) { + *is_rtl = prv_resolved_side_is_rtl(line_start, cur, end, para_is_rtl, cls); + return true; + } + cur = next; + } + return false; +} + +static utf8_t *prv_skip_terminators(utf8_t *pos, const utf8_t *end) { + utf8_t *cur = pos; + while (cur < end && *cur != '\0') { + utf8_t *next = NULL; + if (prv_class_at(cur, end, &next) != BidiClassET || next == NULL) { + break; + } + cur = next; + } + return cur; +} + +//! Consume a number along with the separators and terminators that bind to it +//! (UAX 9 W4-W6). @p pos must point at a number of class @p num_cls. +static utf8_t *prv_scan_number(utf8_t *pos, const utf8_t *end, BidiClass num_cls) { + utf8_t *cur = pos; + while (cur < end && *cur != '\0') { + utf8_t *next = NULL; + BidiClass cls = prv_class_at(cur, end, &next); + if (next == NULL) { + break; + } + if (cls == num_cls || cls == BidiClassNSM) { + cur = next; + continue; + } + // W4: a separator surrounded by numbers of the same class joins them. + if ((cls == BidiClassCS) || (cls == BidiClassES && num_cls == BidiClassEN)) { + utf8_t *after = NULL; + if (prv_class_at(next, end, &after) == num_cls && after != NULL) { + cur = after; + continue; + } + break; + } + // W5: terminators next to a European number join it. + if (cls == BidiClassET && num_cls == BidiClassEN) { + cur = next; + continue; + } + break; + } + return cur; +} + +//! Class a non-spacing mark inherits (UAX 9 W1). A mark takes the class of the +//! character it follows, or Other Neutral when nothing precedes it. +static BidiClass prv_inherited_class(const utf8_t *line_start, const utf8_t *pos, + const utf8_t *end) { + utf8_t *cur = (utf8_t *)pos; + while (cur > line_start) { + cur = utf8_get_previous((utf8_t *)line_start, cur); + if (cur == NULL) { + break; + } + utf8_t *next = NULL; + BidiClass cls = prv_class_at(cur, end, &next); + if (next == NULL) { + break; + } + if (cls != BidiClassNSM) { + return cls; + } + } + return BidiClassON; +} + +//! Embedding level for a resolved direction (UAX 9 I1/I2). Right-to-left is +//! always level 1 here; left-to-right sits at 0 in an LTR paragraph and at 2 +//! when it is embedded inside an RTL one. +static uint8_t prv_level_for_dir(bool is_rtl, bool para_is_rtl) { + if (is_rtl) { + return 1; + } + return para_is_rtl ? 2 : 0; +} + +//! Resolve the direction of the span starting at @p pos and report where it +//! ends. A span is one strong character, a number with its weak neighbours, or +//! a stretch of neutrals resolved together. +static bool prv_resolve_span(const utf8_t *line_start, utf8_t *pos, const utf8_t *end, + bool para_is_rtl, uint8_t *span_level, utf8_t **span_end) { + utf8_t *next = NULL; + BidiClass cls = prv_class_at(pos, end, &next); + if (next == NULL) { + return false; + } + + // W1: a mark joins whatever it follows, so it never splits off on its own. + if (cls == BidiClassNSM) { + cls = prv_inherited_class(line_start, pos, end); + } + + switch (cls) { + case BidiClassL: + *span_level = prv_level_for_dir(false, para_is_rtl); + *span_end = next; + return true; + case BidiClassR: + *span_level = 1; + *span_end = next; + return true; + case BidiClassAN: + // Arabic numbers sit one level inside their surroundings either way. + *span_level = 2; + *span_end = prv_scan_number(pos, end, cls); + return true; + case BidiClassEN: + // W7 already turned a number after Latin text into L, so it belongs at + // the paragraph's own level rather than nested inside an RTL region. + *span_level = prv_number_follows_ltr(line_start, pos, end, para_is_rtl) ? + prv_level_for_dir(false, para_is_rtl) : 2; + *span_end = prv_scan_number(pos, end, cls); + return true; + case BidiClassB: + // A paragraph separator stands on its own at the paragraph direction. + *span_level = prv_level_for_dir(para_is_rtl, para_is_rtl); + *span_end = next; + return true; + default: + break; + } + + // A terminator run directly ahead of a European number belongs to it (W5). + if (cls == BidiClassET) { + utf8_t *number = prv_skip_terminators(pos, end); + utf8_t *after = NULL; + if (prv_class_at(number, end, &after) == BidiClassEN && after != NULL) { + *span_level = prv_number_follows_ltr(line_start, number, end, para_is_rtl) ? + prv_level_for_dir(false, para_is_rtl) : 2; + *span_end = prv_scan_number(number, end, BidiClassEN); + return true; + } + } + + // Neutral stretch, up to the next strong character or number. + utf8_t *stretch_end = pos; + while (stretch_end < end && *stretch_end != '\0') { + utf8_t *stretch_next = NULL; + BidiClass stretch_cls = prv_class_at(stretch_end, end, &stretch_next); + if (stretch_next == NULL || prv_class_has_side(stretch_cls) || + (stretch_cls == BidiClassB)) { + break; + } + if (stretch_cls == BidiClassET) { + utf8_t *number = prv_skip_terminators(stretch_end, end); + utf8_t *after = NULL; + if (prv_class_at(number, end, &after) == BidiClassEN && after != NULL) { + break; + } + } + stretch_end = stretch_next; + } + if (stretch_end == pos) { + stretch_end = next; + } + + // N1: neutrals between two runs of the same direction take that direction. + // N2: otherwise they take the paragraph direction. + bool before_is_rtl = false; + bool after_is_rtl = false; + const bool has_before = prv_prev_side(line_start, pos, end, para_is_rtl, &before_is_rtl); + const bool has_after = prv_next_side(line_start, stretch_end, end, para_is_rtl, &after_is_rtl); + + const bool resolved_is_rtl = + (has_before && has_after && (before_is_rtl == after_is_rtl)) ? before_is_rtl : para_is_rtl; + *span_level = prv_level_for_dir(resolved_is_rtl, para_is_rtl); + *span_end = stretch_end; + return true; +} + +bool bidi_is_needed(const utf8_t *start, const utf8_t *end) { + if (start == NULL || end == NULL || start >= end) { + return false; + } + + // Raw byte scan over the lead bytes of every block prv_class() calls + // strong right-to-left, so the gate and the class table agree. Continuation + // bytes are 0x80-0xBF and never match a lead tested here, so scanning byte by + // byte is safe and pure-ASCII text costs one comparison per byte. + for (const utf8_t *ptr = start; ptr < end && *ptr != '\0'; ptr++) { + const utf8_t lead = *ptr; + const utf8_t next = ((ptr + 1) < end) ? ptr[1] : 0; + + if (lead == 0xD6) { + // U+0590 upwards is Hebrew; Armenian shares this lead byte below it. + if (next >= 0x90) { + return true; + } + continue; + } + if (lead >= 0xD7 && lead <= 0xDF) { + return true; // U+05C0-U+07FF: Hebrew, Arabic, Syriac, Thaana, NKo + } + if ((lead == 0xE0) && (next >= 0xA0) && (next <= 0xA3)) { + return true; // U+0800-U+08FF: Samaritan, Mandaic, Arabic Extended-A/B + } + if ((lead == 0xEF) && + (((next >= 0xAC) && (next <= 0xB7)) || ((next >= 0xB9) && (next <= 0xBB)))) { + return true; // U+FB1D-U+FDFF and U+FE70-U+FEFC presentation forms + } + } + + return false; +} + +bool bidi_paragraph_is_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; + BidiClass cls = prv_class_at(ptr, end, &next); + if (next == NULL) { + break; + } + if (cls == BidiClassB) { + break; + } + if (cls == BidiClassL) { + return false; + } + if (cls == BidiClassR) { + return true; + } + ptr = next; + } + + return false; +} + +utf8_t *bidi_next_run(const utf8_t *line_start, utf8_t *pos, const utf8_t *end, + bool para_is_rtl, uint8_t *run_level) { + if (line_start == NULL || pos == NULL || end == NULL || run_level == NULL || + pos >= end) { + return pos; + } + + uint8_t level = prv_level_for_dir(para_is_rtl, para_is_rtl); + utf8_t *cur = NULL; + if (!prv_resolve_span(line_start, pos, end, para_is_rtl, &level, &cur)) { + return pos; + } + *run_level = level; + + // The separator itself is the whole run: resolving already stepped past it, + // so the check below would otherwise look at the next paragraph's first + // character and let the run continue across the break. + utf8_t *first = NULL; + if (prv_class_at(pos, end, &first) == BidiClassB) { + return cur; + } + + while (cur < end && *cur != '\0') { + utf8_t *peek = NULL; + if (prv_class_at(cur, end, &peek) == BidiClassB) { + break; + } + uint8_t span_level = 0; + utf8_t *span_end = NULL; + if (!prv_resolve_span(line_start, cur, end, para_is_rtl, &span_level, &span_end)) { + break; + } + if (span_level != level || span_end <= cur) { + break; + } + cur = span_end; + } + + return cur; +} + +Codepoint bidi_mirror_codepoint(Codepoint cp) { + if (cp > MAX_MIRRORED_CODEPOINT) { + return cp; + } + + for (size_t i = 0; i < ARRAY_LENGTH(s_mirror_pairs); i++) { + if (s_mirror_pairs[i].first == cp) { + return s_mirror_pairs[i].second; + } + if (s_mirror_pairs[i].second == cp) { + return s_mirror_pairs[i].first; + } + } + + return cp; +} + +size_t bidi_reverse_run(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size) { + if (dest == NULL || dest_size == 0) { + return 0; + } + dest[0] = '\0'; + if (src == NULL || src_len == 0) { + return 0; + } + + // Bound the input to the first null byte or undecodable sequence. + const utf8_t *limit = src + src_len; + const utf8_t *end = src; + while (end < limit && *end != '\0') { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint((utf8_t *)end, &next); + if (cp == 0 || next == NULL || next > limit) { + break; + } + end = next; + } + + size_t dest_offset = 0; + const utf8_t *tail = end; + while (tail > src) { + const utf8_t *base = utf8_get_previous((utf8_t *)src, (utf8_t *)tail); + if (base == NULL) { + break; + } + + // Combining marks are emitted after the base they attach to, so a cluster + // keeps its logical order inside the reversed run. + while (base > src) { + utf8_t *next = NULL; + Codepoint cp = utf8_peek_codepoint((utf8_t *)base, &next); + if (cp == 0 || next == NULL || prv_class(cp) != BidiClassNSM) { + break; + } + const utf8_t *prev = utf8_get_previous((utf8_t *)src, (utf8_t *)base); + if (prev == NULL) { + break; + } + base = prev; + } + + // A flag is a pair of regional indicators, paired from the start of the + // sequence the way the renderer pairs them. Step back onto the first member + // when this one completes a pair, so the pair still names the same country + // once the run has been reversed. An odd trailing indicator stands alone. + utf8_t *base_next = NULL; + if (codepoint_is_regional_indicator(utf8_peek_codepoint((utf8_t *)base, &base_next)) && + (base_next != NULL)) { + size_t preceding = 0; + const utf8_t *scan = base; + while (scan > src) { + const utf8_t *prev = utf8_get_previous((utf8_t *)src, (utf8_t *)scan); + utf8_t *prev_next = NULL; + if ((prev == NULL) || + !codepoint_is_regional_indicator(utf8_peek_codepoint((utf8_t *)prev, &prev_next)) || + (prev_next == NULL)) { + break; + } + preceding++; + scan = prev; + } + if ((preceding % 2) == 1) { + const utf8_t *pair_start = utf8_get_previous((utf8_t *)src, (utf8_t *)base); + if (pair_start != NULL) { + base = pair_start; + } + } + } + + const size_t cluster_len = (size_t)(tail - base); + if ((dest_offset + cluster_len) >= dest_size) { + break; + } + memcpy(dest + dest_offset, base, cluster_len); + dest_offset += cluster_len; + tail = base; + } + + dest[dest_offset] = '\0'; + return dest_offset; +} + +bool bidi_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 (arabic_is_shapeable(cp)) { + return true; + } + ptr = next; + } + + return false; +} diff --git a/src/fw/applib/graphics/bidi.h b/src/fw/applib/graphics/bidi.h new file mode 100644 index 0000000000..2e0685cfa3 --- /dev/null +++ b/src/fw/applib/graphics/bidi.h @@ -0,0 +1,71 @@ +/* SPDX-FileCopyrightText: 2026 Ahmed Hussein */ +/* SPDX-FileCopyrightText: 2026 Khalid Nuaim (kaluaim) */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#pragma once + +#include "utf8.h" + +#include +#include +#include + +//! Minimal bidirectional text engine, a subset of Unicode UAX 9. +//! +//! Implements the rules that matter for a single line of watch text: +//! P2/P3 (paragraph direction), W4-W6 (numbers absorb their separators and +//! terminators), N1/N2 (neutrals take the surrounding direction, otherwise the +//! paragraph direction), L2 (run reordering, done by the caller) and L4 +//! (mirrored glyphs). Explicit directional controls, isolates and levels above +//! two are not implemented. + +//! Check whether a UTF-8 range needs bidirectional processing at all. +//! Scans raw bytes over every block the class table calls strong right-to-left, +//! so pure-ASCII text costs one comparison per byte and stays on the LTR path. +//! @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 right-to-left character +bool bidi_is_needed(const utf8_t *start, const utf8_t *end); + +//! Resolve the paragraph direction of a UTF-8 range (UAX 9 P2/P3). +//! The first strong character wins; ranges without one are left-to-right. +//! @param start Pointer to start of UTF-8 string +//! @param end Pointer to end of UTF-8 string (exclusive) +//! @return true if the paragraph reads right-to-left +bool bidi_paragraph_is_rtl(const utf8_t *start, const utf8_t *end); + +//! Find the extent of the directional run starting at @p pos. +//! +//! Weak types are folded into the neighbouring number and neutrals are +//! resolved against the surrounding runs, so the returned range is a maximal +//! stretch at one embedding level. Levels follow UAX 9 I1/I2 and stay within +//! 0-2: even levels read left-to-right, odd levels right-to-left. +//! +//! @param line_start Start of the line, used to look behind @p pos +//! @param pos Position to start the run at, must be within the line +//! @param end End of the line (exclusive) +//! @param para_is_rtl Paragraph direction, from \ref bidi_paragraph_is_rtl +//! @param[out] run_level Embedding level of the returned run +//! @return Pointer to the first codepoint after the run, or @p pos on failure +utf8_t *bidi_next_run(const utf8_t *line_start, utf8_t *pos, const utf8_t *end, + bool para_is_rtl, uint8_t *run_level); + +//! Return the mirrored form of a codepoint (UAX 9 L4). +//! Glyphs such as brackets are drawn mirrored inside a right-to-left run. +//! Codepoints without a mirrored form are returned unchanged. +Codepoint bidi_mirror_codepoint(Codepoint cp); + +//! Reverse the codepoints of a run for right-to-left display. +//! Combining marks stay behind the base character they attach to. +//! @param src Source UTF-8 string +//! @param src_len Length of source string in bytes +//! @param dest Destination buffer for the 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 bidi_reverse_run(const utf8_t *src, size_t src_len, utf8_t *dest, size_t dest_size); + +//! Check if a UTF-8 string range contains any shapeable Arabic letters. +//! @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 bidi_contains_arabic(const utf8_t *start, const utf8_t *end); diff --git a/src/fw/applib/graphics/rtl_support.c b/src/fw/applib/graphics/rtl_support.c deleted file mode 100644 index 87c3f96e42..0000000000 --- a/src/fw/applib/graphics/rtl_support.c +++ /dev/null @@ -1,210 +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" - -// 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 c063e998d9..add3d240b8 100644 --- a/src/fw/applib/graphics/text_layout.c +++ b/src/fw/applib/graphics/text_layout.c @@ -18,7 +18,7 @@ #include "graphics.h" #include "graphics_private.h" #include "gtypes.h" -#include "rtl_support.h" +#include "bidi.h" #include "text_render.h" #include "text_resources.h" #include "utf8.h" @@ -47,50 +47,23 @@ 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; - } - // General punctuation block (U+2000-U+206F) - includes dashes, quotes, etc. - if (cp >= 0x2000 && cp <= 0x206F) { - return true; - } - return false; -} - -//! 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; - } - - 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; - } - // 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; +// PBL-23045 Eventually remove perimeter debugging +//! Start of the paragraph @p line_start falls in. P2 is defined over the +//! paragraph, so a wrapped line must not resolve its own base direction or the +//! alignment flips part way down. '\n' cannot appear as a UTF-8 continuation +//! byte, so a plain byte scan is safe here. +static utf8_t *prv_paragraph_start(const Utf8Bounds *bounds, const utf8_t *line_start) { + if (bounds == NULL || bounds->start == NULL || line_start == NULL) { + return (utf8_t *)line_start; + } + for (const utf8_t *ptr = line_start; ptr > bounds->start; ptr--) { + if (ptr[-1] == '\n') { + return (utf8_t *)ptr; } - // Found first letter character, check if RTL - return codepoint_is_rtl(cp); } - return false; + return bounds->start; } -// PBL-23045 Eventually remove perimeter debugging void graphics_text_perimeter_debugging_enable(bool enable) { app_state_set_text_perimeter_debugging_enabled(enable); } @@ -685,72 +658,61 @@ 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) + // BiDi support: the line is split into runs of a single direction, the runs + // are reordered for the paragraph direction, and RTL runs are shaped, + // reversed and mirrored before being drawn. bool is_rendering = (char_visitor_cb == render_chars_char_visitor_cb); - // For segment-based RTL rendering during render pass if (is_rendering && line->start != NULL && text_box_params->utf8_bounds != NULL && text_box_params->utf8_bounds->end != NULL && text_box_params->utf8_bounds->end > line->start && - utf8_contains_rtl(line->start, text_box_params->utf8_bounds->end)) { + bidi_is_needed(line->start, text_box_params->utf8_bounds->end)) { - // Segment descriptor for BiDi reordering - // Headroom for splitting boundary spaces into their own neutral segments. + // Run descriptor for BiDi reordering #define MAX_BIDI_SEGMENTS 16 typedef struct { utf8_t *start; utf8_t *end; - bool is_rtl; + uint8_t level; } BiDiSegment; BiDiSegment segments[MAX_BIDI_SEGMENTS]; int num_segments = 0; - utf8_t *ptr = (utf8_t *)line->start; + utf8_t *line_start = (utf8_t *)line->start; utf8_t *line_end = (utf8_t *)text_box_params->utf8_bounds->end; + utf8_t *ptr = line_start; + const bool line_is_rtl = + bidi_paragraph_is_rtl(prv_paragraph_start(text_box_params->utf8_bounds, line_start), + line_end); int total_width_px = 0; - // Pass 1: Collect all segments with their boundaries and directions + // Pass 1: Collect the runs, clamping each to the width still available 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; + uint8_t segment_level = line_is_rtl ? 1 : 0; + utf8_t *run_end = bidi_next_run(line_start, ptr, line_end, line_is_rtl, &segment_level); + const bool segment_is_rtl = ((segment_level & 1) != 0); + if (run_end <= ptr) { + break; } - // Collect segment (until we hit opposite script type or end) + // Walk the run to accumulate its width. Arabic letters are measured in + // their contextual presentation form and RTL glyphs in their mirrored + // form, so the width here agrees with the shaped width used by the + // layout (word_init) and with the draw pass below — otherwise letters at + // the line edge get truncated and a gap appears. 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') { + while (segment_end < run_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; @@ -761,18 +723,6 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box 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; @@ -782,14 +732,18 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box // 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); + Codepoint next_seg_cp = prv_peek_next_letter(segment_end, run_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; } + if (segment_is_rtl) { + width_cp = bidi_mirror_codepoint(width_cp); + } 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) { + if (total_width_px + segment_width_px + glyph_width + suffix_width_px > + available_horiz_px) { break; } segment_width_px += glyph_width; @@ -800,44 +754,50 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box } segment_end = seg_next; } + 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, - }; - } + segments[num_segments++] = (BiDiSegment){ + .start = segment_start, + .end = segment_end, + .level = segment_level, + }; total_width_px += segment_width_px; ptr = segment_end; } - // 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; + // Pass 2: Reorder the runs for display (UAX 9 L2). From the highest level + // present down to the lowest odd level, reverse every contiguous group of + // runs at that level or above. A number embedded in a right-to-left region + // sits one level deeper, so this moves it to the correct side of the region + // in either paragraph direction. + uint8_t max_level = 0; + uint8_t lowest_odd = 0xFF; + for (int i = 0; i < num_segments; i++) { + if (segments[i].level > max_level) { + max_level = segments[i].level; + } + if ((segments[i].level & 1) && (segments[i].level < lowest_odd)) { + lowest_odd = segments[i].level; + } + } + + for (uint8_t level = max_level; (lowest_odd != 0xFF) && (level >= lowest_odd); level--) { + for (int i = 0; i < num_segments; i++) { + if (segments[i].level < level) { + continue; + } + int last = i; + while ((last + 1 < num_segments) && (segments[last + 1].level >= level)) { + last++; + } + for (int a = i, b = last; a < b; a++, b--) { + BiDiSegment temp = segments[a]; + segments[a] = segments[b]; + segments[b] = temp; + } + i = last; } } @@ -851,7 +811,7 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box BiDiSegment *seg = &segments[seg_idx]; size_t seg_len = seg->end - seg->start; - if (seg->is_rtl) { + if ((seg->level & 1) != 0) { // 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). @@ -862,23 +822,23 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box render_len = rtl_buffer_size - 4; } - if (utf8_contains_arabic(seg->start, seg->end)) { + if (bidi_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); + reversed_len = bidi_reverse_run(shaped_buffer, shaped_len, + rtl_buffer, rtl_buffer_size - 1); } applib_free(shaped_buffer); } } if (reversed_len == 0) { - reversed_len = utf8_reverse_for_rtl(seg->start, render_len, - rtl_buffer, rtl_buffer_size - 1); + reversed_len = bidi_reverse_run(seg->start, render_len, + rtl_buffer, rtl_buffer_size - 1); } if (reversed_len > 0) { @@ -891,6 +851,27 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box if (rcp == 0 || rnext == NULL) break; if (prv_codepoint_is_invisible(rcp)) { rptr = rnext; continue; } + // L4: mirrored glyphs such as brackets face the other way inside + // an RTL run. + rcp = bidi_mirror_codepoint(rcp); + + // Fold an emoji pair the same way the width pass did. The reversal + // keeps a flag's two regional indicators in order, so folding here + // draws one glyph and advances once, matching the measurement. + Codepoint rnext_cp = 0; + if (*rnext != '\0') { + utf8_t *rpeek = NULL; + rnext_cp = utf8_peek_codepoint(rnext, &rpeek); + } + bool rconsumed_next = false; + rcp = emoji_shape_pair(rcp, rnext_cp, &rconsumed_next); + if (rconsumed_next) { + utf8_t *rskip = NULL; + if (utf8_peek_codepoint(rnext, &rskip) != 0 && rskip != NULL) { + rnext = rskip; + } + } + int glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, text_box_params->font, rcp); @@ -1457,9 +1438,14 @@ 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 reads RTL, switch to right. + // Gated the same way as the render path, so a script the renderer leaves in + // logical order is not right-aligned on its own. + if (effective_alignment == GTextAlignmentLeft && line->start != NULL && + text_box_params->utf8_bounds != NULL && text_box_params->utf8_bounds->end != NULL && + bidi_is_needed(line->start, text_box_params->utf8_bounds->end)) { + if (bidi_paragraph_is_rtl(prv_paragraph_start(text_box_params->utf8_bounds, line->start), + text_box_params->utf8_bounds->end)) { effective_alignment = GTextAlignmentRight; } } diff --git a/tests/fw/CMakeLists.txt b/tests/fw/CMakeLists.txt index 542c0b9721..0a736b945f 100644 --- a/tests/fw/CMakeLists.txt +++ b/tests/fw/CMakeLists.txt @@ -48,9 +48,10 @@ pbl_clar_test(test_codepoint src/fw/applib/fonts/codepoint.c ) -pbl_clar_test(test_rtl_support +pbl_clar_test(test_bidi SOURCES - 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/fonts/codepoint.c ) @@ -67,7 +68,7 @@ pbl_clar_test(test_char_iterator 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 @@ -79,7 +80,7 @@ pbl_clar_test(test_word_iterator 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 @@ -100,7 +101,7 @@ pbl_clar_test(test_line_layout 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 @@ -322,7 +323,7 @@ pbl_clar_test(test_text_layout_8_bit src/fw/applib/graphics/bitblt.c src/fw/applib/graphics/8_bit/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 diff --git a/tests/fw/apps/system_apps/health/CMakeLists.txt b/tests/fw/apps/system_apps/health/CMakeLists.txt index 7cdb529084..9a4ae5286d 100644 --- a/tests/fw/apps/system_apps/health/CMakeLists.txt +++ b/tests/fw/apps/system_apps/health/CMakeLists.txt @@ -31,7 +31,7 @@ pbl_clar_test(test_health_card_view 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/bidi.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 @@ -138,7 +138,7 @@ pbl_clar_test(test_health_activity_summary_card 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/bidi.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 @@ -240,7 +240,7 @@ pbl_clar_test(test_health_sleep_summary_card 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/bidi.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 @@ -342,7 +342,7 @@ pbl_clar_test(test_health_hr_summary_card 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/bidi.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 @@ -444,7 +444,7 @@ pbl_clar_test(test_health_activity_detail_card 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/bidi.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 @@ -545,7 +545,7 @@ pbl_clar_test(test_health_sleep_detail_card 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/bidi.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 @@ -646,7 +646,7 @@ pbl_clar_test(test_health_hr_detail_card 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/bidi.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 @@ -747,7 +747,7 @@ pbl_clar_test(test_health_detail_card 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/bidi.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/CMakeLists.txt b/tests/fw/apps/system_apps/launcher/CMakeLists.txt index 3f90fe4023..fd543720a9 100644 --- a/tests/fw/apps/system_apps/launcher/CMakeLists.txt +++ b/tests/fw/apps/system_apps/launcher/CMakeLists.txt @@ -24,7 +24,7 @@ pbl_clar_test(test_launcher_menu_layer 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/CMakeLists.txt b/tests/fw/apps/system_apps/music/CMakeLists.txt index 80cec4c8e5..5fe876852d 100644 --- a/tests/fw/apps/system_apps/music/CMakeLists.txt +++ b/tests/fw/apps/system_apps/music/CMakeLists.txt @@ -28,7 +28,7 @@ pbl_clar_test(test_music 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/CMakeLists.txt b/tests/fw/apps/system_apps/timeline/CMakeLists.txt index 6773424659..2b0a230c6a 100644 --- a/tests/fw/apps/system_apps/timeline/CMakeLists.txt +++ b/tests/fw/apps/system_apps/timeline/CMakeLists.txt @@ -28,7 +28,7 @@ pbl_clar_test(test_timeline_list_view 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 @@ -152,7 +152,7 @@ pbl_clar_test(test_timeline_no_events 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/CMakeLists.txt b/tests/fw/apps/system_apps/weather/CMakeLists.txt index a24a2133c9..345e791b39 100644 --- a/tests/fw/apps/system_apps/weather/CMakeLists.txt +++ b/tests/fw/apps/system_apps/weather/CMakeLists.txt @@ -28,7 +28,7 @@ pbl_clar_test(test_weather_app_layout 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/CMakeLists.txt b/tests/fw/apps/system_apps/workout/CMakeLists.txt index f955edbdb5..dfa20ee26a 100644 --- a/tests/fw/apps/system_apps/workout/CMakeLists.txt +++ b/tests/fw/apps/system_apps/workout/CMakeLists.txt @@ -28,7 +28,7 @@ pbl_clar_test(test_workout_summary 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 @@ -134,7 +134,7 @@ pbl_clar_test(test_workout_active 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 @@ -238,7 +238,7 @@ pbl_clar_test(test_workout_dialog 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 @@ -341,7 +341,7 @@ pbl_clar_test(test_workout_selection 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/CMakeLists.txt b/tests/fw/apps/watch/kickstart/CMakeLists.txt index d0c155d74c..85cea1b78e 100644 --- a/tests/fw/apps/watch/kickstart/CMakeLists.txt +++ b/tests/fw/apps/watch/kickstart/CMakeLists.txt @@ -28,7 +28,7 @@ pbl_clar_test(test_kickstart 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/CMakeLists.txt b/tests/fw/graphics/CMakeLists.txt index 3fca21b663..11192ef24c 100644 --- a/tests/fw/graphics/CMakeLists.txt +++ b/tests/fw/graphics/CMakeLists.txt @@ -73,7 +73,7 @@ set(graphics_draw_text_sources 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 @@ -156,7 +156,7 @@ pbl_clar_test(test_graphics_draw_text_flow 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/CMakeLists.txt b/tests/fw/services/timeline/CMakeLists.txt index ab6d4464ab..eb46af000f 100644 --- a/tests/fw/services/timeline/CMakeLists.txt +++ b/tests/fw/services/timeline/CMakeLists.txt @@ -139,7 +139,7 @@ pbl_clar_test(test_timeline_layouts 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_bidi.c b/tests/fw/test_bidi.c new file mode 100644 index 0000000000..786ebf5073 --- /dev/null +++ b/tests/fw/test_bidi.c @@ -0,0 +1,445 @@ +/* SPDX-FileCopyrightText: 2026 Khalid Nuaim (kaluaim) */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "applib/graphics/bidi.h" + +#include "clar.h" + +#include "pbl/util/size.h" + +#include + +// Stubs +/////////////////////////////////////////////////////////// +#include "stubs_logging.h" +#include "stubs_passert.h" + +// Helpers +/////////////////////////////////////////////////////////// + +typedef struct { + size_t offset; + size_t length; + bool is_rtl; +} ExpectedRun; + +typedef struct { + size_t offset; + size_t length; + uint8_t level; +} ExpectedLevelRun; + +//! Like prv_assert_runs, but checks the embedding level rather than only the +//! direction, so level-2 nesting inside a level-1 region is covered. +static void prv_assert_levels(const char *text, const ExpectedLevelRun *expected, + int num_expected) { + utf8_t *start = (utf8_t *)text; + const utf8_t *end = start + strlen(text); + const bool para_is_rtl = bidi_paragraph_is_rtl(start, end); + + utf8_t *pos = start; + int index = 0; + while (pos < end) { + uint8_t run_level = 0; + utf8_t *run_end = bidi_next_run(start, pos, end, para_is_rtl, &run_level); + cl_assert(run_end > pos); + cl_assert_lt(index, num_expected); + cl_assert_equal_i((int)(pos - start), (int)expected[index].offset); + cl_assert_equal_i((int)(run_end - pos), (int)expected[index].length); + cl_assert_equal_i(run_level, expected[index].level); + pos = run_end; + index++; + } + cl_assert_equal_i(index, num_expected); +} + +//! Split `text` into runs the way the render path does and compare the result +//! against the expected offsets, lengths and directions. +static void prv_assert_runs(const char *text, const ExpectedRun *expected, int num_expected) { + utf8_t *start = (utf8_t *)text; + const utf8_t *end = start + strlen(text); + const bool para_is_rtl = bidi_paragraph_is_rtl(start, end); + + utf8_t *pos = start; + int index = 0; + while (pos < end) { + uint8_t run_level = 0; + utf8_t *run_end = bidi_next_run(start, pos, end, para_is_rtl, &run_level); + cl_assert(run_end > pos); + cl_assert_lt(index, num_expected); + cl_assert_equal_i((int)(pos - start), (int)expected[index].offset); + cl_assert_equal_i((int)(run_end - pos), (int)expected[index].length); + cl_assert_equal_b((run_level & 1) != 0, expected[index].is_rtl); + pos = run_end; + index++; + } + cl_assert_equal_i(index, num_expected); +} + +static void prv_assert_reversed(const char *text, const char *expected) { + utf8_t buffer[64]; + const size_t length = bidi_reverse_run((const utf8_t *)text, strlen(text), + buffer, sizeof(buffer)); + cl_assert_equal_i((int)length, (int)strlen(expected)); + cl_assert_equal_s((const char *)buffer, expected); +} + +// Tests +/////////////////////////////////////////////////////////// + +void test_bidi__not_needed_for_latin_text(void) { + const char *text = "Hello (world) 123"; + cl_assert(!bidi_is_needed((const utf8_t *)text, (const utf8_t *)text + strlen(text))); + + const char *accented = "Café (naïve)"; + cl_assert(!bidi_is_needed((const utf8_t *)accented, (const utf8_t *)accented + strlen(accented))); +} + +void test_bidi__needed_for_arabic_and_hebrew(void) { + const char *arabic = "Hello مرحبا"; + cl_assert(bidi_is_needed((const utf8_t *)arabic, (const utf8_t *)arabic + strlen(arabic))); + + const char *hebrew = "Hello שלום"; + cl_assert(bidi_is_needed((const utf8_t *)hebrew, (const utf8_t *)hebrew + strlen(hebrew))); +} + +void test_bidi__paragraph_direction(void) { + const char *rtl = "الرسالة (Hello) وصلت"; + cl_assert(bidi_paragraph_is_rtl((const utf8_t *)rtl, (const utf8_t *)rtl + strlen(rtl))); + + // Digits and punctuation are not strong, so the Arabic word decides. + const char *leading_digits = "123 مرحبا"; + cl_assert(bidi_paragraph_is_rtl((const utf8_t *)leading_digits, + (const utf8_t *)leading_digits + strlen(leading_digits))); + + const char *ltr = "Say (שלום) now"; + cl_assert(!bidi_paragraph_is_rtl((const utf8_t *)ltr, (const utf8_t *)ltr + strlen(ltr))); + + const char *neutral = " (123) "; + cl_assert(!bidi_paragraph_is_rtl((const utf8_t *)neutral, + (const utf8_t *)neutral + strlen(neutral))); +} + +void test_bidi__mirror_codepoint(void) { + cl_assert_equal_i(bidi_mirror_codepoint('('), ')'); + cl_assert_equal_i(bidi_mirror_codepoint(')'), '('); + cl_assert_equal_i(bidi_mirror_codepoint('['), ']'); + cl_assert_equal_i(bidi_mirror_codepoint(']'), '['); + cl_assert_equal_i(bidi_mirror_codepoint('{'), '}'); + cl_assert_equal_i(bidi_mirror_codepoint('<'), '>'); + cl_assert_equal_i(bidi_mirror_codepoint(0x00AB), 0x00BB); + cl_assert_equal_i(bidi_mirror_codepoint(0x00BB), 0x00AB); + + // Codepoints without a mirrored form are returned unchanged. + cl_assert_equal_i(bidi_mirror_codepoint('a'), 'a'); + cl_assert_equal_i(bidi_mirror_codepoint('-'), '-'); + cl_assert_equal_i(bidi_mirror_codepoint(0x0645), 0x0645); +} + +void test_bidi__reverse_run(void) { + prv_assert_reversed("abc", "cba"); + prv_assert_reversed("مرحبا", "ابحرم"); + prv_assert_reversed("", ""); +} + +void test_bidi__reverse_run_keeps_marks_on_their_base(void) { + // "ab́c" reverses to "cb́a": the acute stays behind the b. + prv_assert_reversed("ab\xCC\x81" "c", "c" "b\xCC\x81" "a"); + + // Arabic fatha and shadda stay behind the letter they belong to. + prv_assert_reversed("\xD9\x85\xD9\x8E" "\xD8\xA8", "\xD8\xA8" "\xD9\x85\xD9\x8E"); +} + +void test_bidi__latin_in_arabic_parentheses(void) { + // "الرسالة (Hello) وصلت" - the brackets take the paragraph direction, so both + // land in RTL runs and are mirrored when drawn. + static const ExpectedRun expected[] = { + { 0, 16, true }, // "الرسالة (" + { 16, 5, false }, // "Hello" + { 21, 10, true }, // ") وصلت" + }; + prv_assert_runs("الرسالة (Hello) وصلت", expected, 3); +} + +void test_bidi__arabic_in_arabic_parentheses(void) { + // Nothing breaks the direction, so the whole line is one RTL run. + static const ExpectedRun expected[] = { + { 0, 36, true }, + }; + prv_assert_runs("الرسالة (مرحبا) وصلت", expected, 1); +} + +void test_bidi__arabic_then_latin_in_parentheses(void) { + static const ExpectedRun expected[] = { + { 0, 27, true }, // "الرسالة (مرحبا " + { 27, 2, false }, // "Hi" + { 29, 10, true }, // ") وصلت" + }; + prv_assert_runs("الرسالة (مرحبا Hi) وصلت", expected, 3); +} + +void test_bidi__latin_then_arabic_in_parentheses(void) { + static const ExpectedRun expected[] = { + { 0, 16, true }, // "الرسالة (" + { 16, 2, false }, // "Hi" + { 18, 21, true }, // " مرحبا) وصلت" + }; + prv_assert_runs("الرسالة (Hi مرحبا) وصلت", expected, 3); +} + +void test_bidi__arabic_indic_digits_read_left_to_right(void) { + // Arabic-Indic digits sit in the Arabic block but are laid out left to right, + // so they form their own run and keep their order. + static const ExpectedRun expected[] = { + { 0, 15, true }, + { 15, 8, false }, + { 23, 9, true }, + }; + prv_assert_runs("الرسالة ٢٠٢٦ وصلت", expected, 3); +} + +void test_bidi__digits_keep_their_order_inside_arabic(void) { + // Arabic-Indic and Western digits both read left to right, so each forms its + // own run rather than being reversed with the letters around it. + static const ExpectedRun arabic_indic[] = { + { 0, 2, true }, // Alef + { 2, 4, false }, // "\u0662\u0663" + }; + prv_assert_runs("\u0627\u0662\u0663", arabic_indic, 2); + + static const ExpectedRun western[] = { + { 0, 2, true }, // Alef + { 2, 3, false }, // "123" + }; + prv_assert_runs("\u0627" "123", western, 2); +} + +void test_bidi__date_keeps_its_groups_in_order(void) { + // The slashes bind to the digits either side, so the whole date stays one + // left-to-right run and the groups are not reordered. + static const ExpectedRun expected[] = { + { 0, 18, false }, + }; + prv_assert_runs("\u0662\u0660\u0662\u0666/\u0660\u0666/\u0662\u0662", expected, 1); +} + +void test_bidi__arabic_indic_time_keeps_its_groups(void) { + static const ExpectedRun expected[] = { + { 0, 9, false }, + }; + prv_assert_runs("\u0661\u0662:\u0663\u0664", expected, 1); +} + +void test_bidi__separator_needs_a_number_on_both_sides(void) { + // A separator with a letter on one side is not part of the number, so it + // resolves as a neutral and stays with the Arabic run. + static const ExpectedRun expected[] = { + { 0, 3, true }, // Alef + '/' + { 3, 2, false }, // "\u0662" + }; + prv_assert_runs("\u0627/\u0662", expected, 2); +} + +void test_bidi__number_keeps_its_separator(void) { + // The colon binds the two halves of the time together instead of splitting + // the number into three runs. + static const ExpectedRun expected[] = { + { 0, 3, true }, + { 3, 5, false }, // "12:30" + { 8, 3, true }, + }; + prv_assert_runs("م 12:30 م", expected, 3); +} + +void test_bidi__number_keeps_its_terminator(void) { + static const ExpectedRun expected[] = { + { 0, 3, true }, + { 3, 3, false }, // "50%" + { 6, 3, true }, + }; + prv_assert_runs("م 50% م", expected, 3); +} + +void test_bidi__trailing_emoji_joins_the_arabic_run(void) { + // A trailing neutral has no strong character after it, so it takes the + // paragraph direction and ends up at the visual start of the line. + static const ExpectedRun expected[] = { + { 0, 15, true }, + }; + prv_assert_runs("مرحبا 👋", expected, 1); +} + +void test_bidi__mark_stays_with_the_run_of_its_base(void) { + // The acute on the final "e" must join the Latin run rather than resolving on + // its own, or reordering would strand it at the visual start of the line. + static const ExpectedRun expected[] = { + { 0, 11, true }, // "\u0645\u0631\u062d\u0628\u0627 " + { 11, 6, false }, // "caf" + "e" + combining acute + }; + prv_assert_runs("\u0645\u0631\u062d\u0628\u0627 caf" "e\u0301", expected, 2); +} + +void test_bidi__latin_phrase_with_a_number_stays_together(void) { + // W7: a number after a Latin word takes that word's direction, so the + // neutrals around it stay left-to-right and the phrase is not split into + // runs that the paragraph reorder would then reverse. + static const ExpectedRun expected[] = { + { 0, 11, true }, // "\u0645\u0631\u062d\u0628\u0627 " + { 11, 11, false}, // "abc 123 def" + }; + prv_assert_runs("\u0645\u0631\u062d\u0628\u0627 abc 123 def", expected, 2); +} + +void test_bidi__honorific_mark_stays_with_its_base(void) { + // U+0610-U+061A are transparent to the shaper and must be treated as marks + // here too, or the reversal detaches them from the letter they sit on. + prv_assert_reversed("\u0628\u0610\u062c", "\u062c\u0628\u0610"); + + static const ExpectedRun expected[] = { + { 0, 6, true }, + }; + prv_assert_runs("\u0628\u0610\u062c", expected, 1); +} + +void test_bidi__direction_does_not_cross_a_newline(void) { + // A newline is a paragraph separator, so a neutral at the end of one line + // must not take its direction from the text after the break. The bracket here + // resolves the same way whether or not an Arabic paragraph follows. + static const ExpectedRun alone[] = { + { 0, 4, false }, // "abc " + { 4, 8, true }, // "\u05e9\u05dc\u05d5\u05dd" + { 12, 1, false }, // ")" + }; + prv_assert_runs("abc \u05e9\u05dc\u05d5\u05dd)", alone, 3); + + static const ExpectedRun with_next_paragraph[] = { + { 0, 4, false }, + { 4, 8, true }, + { 12, 1, false }, // ")" - unchanged by the Arabic after the break + { 13, 1, false }, // the separator itself + { 14, 10, true }, // "\u0645\u0631\u062d\u0628\u0627" + }; + prv_assert_runs("abc \u05e9\u05dc\u05d5\u05dd)\n\u0645\u0631\u062d\u0628\u0627", + with_next_paragraph, 5); +} + +void test_bidi__extended_arabic_mark_stays_with_its_base(void) { + // The Arabic Extended-A/B blocks carry combining marks among their letters, + // so the range cannot be classified as strong right-to-left wholesale. + prv_assert_reversed("\u0628\u08f0\u062c", "\u062c\u0628\u08f0"); +} + +void test_bidi__marks_outside_the_main_block_stay_with_their_base(void) { + // Combining marks live in several blocks; every one of them has to be + // recognised or the reversal drops the mark onto the neighbouring character. + prv_assert_reversed("a\u1ab0b", "b" "a\u1ab0"); // marks extended + prv_assert_reversed("a\u1dc0b", "b" "a\u1dc0"); // marks supplement + prv_assert_reversed("a\u20d0b", "b" "a\u20d0"); // marks for symbols + prv_assert_reversed("a\ufe20b", "b" "a\ufe20"); // combining half marks +} + +void test_bidi__supplementary_script_is_not_neutral(void) { + // Only emoji are neutral above the BMP; a supplementary ideograph keeps the + // left-to-right default and forms its own run instead of being reversed with + // the Arabic around it. + static const ExpectedRun expected[] = { + { 0, 11, true }, // "\u0645\u0631\u062d\u0628\u0627 " + { 11, 4, false }, // U+20000 + }; + prv_assert_runs("\u0645\u0631\u062d\u0628\u0627 \U00020000", expected, 2); +} + +void test_bidi__separator_is_a_run_of_its_own(void) { + // Resolving the separator already steps past it, so a run starting on one + // must end there rather than continuing into the next paragraph. + static const ExpectedRun expected[] = { + { 0, 3, false }, // "abc" + { 3, 1, false }, // the separator + { 4, 3, false }, // "xyz" + }; + prv_assert_runs("abc\nxyz", expected, 3); + prv_assert_runs("abc\rxyz", expected, 3); +} + +void test_bidi__flag_keeps_its_indicator_order(void) { + // A flag is an ordered pair of regional indicators. Reversing them would name + // a different country, so the pair travels together like a cluster. + prv_assert_reversed("\U0001f1fa\U0001f1f8" "a", "a" "\U0001f1fa\U0001f1f8"); + + // Pairing runs from the start of the sequence, as the renderer does, so an + // odd trailing indicator stands alone rather than re-pairing the ones before + // it into a different flag: A B C reverses to C, then A B. + prv_assert_reversed("\U0001f1e6\U0001f1e7\U0001f1e8", + "\U0001f1e8" "\U0001f1e6\U0001f1e7"); + prv_assert_reversed("\U0001f1e6\U0001f1e7\U0001f1e8\U0001f1e9", + "\U0001f1e8\U0001f1e9" "\U0001f1e6\U0001f1e7"); +} + +void test_bidi__number_after_rtl_nests_one_level_deeper(void) { + // In an LTR paragraph a number following an RTL run sits at level 2 inside the + // level-1 region, so L2 moves it to the left of that region. Without the level + // the run would look like ordinary level-0 Latin and stay put. + static const ExpectedLevelRun expected[] = { + { 0, 6, 0 }, // "Total " + { 6, 11, 1 }, // "\u0645\u0631\u062d\u0628\u0627 " + { 17, 3, 2 }, // "123" + }; + prv_assert_levels("Total \u0645\u0631\u062d\u0628\u0627 123", expected, 3); +} + +void test_bidi__latin_between_rtl_stays_at_the_paragraph_level(void) { + // A Latin word between two RTL words is level 0, not level 2, so the two RTL + // words must not be reordered around it. + static const ExpectedLevelRun expected[] = { + { 0, 2, 0 }, // "A " + { 2, 10, 1 }, // "\u0645\u0631\u062d\u0628\u0627" + { 12, 5, 0 }, // " DEF " + { 17, 10, 1 }, // "\u0648\u062f\u0627\u0639\u0627" + { 27, 2, 0 }, // " Z" + }; + prv_assert_levels("A \u0645\u0631\u062d\u0628\u0627 DEF \u0648\u062f\u0627\u0639\u0627 Z", + expected, 5); +} + +void test_bidi__gate_matches_the_class_table(void) { + // Every block the classifier calls strong right-to-left has to pass the gate, + // or that text is left in logical order while still being right-aligned. + static const char *rtl_blocks[] = { + "\u0710\u0712", // Syriac + "\u0780\u0781", // Thaana + "\u07ca\u07cb", // NKo + "\u08a0\u08a1", // Arabic Extended-A + "\ufe8d\ufe91", // Arabic presentation forms + "\ufb2a\ufb2b", // Hebrew presentation forms + }; + for (size_t i = 0; i < ARRAY_LENGTH(rtl_blocks); i++) { + const utf8_t *s = (const utf8_t *)rtl_blocks[i]; + cl_assert(bidi_is_needed(s, s + strlen(rtl_blocks[i]))); + cl_assert(bidi_paragraph_is_rtl(s, s + strlen(rtl_blocks[i]))); + } +} + +void test_bidi__hebrew_inside_a_latin_paragraph(void) { + // The paragraph reads left to right, so the brackets stay left to right and + // are drawn unmirrored. + static const ExpectedRun expected[] = { + { 0, 5, false }, // "Say (" + { 5, 8, true }, // "שלום" + { 13, 5, false }, // ") now" + }; + prv_assert_runs("Say (שלום) now", expected, 3); +} + +void test_bidi__contains_arabic(void) { + const char *arabic = "hi مرحبا"; + cl_assert(bidi_contains_arabic((const utf8_t *)arabic, + (const utf8_t *)arabic + strlen(arabic))); + + const char *hebrew = "hi שלום"; + cl_assert(!bidi_contains_arabic((const utf8_t *)hebrew, + (const utf8_t *)hebrew + strlen(hebrew))); + + const char *latin = "hi there"; + cl_assert(!bidi_contains_arabic((const utf8_t *)latin, + (const utf8_t *)latin + strlen(latin))); +} 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/CMakeLists.txt b/tests/fw/ui/CMakeLists.txt index 26da489ff5..3456b03c29 100644 --- a/tests/fw/ui/CMakeLists.txt +++ b/tests/fw/ui/CMakeLists.txt @@ -65,7 +65,7 @@ pbl_clar_test(test_status_bar_layer 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 @@ -281,7 +281,7 @@ pbl_clar_test(test_menu_layer_system_cells 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 @@ -354,7 +354,7 @@ pbl_clar_test(test_action_menu_window 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 @@ -436,7 +436,7 @@ pbl_clar_test(test_notification_window 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 @@ -531,7 +531,7 @@ pbl_clar_test(test_simple_dialog 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 @@ -618,7 +618,7 @@ pbl_clar_test(test_emoji_fonts 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 @@ -688,7 +688,7 @@ pbl_clar_test(test_expandable_dialog 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 @@ -785,7 +785,7 @@ pbl_clar_test(test_selection_windows 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 @@ -881,7 +881,7 @@ pbl_clar_test(test_timeline_peek 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 @@ -979,7 +979,7 @@ pbl_clar_test(test_option_menu_window 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