From 569a33468290c8e9c8ce203b38bbe89e52ca71ad Mon Sep 17 00:00:00 2001 From: Khalid Nuaim Date: Sun, 30 Aug 2026 10:29:18 +0300 Subject: [PATCH] applib/graphics: add a minimal BiDi engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTL rendering reverses the codepoints of each right-to-left run but never mirrors the glyphs that call for it, so a bracket keeps facing the same way after the reversal and "(مرحبا)" draws as ")مرحبا(". Neutral characters are attached to whichever run happens to be open rather than resolved against their neighbours, so the brackets around an embedded Latin word can land in different runs and be treated inconsistently. A combining mark is reversed away from the letter it attaches to. Replace rtl_support.c with bidi.c, a subset of Unicode UAX 9 sized for a single line of watch text: P2/P3 for the paragraph direction, W1 so a mark takes the class of the character it follows, W4-W6 so a number keeps the separators and terminators that belong to it, N1/N2 for neutrals and L4 for mirrored glyphs. Explicit directional controls, isolates and embedding levels above two are out of scope. N0 is not implemented; N1/N2 already give both halves of a bracket pair the same direction in every case that turns up in practice. The weak-LTR digit and numeric separator behaviour already in rtl_support.c is preserved, but moves from a special case inside the reversal into the run classification: European and Arabic-Indic numbers resolve to their own left-to-right run, which is the level UAX 9 assigns them in either paragraph direction. Resolving neutrals also removes the need to peel trailing spaces into their own segment, so rtl_segment_content_end() goes away with it. bidi_is_needed() gates the whole path on a raw byte scan over the same range the old utf8_contains_rtl() covered. Text with no Hebrew or Arabic in it costs one comparison per byte and keeps taking the existing left-to-right path, unchanged. The run splitter in text_layout.c collapses into one bidi_next_run() call and mirroring is applied in both the width pass and the draw pass so the two agree. Lam-alef ligature shaping and the segment cap are untouched. test_rtl_support.c is replaced by test_bidi.c, which keeps its digit and separator coverage expressed as run boundaries rather than as reversal output. Co-Authored-By: Claude Opus 5 Signed-off-by: Khalid Nuaim --- src/fw/applib/graphics/bidi.c | 639 ++++++++++++++++++ src/fw/applib/graphics/bidi.h | 70 ++ src/fw/applib/graphics/rtl_support.c | 210 ------ src/fw/applib/graphics/rtl_support.h | 43 -- src/fw/applib/graphics/text_layout.c | 176 ++--- tests/fw/CMakeLists.txt | 13 +- .../fw/apps/system_apps/health/CMakeLists.txt | 16 +- .../apps/system_apps/launcher/CMakeLists.txt | 2 +- .../fw/apps/system_apps/music/CMakeLists.txt | 2 +- .../apps/system_apps/timeline/CMakeLists.txt | 4 +- .../apps/system_apps/weather/CMakeLists.txt | 2 +- .../apps/system_apps/workout/CMakeLists.txt | 8 +- tests/fw/apps/watch/kickstart/CMakeLists.txt | 2 +- tests/fw/graphics/CMakeLists.txt | 4 +- tests/fw/services/timeline/CMakeLists.txt | 2 +- tests/fw/test_bidi.c | 369 ++++++++++ tests/fw/test_rtl_support.c | 153 ----- tests/fw/ui/CMakeLists.txt | 20 +- 18 files changed, 1167 insertions(+), 568 deletions(-) create mode 100644 src/fw/applib/graphics/bidi.c create mode 100644 src/fw/applib/graphics/bidi.h delete mode 100644 src/fw/applib/graphics/rtl_support.c delete mode 100644 src/fw/applib/graphics/rtl_support.h create mode 100644 tests/fw/test_bidi.c delete mode 100644 tests/fw/test_rtl_support.c diff --git a/src/fw/applib/graphics/bidi.c b/src/fw/applib/graphics/bidi.c new file mode 100644 index 0000000000..9e71fa157f --- /dev/null +++ b/src/fw/applib/graphics/bidi.c @@ -0,0 +1,639 @@ +/* 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; +} + +//! 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, bool *span_is_rtl, 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_is_rtl = false; + *span_end = next; + return true; + case BidiClassR: + *span_is_rtl = true; + *span_end = next; + return true; + case BidiClassEN: + case BidiClassAN: + // Numbers read left-to-right in either paragraph direction. + *span_is_rtl = false; + *span_end = prv_scan_number(pos, end, cls); + return true; + case BidiClassB: + // A paragraph separator stands on its own at the paragraph direction. + *span_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_is_rtl = false; + *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); + + *span_is_rtl = (has_before && has_after && (before_is_rtl == after_is_rtl)) ? + before_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; + } + + // Hebrew (U+0590) through Arabic (U+06FF) encode with lead bytes 0xD6-0xDB. + // Continuation bytes never land in that range, so a raw byte scan is enough + // and pure-ASCII text costs one comparison per byte. + for (const utf8_t *ptr = start; ptr < end && *ptr != '\0'; ptr++) { + if (*ptr < 0xD6 || *ptr > 0xDB) { + continue; + } + if (*ptr > 0xD6) { + return true; + } + // Armenian (U+0580-U+058F) shares the 0xD6 lead byte with Hebrew. + if ((ptr + 1) < end && ptr[1] >= 0x90) { + return true; + } + } + + 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, bool *run_is_rtl) { + if (line_start == NULL || pos == NULL || end == NULL || run_is_rtl == NULL || + pos >= end) { + return pos; + } + + bool dir = para_is_rtl; + utf8_t *cur = NULL; + if (!prv_resolve_span(line_start, pos, end, para_is_rtl, &dir, &cur)) { + return pos; + } + *run_is_rtl = dir; + + // 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; + } + bool span_is_rtl = false; + utf8_t *span_end = NULL; + if (!prv_resolve_span(line_start, cur, end, para_is_rtl, &span_is_rtl, &span_end)) { + break; + } + if (span_is_rtl != dir || 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..20185e298d --- /dev/null +++ b/src/fw/applib/graphics/bidi.h @@ -0,0 +1,70 @@ +/* SPDX-FileCopyrightText: 2026 Ahmed Hussein */ +/* SPDX-FileCopyrightText: 2026 Khalid Nuaim (kaluaim) */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#pragma once + +#include "utf8.h" + +#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, so pure-ASCII text costs one comparison per byte and the +//! caller can stay on its left-to-right 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 of one direction. Numbers always form a left-to-right run, matching +//! the level they are assigned in either paragraph direction. +//! +//! @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_is_rtl Direction 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, bool *run_is_rtl); + +//! 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..e6d60ec5ad 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,49 +47,6 @@ 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; - } - // Found first letter character, check if RTL - return codepoint_is_rtl(cp); - } - return false; -} - // PBL-23045 Eventually remove perimeter debugging void graphics_text_perimeter_debugging_enable(bool enable) { app_state_set_text_perimeter_debugging_enabled(enable); @@ -685,19 +642,17 @@ 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; @@ -708,49 +663,37 @@ utf8_t* walk_line(GContext* ctx, Line* line, const TextBoxParams* const text_box 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(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; + bool segment_is_rtl = line_is_rtl; + utf8_t *run_end = bidi_next_run(line_start, ptr, line_end, line_is_rtl, &segment_is_rtl); + 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 +704,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 +713,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,39 +735,22 @@ 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, + .is_rtl = segment_is_rtl, + }; 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); + // Pass 2: Reorder the runs for the paragraph direction (UAX 9 L2) + // In an RTL paragraph the visual order of the runs is reversed so the + // first logical run appears on the right, where reading starts. if (line_is_rtl && num_segments > 1) { for (int i = 0; i < num_segments / 2; i++) { BiDiSegment temp = segments[i]; @@ -862,23 +780,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 +809,10 @@ 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); + int glyph_width = prv_codepoint_get_horizontal_advance(&ctx->font_cache, text_box_params->font, rcp); @@ -1457,9 +1379,13 @@ 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(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..67bdb45311 --- /dev/null +++ b/tests/fw/test_bidi.c @@ -0,0 +1,369 @@ +/* SPDX-FileCopyrightText: 2026 Khalid Nuaim (kaluaim) */ +/* 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 +/////////////////////////////////////////////////////////// + +typedef struct { + size_t offset; + size_t length; + bool is_rtl; +} ExpectedRun; + +//! 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) { + bool run_is_rtl = false; + utf8_t *run_end = bidi_next_run(start, pos, end, para_is_rtl, &run_is_rtl); + 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_is_rtl, 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__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