feat: add RTL support in epub and txt readers (#1700)

Co-authored-by: Zach Nelson <zach@zdnelson.com>
This commit is contained in:
Uri Tauber
2026-05-29 03:25:17 -04:00
committed by GitHub
co-authored by Zach Nelson
parent cc2079a578
commit f5bc554ae7
27 changed files with 1621 additions and 119 deletions
+234
View File
@@ -0,0 +1,234 @@
#include "BidiUtils.h"
extern "C" {
#include "minibidi.h"
}
#undef when
#undef otherwise
#include <Logging.h>
#include <Utf8.h>
#include <cstring>
namespace {
bool isNaturalDirectionClass(const uchar cls) {
switch (cls) {
case L:
case R:
case AL:
case EN:
case AN:
return true;
default:
return false;
}
}
} // namespace
namespace BidiUtils {
bool startsWithRtl(const char* utf8, int maxStrongChars) {
if (!utf8 || maxStrongChars <= 0) return false;
auto* p = reinterpret_cast<const unsigned char*>(utf8);
int checked = 0;
while (*p) {
const uint32_t cp = utf8NextCodepoint(&p);
if (!cp || cp == REPLACEMENT_GLYPH) break;
const uchar cls = bidi_class(cp);
if (cls == R || cls == AL) return true;
if (cls == L) return false;
checked++;
if (checked >= maxStrongChars) break;
}
return false;
}
int detectParagraphLevel(const char* utf8, const int fallbackLevel, const int maxStrongChars) {
if (!utf8 || maxStrongChars <= 0) return fallbackLevel & 1;
auto* p = reinterpret_cast<const unsigned char*>(utf8);
int checked = 0;
while (*p) {
const uint32_t cp = utf8NextCodepoint(&p);
if (!cp || cp == REPLACEMENT_GLYPH) break;
const uchar cls = bidi_class(cp);
if (cls == R || cls == AL) return 1;
if (cls == L) return 0;
checked++;
if (checked >= maxStrongChars) break;
}
return fallbackLevel & 1;
}
bool applyBidiVisual(const char* utf8, std::string& out, int paragraphLevel) {
if (!utf8 || !*utf8) return false;
static bidi_char line[BIDI_MAX_LINE];
int count = 0;
auto* p = reinterpret_cast<const unsigned char*>(utf8);
while (*p) {
if (count >= BIDI_MAX_LINE) {
LOG_DBG("BIDI", "applyBidiVisual: input exceeds BIDI_MAX_LINE (%d chars), returning unprocessed", BIDI_MAX_LINE);
return false;
}
const uint32_t cp = utf8NextCodepoint(&p);
if (!cp || cp == REPLACEMENT_GLYPH) break;
line[count].origwc = line[count].wc = cp;
line[count].index = static_cast<uint16_t>(count);
count++;
}
if (!count) return false;
const bool autodir = (paragraphLevel < 0);
const int level = autodir ? 0 : (paragraphLevel & 1);
do_bidi(autodir, level, line, count);
out.clear();
out.reserve(std::strlen(utf8));
for (int i = 0; i < count; i++) {
utf8AppendCodepoint(line[i].wc, out);
}
return true;
}
bool computeVisualWordOrder(const std::vector<std::string>& words, bool paragraphIsRtl,
std::vector<uint16_t>& visualOrder) {
visualOrder.clear();
const size_t nWords = words.size();
if (nWords <= 1 || nWords > BIDI_MAX_LINE) return false;
static bidi_char line[BIDI_MAX_LINE];
int count = 0;
bool truncated = false;
for (size_t w = 0; w < nWords && !truncated; w++) {
auto* p = reinterpret_cast<const unsigned char*>(words[w].c_str());
while (*p) {
if (count >= BIDI_MAX_LINE) {
truncated = true;
break;
}
const uint32_t cp = utf8NextCodepoint(&p);
if (!cp || cp == REPLACEMENT_GLYPH) break;
line[count].origwc = line[count].wc = cp;
line[count].index = static_cast<uint16_t>(w);
count++;
}
if (!truncated && w + 1 < nWords) {
if (count >= BIDI_MAX_LINE) {
truncated = true;
break;
}
line[count].origwc = line[count].wc = ' ';
line[count].index = static_cast<uint16_t>(nWords);
count++;
}
}
if (truncated || count == 0) return false;
// Fast-path for homogeneous lines: skip UAX#9 if there's no mixing.
bool hasL = false, hasR = false;
for (int i = 0; i < count; i++) {
uchar bc = bidi_class(line[i].wc);
if (bc == L || bc == EN || bc == AN)
hasL = true;
else if (bc == R || bc == AL)
hasR = true;
}
// Purely LTR line in RTL paragraph: identity order, but we might still need to reorder
// if some characters are mirrored or neutral resolution differs.
// Actually, UAX#9 rule L1/L2 says purely LTR in RTL para stays as is (identity).
// Purely RTL line: just reverse the words.
if (!hasL && hasR && paragraphIsRtl) {
visualOrder.reserve(nWords);
for (int i = static_cast<int>(nWords) - 1; i >= 0; i--) {
visualOrder.push_back(static_cast<uint16_t>(i));
}
return true;
}
if (!hasR) {
if (!paragraphIsRtl) {
// Pure LTR in LTR paragraph: nothing to do.
return false;
}
// Pure LTR in RTL paragraph: no word reordering, but must use the
// willReorder (left-to-right) positioning path, not the RTL right-to-left path.
visualOrder.reserve(nWords);
for (size_t i = 0; i < nWords; i++) {
visualOrder.push_back(static_cast<uint16_t>(i));
}
return true;
}
do_bidi(/*autodir=*/false, paragraphIsRtl ? 1 : 0, line, count);
uint16_t firstAny[BIDI_MAX_LINE];
uint16_t firstNatural[BIDI_MAX_LINE];
for (size_t w = 0; w < nWords; w++) {
firstAny[w] = UINT16_MAX;
firstNatural[w] = UINT16_MAX;
}
for (int i = 0; i < count; i++) {
const uint16_t w = line[i].index;
if (w >= nWords) continue;
if (firstAny[w] == UINT16_MAX) {
firstAny[w] = static_cast<uint16_t>(i);
}
if (firstNatural[w] == UINT16_MAX && isNaturalDirectionClass(bidi_class(line[i].wc))) {
firstNatural[w] = static_cast<uint16_t>(i);
}
}
visualOrder.reserve(nWords);
for (int i = 0; i < count; i++) {
const uint16_t w = line[i].index;
if (w >= nWords) continue;
const uint16_t anchor = firstNatural[w] != UINT16_MAX ? firstNatural[w] : firstAny[w];
if (anchor == UINT16_MAX) {
visualOrder.clear();
return false;
}
if (anchor == static_cast<uint16_t>(i)) {
visualOrder.push_back(w);
}
}
if (visualOrder.size() != nWords) {
visualOrder.clear();
return false;
}
// Check if the order is exactly the same as the original input
bool needsReorder = false;
for (size_t i = 0; i < nWords; i++) {
if (visualOrder[i] != i) {
needsReorder = true;
break;
}
}
if (!needsReorder) {
visualOrder.clear();
return false;
}
return true;
}
} // namespace BidiUtils
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace BidiUtils {
// Paragraph-level P2/P3: scan the first N strong chars per word to find base direction.
inline constexpr int RTL_PARAGRAPH_PROBE_DEPTH = 5;
bool startsWithRtl(const char* utf8, int maxStrongChars = RTL_PARAGRAPH_PROBE_DEPTH);
int detectParagraphLevel(const char* utf8, int fallbackLevel = 0, int maxStrongChars = 64);
// paragraphLevel: -1 = auto-detect, 0 = LTR, 1 = RTL
bool applyBidiVisual(const char* utf8, std::string& out, int paragraphLevel = -1);
bool computeVisualWordOrder(const std::vector<std::string>& words, bool paragraphIsRtl,
std::vector<uint16_t>& visualOrder);
} // namespace BidiUtils
+39
View File
@@ -0,0 +1,39 @@
/* bidi_pairs.t unified mirror + bracket table for CrossPoint.
*
* Replaces both mirroring.t and brackets.t. canonical.t is dropped
* (fullwidth brackets are not used in Hebrew epub content).
*
* Each entry: {from, to, bracket_type}
* bracket_type == BRACKo : `from` is an opening bracket
* bracket_type == BRACKc : `from` is a closing bracket; `to` = opener
* bracket_type == BRACKx : not a bracket pair mirrored by rule L4 only
*
* mirror(c) always returns `to` for any entry where c==from
* bracket(c) returns 0 for BRACKx; c for BRACKo; `to` (opener) for BRACKc
*
* Sorted ascending by `from` (binary search).
*/
/* ASCII brackets — both bracket pairs AND L4 mirrors */
{0x0028, 0x0029, BRACKo}, /* ( */
{0x0029, 0x0028, BRACKc}, /* ) */
{0x003C, 0x003E, BRACKo}, /* < */
{0x003E, 0x003C, BRACKc}, /* > */
{0x005B, 0x005D, BRACKo}, /* [ */
{0x005D, 0x005B, BRACKc}, /* ] */
{0x007B, 0x007D, BRACKo}, /* { */
{0x007D, 0x007B, BRACKc}, /* } */
/* Angle quotation marks — L4 mirror only */
{0x00AB, 0x00BB, BRACKx}, /* « → » */
{0x00BB, 0x00AB, BRACKx}, /* » → « */
/* Curly quotes — L4 mirror only */
{0x2018, 0x2019, BRACKx}, /* ' → ' */
{0x2019, 0x2018, BRACKx}, /* ' → ' */
{0x201C, 0x201D, BRACKx}, /* " → " */
{0x201D, 0x201C, BRACKx}, /* " → " */
/* Single angle quotes */
{0x2039, 0x203A, BRACKo}, /* */
{0x203A, 0x2039, BRACKc}, /* */
+115
View File
@@ -0,0 +1,115 @@
/* bidiclasses.t — bidi class table for CrossPoint Hebrew/English epub.
*
* Coverage rationale:
* Hebrew + English is the primary target. However, CrossPoint renders
* Latin and Cyrillic scripts for many other languages, so these MUST be
* classified as L (not fall through to ON) to avoid regression when they
* appear adjacent to Hebrew runs.
*
* Scripts NOT in this table fall through to ON correct per UAX#9 for
* scripts CrossPoint's fonts don't support (CJK, Arabic, Devanagari, etc.)
* ON is the right class for "unknown" it behaves neutrally.
*
* Entries sorted ascending by first (binary search requirement).
*/
/* ── ASCII C0 controls ────────────────────────────────────────────────── */
{0x0000, 0x0008, BN},
{0x0009, 0x0009, S},
{0x000A, 0x000A, B},
{0x000B, 0x000B, S},
{0x000C, 0x000C, WS},
{0x000D, 0x000D, B},
{0x000E, 0x001B, BN},
{0x001C, 0x001E, B},
{0x001F, 0x001F, S},
{0x0020, 0x0020, WS},
/* ── ASCII punctuation: number-adjacent classes ─────────────────────── */
{0x0023, 0x0025, ET}, /* # $ % */
{0x002B, 0x002B, ES}, /* + */
{0x002C, 0x002C, CS}, /* , */
{0x002D, 0x002D, ES}, /* - */
{0x002E, 0x002F, CS}, /* . / */
{0x0030, 0x0039, EN}, /* 0-9 */
{0x003A, 0x003A, CS}, /* : */
/* ── Basic Latin letters ─────────────────────────────────────────────── */
{0x0041, 0x005A, L}, /* A-Z */
{0x0061, 0x007A, L}, /* a-z */
/* ── C1 / BN */
{0x007F, 0x0084, BN},
{0x0085, 0x0085, B},
{0x0086, 0x009F, BN},
/* ── Latin-1 supplement ─────────────────────────────────────────────── */
{0x00A0, 0x00A0, CS}, /* non-breaking space */
{0x00A2, 0x00A5, ET}, /* ¢ £ ¤ ¥ */
{0x00AA, 0x00AA, L},
{0x00AD, 0x00AD, BN}, /* soft hyphen */
{0x00B0, 0x00B1, ET}, /* ° ± */
{0x00B2, 0x00B3, EN}, /* ² ³ */
{0x00B5, 0x00B5, L},
{0x00B9, 0x00B9, EN}, /* ¹ */
{0x00BA, 0x00BA, L},
{0x00C0, 0x00D6, L},
{0x00D8, 0x00F6, L},
{0x00F8, 0x02B8, L}, /* Latin Extended-A/B, IPA, Spacing Modifiers
covers: Polish, Czech, Slovak, Turkish, etc. */
/* ── Combining Diacritical Marks (NSM) ──────────────────────────────── */
/* Needed for decomposed Latin characters (some epubs use NFD/NFKD form) */
{0x0300, 0x036F, NSM},
/* ── Cyrillic (L) ────────────────────────────────────────────────────── */
/* Required: CrossPoint supports Russian, Ukrainian, Bulgarian, etc.
Without these, Cyrillic chars fall to ON, breaking mixed Hebrew+Russian. */
{0x0400, 0x04FF, L}, /* Cyrillic */
{0x0500, 0x052F, L}, /* Cyrillic Supplement */
/* ── Hebrew vowel points / cantillation (NSM) */
/* Do NOT remove: niqqud must be NSM or pointed Hebrew breaks after reorder */
{0x0591, 0x05A1, NSM},
{0x05A3, 0x05B9, NSM},
{0x05BB, 0x05BD, NSM},
{0x05BE, 0x05BE, R}, /* maqaf (Hebrew hyphen) */
{0x05BF, 0x05BF, NSM},
{0x05C0, 0x05C0, R}, /* paseq */
{0x05C1, 0x05C2, NSM},
{0x05C3, 0x05C3, R}, /* sof pasuq */
{0x05C4, 0x05C4, NSM},
/* ── Hebrew letters ─────────────────────────────────────────────────── */
{0x05D0, 0x05EA, R}, /* alef … tav */
{0x05F0, 0x05F4, R}, /* alternative forms + geresh/gershayim */
/* ── Latin Extended Additional (L) ─────────────────────────────────── */
/* Covers accented chars for Vietnamese, Welsh, Romanian, etc.
Not currently rendered by CrossPoint fonts, but costs only 2 table rows. */
{0x1E00, 0x1EFF, L},
/* ── Unicode directional format characters ─────────────────────────── */
/* All must be present — UBA X-rules depend on them */
{0x200B, 0x200D, BN}, /* ZWSP, ZWNJ, ZWJ */
{0x200E, 0x200E, L}, /* LEFT-TO-RIGHT MARK */
{0x200F, 0x200F, R}, /* RIGHT-TO-LEFT MARK */
{0x2028, 0x2028, WS},
{0x2029, 0x2029, B},
{0x202A, 0x202A, LRE},
{0x202B, 0x202B, RLE},
{0x202C, 0x202C, PDF},
{0x202D, 0x202D, LRO},
{0x202E, 0x202E, RLO},
{0x202F, 0x202F, WS}, /* narrow no-break space */
{0x2060, 0x2063, BN},
/* Unicode 6.3 isolate markers */
{0x2066, 0x2066, LRI},
{0x2067, 0x2067, RLI},
{0x2068, 0x2068, FSI},
{0x2069, 0x2069, PDI},
{0x206A, 0x206F, BN},
/* ── Byte Order Mark ────────────────────────────────────────────────── */
{0xFEFF, 0xFEFF, BN},
+565
View File
@@ -0,0 +1,565 @@
/*
* minibidi.c — Unicode Bidirectional Algorithm (UAX #9) for CrossPoint/ESP32C3
*
* Original author: Ahmad Khalifa (www.arabeyes.org, MIT licence)
* Mintty changes: Thomas Wolff (rules N0, W7/L1/X9 fixes, isolates)
*
* UAX #9: https://www.unicode.org/reports/tr9/
*/
#include "minibidi.h"
#define leastGreaterOdd(x) (((x) + 1) | 1)
#define leastGreaterEven(x) (((x) + 2) & ~1)
/* ═══════════════════════════════════════════════════════════════════════
* flip_runs / find_run (UAX#9 rule L2)
* ═══════════════════════════════════════════════════════════════════════ */
static int find_run(uchar* levels, int start, int count, int tlevel) {
for (int i = start; i < count; i++)
if (tlevel <= levels[i]) return i;
return count;
}
static void flip_runs(bidi_char* from, uchar* levels, int tlevel, int count) {
int i = 0, j = 0;
while (i < count && j < count) {
i = j = find_run(levels, i, count, tlevel);
while (i < count && tlevel <= levels[i]) i++;
for (int k = i - 1; k > j; k--, j++) {
bidi_char tmp = from[k];
from[k] = from[j];
from[j] = tmp;
}
}
}
/* ═══════════════════════════════════════════════════════════════════════
* bidi_class()
* ═══════════════════════════════════════════════════════════════════════ */
uchar bidi_class(ucschar ch) {
static const struct {
ucschar first, last;
uchar type;
} lookup[] = {
#include "bidiclasses.t"
};
int i = -1, j = lengthof(lookup);
while (j - i > 1) {
int k = (i + j) / 2;
if (ch < lookup[k].first)
j = k;
else if (ch > lookup[k].last)
i = k;
else
return lookup[k].type;
}
return ON; /* correct UAX#9 fallback for unlisted characters */
}
/* ═══════════════════════════════════════════════════════════════════════
* Character class predicates
* ═══════════════════════════════════════════════════════════════════════ */
bool is_rtl_class(uchar bc) {
const int mask = (1 << R) | (1 << AL) | (1 << RLE) | (1 << RLO) | (1 << RLI) | (1 << FSI);
return (mask >> bc) & 1;
}
static inline bool is_NI(uchar bc) {
const int mask = (1 << B) | (1 << S) | (1 << WS) | (1 << ON) | (1 << FSI) | (1 << LRI) | (1 << RLI) | (1 << PDI);
return (mask >> bc) & 1;
}
/* ═══════════════════════════════════════════════════════════════════════
* Unified bracket + mirror table (bidi_pairs.t)
*
* Replaces both brackets.t and mirroring.t. canonical.t is dropped.
* ═══════════════════════════════════════════════════════════════════════ */
enum { BRACKx = 0, BRACKo = 1, BRACKc = 2 };
typedef struct {
ucschar from, to;
uchar bracket; /* BRACKo / BRACKc / BRACKx */
} bidi_pair;
static const bidi_pair pairs[] = {
#include "bidi_pairs.t"
};
/* Binary search over the pairs table */
static const bidi_pair* find_pair(ucschar c) {
int i = -1, j = lengthof(pairs);
while (j - i > 1) {
int k = (i + j) / 2;
if (c == pairs[k].from)
return &pairs[k];
else if (c < pairs[k].from)
j = k;
else
i = k;
}
return NULL;
}
/*
* bracket(c):
* 0 → not a bracket
* c → opening bracket
* opener → closing bracket (returns the matching opener)
*/
static ucschar bracket(ucschar c) {
const bidi_pair* p = find_pair(c);
if (!p || p->bracket == BRACKx) return 0;
return (p->bracket == BRACKo) ? c : p->to;
}
/*
* mirror(c): returns the mirrored form for rule L4,
* or c unchanged if not in the table.
*/
ucschar mirror(ucschar c) {
const bidi_pair* p = find_pair(c);
return p ? p->to : c;
}
/* ═══════════════════════════════════════════════════════════════════════
* Directional Status Stack
* (replaces GCC nested functions — ESP32C3 has no executable stack)
* ═══════════════════════════════════════════════════════════════════════ */
typedef struct {
uchar emb[BIDI_MAX_LINE + 1];
uchar ovr[BIDI_MAX_LINE + 1];
bool isol[BIDI_MAX_LINE + 1];
int top;
} DirStatusStack;
static inline void dss_init(DirStatusStack* s) { s->top = -1; }
static inline int dss_count(const DirStatusStack* s) { return s->top + 1; }
static inline void dss_push(DirStatusStack* s, uchar emb, uchar ovr, bool isol) {
if (s->top < BIDI_MAX_LINE) {
++s->top;
s->emb[s->top] = emb;
s->ovr[s->top] = ovr;
s->isol[s->top] = isol;
}
}
static inline void dss_pop(DirStatusStack* s, uchar* emb, uchar* ovr, bool* isol) {
if (s->top >= 0) s->top--;
if (s->top >= 0) {
*emb = s->emb[s->top];
*ovr = s->ovr[s->top];
*isol = s->isol[s->top];
} else {
/* Stack underflow: return safe defaults (should not happen in valid input) */
*emb = 0; /* LTR base level */
*ovr = ON; /* No override */
*isol = false; /* No isolate */
}
}
/* ═══════════════════════════════════════════════════════════════════════
* do_bidi() — The main UAX#9 algorithm
* ═══════════════════════════════════════════════════════════════════════ */
int do_bidi(bool autodir, int paragraphLevel, bidi_char* line, int count) {
if (count > BIDI_MAX_LINE) count = BIDI_MAX_LINE;
uchar currentEmbedding, currentOverride;
bool currentIsolate;
int i, j;
/* Fixed-size working arrays — no VLAs, no heap */
uchar types[BIDI_MAX_LINE];
uchar levels[BIDI_MAX_LINE];
bool skip[BIDI_MAX_LINE];
/* ── P2/P3: detect paragraph level ── */
int isolateLevel = 0, resLevel = -1;
bool hasRTL = false;
for (i = 0; i < count; i++) {
uchar type = bidi_class(line[i].wc);
if (type == LRI || type == RLI || type == FSI) {
hasRTL = true;
isolateLevel++;
} else if (type == PDI) {
hasRTL = true;
if (isolateLevel > 0) isolateLevel--;
} else if (isolateLevel == 0) {
if (type == R || type == AL) {
hasRTL = true;
if (resLevel < 0) resLevel = 1;
break;
} else if (type == RLE || type == LRE || type == RLO || type == LRO || type == PDF) {
hasRTL = true;
if (resLevel >= 0) break;
} else if (type == L) {
if (resLevel < 0) resLevel = 0;
} else if (type == AN)
hasRTL = true;
}
}
if (autodir) {
if (resLevel >= 0) paragraphLevel = resLevel;
} else
resLevel = paragraphLevel;
/* Fast path: pure LTR line with LTR paragraph — nothing to reorder */
if (!hasRTL && !paragraphLevel) return 0;
/* ── X1X8: compute embedding levels ── */
currentEmbedding = (uchar)paragraphLevel;
currentOverride = ON;
currentIsolate = false;
isolateLevel = 0;
DirStatusStack dss;
dss_init(&dss);
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
for (i = 0; i < count; i++) {
uchar tempType = bidi_class(line[i].wc);
levels[i] = currentEmbedding;
/* FSI: look-ahead to resolve direction */
if (tempType == FSI) {
int lvl = 0;
tempType = LRI;
for (int k = i + 1; k < count; k++) {
uchar kt = bidi_class(line[k].wc);
if (kt == FSI || kt == RLI || kt == LRI)
lvl++;
else if (kt == PDI) {
if (lvl)
lvl--;
else
break;
} else if (kt == R || kt == AL) {
tempType = RLI;
break;
} else if (kt == L)
break;
}
}
switch (tempType) {
when RLE : currentEmbedding = leastGreaterOdd(currentEmbedding);
currentOverride = ON;
currentIsolate = false;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when LRE : currentEmbedding = leastGreaterEven(currentEmbedding);
currentOverride = ON;
currentIsolate = false;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when RLO : currentEmbedding = leastGreaterOdd(currentEmbedding);
currentOverride = R;
currentIsolate = false;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when LRO : currentEmbedding = leastGreaterEven(currentEmbedding);
currentOverride = L;
currentIsolate = false;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when RLI : if (currentOverride != ON) tempType = currentOverride;
currentEmbedding = leastGreaterOdd(currentEmbedding);
isolateLevel++;
currentOverride = ON;
currentIsolate = true;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when LRI : if (currentOverride != ON) tempType = currentOverride;
currentEmbedding = leastGreaterEven(currentEmbedding);
isolateLevel++;
currentOverride = ON;
currentIsolate = true;
dss_push(&dss, currentEmbedding, currentOverride, currentIsolate);
when PDF : if (!currentIsolate && dss_count(&dss) >= 2)
dss_pop(&dss, &currentEmbedding, &currentOverride, &currentIsolate);
levels[i] = currentEmbedding;
when PDI : if (isolateLevel > 0) {
while (!currentIsolate && dss_count(&dss) > 0)
dss_pop(&dss, &currentEmbedding, &currentOverride, &currentIsolate);
dss_pop(&dss, &currentEmbedding, &currentOverride, &currentIsolate);
isolateLevel--;
}
if (currentOverride != ON) tempType = currentOverride;
levels[i] = currentEmbedding;
when WS : case S:
if (currentOverride != ON) tempType = currentOverride;
otherwise : if (currentOverride != ON) tempType = currentOverride;
}
types[i] = tempType;
}
/* ── X9: mask format chars as NSM (Wolff fix: NSM not BN) ── */
for (i = 0; i < count; i++) {
switch (types[i]) {
when RLE : case LRE:
case RLO:
case LRO:
case PDF:
case BN:
types[i] = NSM;
skip[i] = true;
otherwise:
skip[i] = false;
}
}
/* ── W1: NSM inherits type of previous char (or sor) ── */
if (types[0] == NSM) types[0] = (paragraphLevel & 1) ? R : L;
for (i = 1; i < count; i++) {
if (types[i] == NSM) {
switch (types[i - 1]) {
when LRI : case RLI:
case FSI:
case PDI:
types[i] = ON;
otherwise : types[i] = types[i - 1];
}
}
}
/* ── W2: EN after AL → AN ── */
for (i = 0; i < count; i++) {
if (types[i] == EN) {
for (j = i - 1; j >= 0; j--) {
uchar t = types[j];
if (t == AL) {
types[i] = AN;
break;
}
if (t == R || t == L) break;
}
}
}
/* ── W3: AL → R ── */
for (i = 0; i < count; i++)
if (types[i] == AL) types[i] = R;
/* ── W4: single ES/CS between same numerals → that numeral type ── */
for (i = 1; i + 1 < count; i++) {
if (types[i] == ES || types[i] == CS) {
int prev = i - 1;
while (prev >= 0 && skip[prev]) prev--;
int next = i + 1;
while (next < count && skip[next]) next++;
if (prev >= 0 && next < count) {
if (types[i] == ES && types[prev] == EN && types[next] == EN) types[i] = EN;
if (types[i] == CS) {
if (types[prev] == EN && types[next] == EN) types[i] = EN;
if (types[prev] == AN && types[next] == AN) types[i] = AN;
}
}
}
}
/* ── W5: ET adjacent to EN → EN (forward pass) ── */
for (i = 0; i < count; i++) {
if (skip[i] || types[i] != ET) continue;
for (j = i; j < count; j++) {
if (skip[j]) continue;
if (types[j] == ET) continue;
if (types[j] == EN) types[i] = EN;
break;
}
}
/* W5 backward pass */
for (i = count - 1; i >= 0; i--) {
if (skip[i] || types[i] != ET) continue;
for (j = i; j >= 0; j--) {
if (skip[j]) continue;
if (types[j] == ET) continue;
if (types[j] == EN) types[i] = EN;
break;
}
}
/* ── W6: remaining ES, ET, CS → ON ── */
for (i = 0; i < count; i++)
if (types[i] == ES || types[i] == ET || types[i] == CS) types[i] = ON;
/* ── W7: EN after last strong L (back to sor) → L ── */
{
uchar last_strong = (paragraphLevel & 1) ? R : L;
for (i = 0; i < count; i++) {
if (skip[i]) continue;
if (types[i] == L || types[i] == R) last_strong = types[i];
if (types[i] == EN && last_strong == L) types[i] = L;
}
}
/* ── N0: bracket pair handling ── */
{
uchar e = (paragraphLevel & 1) ? R : L;
uchar o = (e == L) ? R : L;
#define BRACKET_STACK 63
struct {
ucschar opener;
int pos;
} openers[BRACKET_STACK];
int opener_top = 0;
for (i = 0; i < count; i++) {
if (skip[i]) continue;
ucschar bc = bracket(line[i].wc);
if (!bc) continue;
if (bc == line[i].wc) {
/* Opening bracket */
if (opener_top < BRACKET_STACK) {
openers[opener_top].opener = line[i].wc;
openers[opener_top].pos = i;
opener_top++;
}
} else {
/* Closing bracket: find matching opener */
int k;
for (k = opener_top - 1; k >= 0; k--)
if (openers[k].opener == bc) break;
if (k < 0) continue;
int open_pos = openers[k].pos;
opener_top = k;
bool found_e = false, found_o = false;
for (int m = open_pos + 1; m < i; m++) {
if (skip[m]) continue;
uchar t = types[m];
if (t == EN || t == AN) t = R;
if (t == R || t == AL) {
if (e == R)
found_e = true;
else
found_o = true;
} else if (t == L) {
if (e == L)
found_e = true;
else
found_o = true;
}
}
uchar dir;
if (found_e) {
dir = e;
} else if (found_o) {
uchar ctx = e;
for (int m = open_pos - 1; m >= 0; m--) {
if (skip[m]) continue;
uchar t = types[m];
if (t == EN || t == AN) t = R;
if (t == R || t == AL) {
ctx = R;
break;
} else if (t == L) {
ctx = L;
break;
}
}
dir = (ctx == o) ? o : e;
} else {
continue;
}
types[open_pos] = dir;
types[i] = dir;
for (int m = open_pos + 1; m < i; m++)
if (is_NI(types[m])) types[m] = dir;
}
}
#undef BRACKET_STACK
}
/* ── N1: NI between same-direction strongs → that direction ── */
for (i = 0; i < count; i++) {
if (skip[i] || !is_NI(types[i])) continue;
int end = i;
while (end + 1 < count && (skip[end + 1] || is_NI(types[end + 1]))) end++;
uchar prev_strong = (paragraphLevel & 1) ? R : L;
for (j = i - 1; j >= 0; j--) {
if (skip[j]) continue;
uchar t = types[j];
if (t == EN || t == AN) t = R;
if (t == R || t == L) {
prev_strong = t;
break;
}
}
uchar next_strong = (paragraphLevel & 1) ? R : L;
for (j = end + 1; j < count; j++) {
if (skip[j]) continue;
uchar t = types[j];
if (t == EN || t == AN) t = R;
if (t == R || t == L) {
next_strong = t;
break;
}
}
if (prev_strong == next_strong)
for (j = i; j <= end; j++) types[j] = prev_strong;
i = end;
}
/* ── N2: remaining NI → embedding direction ── */
for (i = 0; i < count; i++)
if (is_NI(types[i])) types[i] = (levels[i] & 1) ? R : L;
/* ── I1/I2: adjust levels ── */
for (i = 0; i < count; i++) {
if (skip[i]) continue;
if ((levels[i] & 1) == 0) {
if (types[i] == R)
levels[i] += 1;
else if (types[i] == AN || types[i] == EN)
levels[i] += 2;
} else {
if (types[i] == L || types[i] == EN || types[i] == AN) levels[i] += 1;
}
}
/* ── L1: reset trailing/segment whitespace to paragraph level ── */
for (i = count - 1; i >= 0; i--) {
if (skip[i]) continue;
uchar t = types[i];
if (t == WS || t == S || t == B)
levels[i] = (uchar)paragraphLevel;
else
break;
}
for (i = 0; i < count; i++) {
if (types[i] == S) {
levels[i] = (uchar)paragraphLevel;
for (j = i - 1; j >= 0; j--) {
if (skip[j]) continue;
if (types[j] == WS || types[j] == BN)
levels[j] = (uchar)paragraphLevel;
else
break;
}
}
}
/* ── L2: reverse from highest level down to lowest odd ── */
uchar max_level = (uchar)paragraphLevel, min_odd = 255;
for (i = 0; i < count; i++) {
if (levels[i] > max_level) max_level = levels[i];
if ((levels[i] & 1) && levels[i] < min_odd) min_odd = levels[i];
}
for (int level = max_level; level >= (int)min_odd; level--) flip_runs(line, levels, level, count);
/* ── L4: mirror characters in RTL runs ── */
for (i = 0; i < count; i++)
if (levels[i] & 1) line[i].wc = mirror(line[i].wc);
return paragraphLevel;
}
+115
View File
@@ -0,0 +1,115 @@
#ifndef MINIBIDI_H
#define MINIBIDI_H
/*
* minibidi.h — standalone header for ESP32C3 BiDi calculations
*
* Derived from [mintty](https://github.com/mintty/mintty/) (Thomas Wolff, MIT licence).
* Stripped of: Arabic shaping, box-drawing mirror, terminal dependencies,
* GCC nested functions, VLAs, and non-Hebrew/English Unicode data.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* ── Basic types ─────────────────────────────────────────────────────── */
typedef uint8_t uchar;
typedef uint32_t ucschar; /* Unicode codepoint; BMP-only content fits uint16_t
but uint32_t is safer and ESP32C3 is 32-bit anyway */
/* ── Convenience macros ──────────────────────────────────────────────── */
#define lengthof(a) ((int)(sizeof(a) / sizeof(*(a))))
/* PuTTY/mintty switch-case style — kept for readability of algorithm */
#define when \
break; \
case
#define otherwise \
break; \
default
/* Maximum line length the algorithm will process.
Adjust to your actual screen width. Stack cost = ~5×MAX bytes. */
#define BIDI_MAX_LINE 128
/* ── bidi_char ───────────────────────────────────────────────────────── */
/* origwc: the codepoint as it came from the epub text stream
wc: working codepoint (may be replaced by mirrored form after do_bidi)
index: original logical position, so the caller can reorder glyphs */
typedef struct {
ucschar origwc;
ucschar wc;
uint16_t index;
} bidi_char;
/* ── Bidi character classes (UAX #9) ────────────────────────────────── */
enum {
L, /* Left-to-Right */
LRE, /* Left-to-Right Embedding */
LRO, /* Left-to-Right Override */
R, /* Right-to-Left */
AL, /* Right-to-Left Arabic */
RLE, /* Right-to-Left Embedding */
RLO, /* Right-to-Left Override */
PDF, /* Pop Directional Format */
EN, /* European Number */
ES, /* European Number Separator */
ET, /* European Number Terminator */
AN, /* Arabic Number */
CS, /* Common Number Separator */
NSM, /* Non-Spacing Mark */
BN, /* Boundary Neutral */
B, /* Paragraph Separator */
S, /* Segment Separator */
WS, /* Whitespace */
ON, /* Other Neutrals */
/* Unicode 6.3 isolate types */
LRI, /* Left-to-Right Isolate */
RLI, /* Right-to-Left Isolate */
FSI, /* First Strong Isolate */
PDI, /* Pop Directional Isolate */
};
/* ── Public API ──────────────────────────────────────────────────────── */
/*
* bidi_class(ch)
* Returns the UAX#9 bidi class of Unicode codepoint ch.
* Unknown characters return ON (correct per spec).
*/
uchar bidi_class(ucschar ch);
/*
* is_rtl_class(bc)
* Returns true if bidi class bc can cause RTL reordering.
* Use to fast-skip lines with no RTL content.
*/
bool is_rtl_class(uchar bc);
/*
* mirror(ch)
* Returns the mirrored form of Unicode codepoint ch for UAX#9 rule L4.
* If no mirror exists, returns ch unchanged.
*/
ucschar mirror(ucschar ch);
/*
* do_bidi(autodir, paragraphLevel, line, count)
*
* Applies UAX#9 Bidirectional Algorithm (rules PL) to `line[0..count-1]`.
* Reorders the array in-place; sets line[i].wc to the mirrored form where
* required (rule L4). Returns the resolved paragraph level (0=LTR, 1=RTL),
* or 0 if the line was left-to-right and no reordering was done.
*
* autodir: true → detect paragraph direction from content (P2/P3)
* false → use paragraphLevel as-is
* paragraphLevel: 0 = LTR, 1 = RTL. Ignored when autodir=true unless
* the content has no strong type (used as fallback).
*
* count must be ≤ BIDI_MAX_LINE; lines longer than that are silently
* truncated to BIDI_MAX_LINE before processing.
*/
int do_bidi(bool autodir, int paragraphLevel, bidi_char* line, int count);
#endif /* MINIBIDI_H */