feat: Arabic/Farsi/Urdu bidi reordering and contextual shaping — PR 1/3 (#2541)

Co-authored-by: Uri Tauber <uritaube@gmail.com>
This commit is contained in:
Husam Younis
2026-07-13 16:02:21 +03:00
committed by GitHub
co-authored by Uri Tauber
parent 552b2683e6
commit 932a472835
18 changed files with 1087 additions and 62 deletions
+9 -3
View File
@@ -90,13 +90,19 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 29
### Version 30
Each file in `sections/*.bin` stores one laid-out spine section. The header is
also the cache-busting key: if any layout-affecting setting differs from the
current reader settings, the section is discarded and rebuilt.
Version 29 includes:
Version 30 is binary-identical to version 29. The version was bumped because
Arabic contextual shaping changed text measurement (`getTextAdvanceX` now
measures the shaped visual text), so word positions cached by v29 no longer
match what `drawText` renders.
Version 28 introduced serialized word style bits for underline, strikethrough,
superscript, and subscript. The format also includes:
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
image rendering mode, and Focus Reading
@@ -119,7 +125,7 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 29
#define EXPECTED_VERSION 30
#define MAX_STRING_LENGTH 65535
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 96
+5 -4
View File
@@ -44,16 +44,17 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0;
const combiningMark::Anchor anchor = combiningMark::anchorFor(cp);
const int raiseBy = isCombining ? combiningMark::raiseAboveBase(anchor, glyph->top, glyph->height, lastBaseTop) : 0;
if (!isCombining && prevCp != 0) {
const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern
lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP);
}
const int glyphBaseX =
isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width)
: lastBaseX;
const int glyphBaseX = isCombining ? combiningMark::anchorOver(anchor, lastBaseX, lastBaseLeft, lastBaseWidth,
glyph->left, glyph->width)
: lastBaseX;
const int glyphBaseY = startY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
+76 -11
View File
@@ -36,24 +36,71 @@ namespace combiningMark {
constexpr int MIN_GAP_PX = 1;
/// Compute the cursor-X at which to render a combining mark so its bitmap
/// is visually centered over the base glyph's bitmap.
constexpr int centerOver(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos + baseLeft + baseWidth / 2 - markWidth / 2 - markLeft;
/// Placement of a mark relative to its base glyph. The default heuristic —
/// centered over the base, raised clear of its top — suits Latin diacritics
/// and Arabic harakat, but misplaces the Hebrew niqqud whose identity depends
/// on position: dagesh sits inside the letter body, the shin/sin dots
/// distinguish the letter by sitting over its right/left arm, and holam hangs
/// over the left corner. "Native" anchors keep the glyph's font-designed
/// height (which may overlap the base) instead of raising it.
enum class Anchor : uint8_t {
CenterRaised, ///< centered over the base, lifted above its top (default)
CenterNative, ///< centered over the base at font-native height
RightNative, ///< right edges aligned, font-native height
LeftNative, ///< left edges aligned, font-native height
};
constexpr Anchor anchorFor(const uint32_t cp) {
switch (cp) {
case 0x05BC: // dagesh / mapiq / shuruk dot: inside the letter body
case 0x05BA: // holam haser for vav: straight above the vav stem
return Anchor::CenterNative;
case 0x05C1: // shin dot: over the letter's right arm
return Anchor::RightNative;
case 0x05B9: // holam: above the letter's left corner
case 0x05C2: // sin dot: over the letter's left arm
return Anchor::LeftNative;
default:
return Anchor::CenterRaised;
}
}
/// Rotated-90CW variant of centerOver. In the rotated coordinate system
/// Horizontal offset from the base bitmap's left edge to the mark bitmap's
/// left edge for a given anchor.
constexpr int anchorShift(const Anchor anchor, const int baseWidth, const int markWidth) {
switch (anchor) {
case Anchor::LeftNative:
return 0;
case Anchor::RightNative:
return baseWidth - markWidth;
default:
return baseWidth / 2 - markWidth / 2;
}
}
/// Compute the cursor-X at which to render a combining mark so its bitmap
/// lands at its anchor position over the base glyph's bitmap.
constexpr int anchorOver(const Anchor anchor, const int baseCursorPos, const int baseLeft, const int baseWidth,
const int markLeft, const int markWidth) {
return baseCursorPos + baseLeft + anchorShift(anchor, baseWidth, markWidth) - markLeft;
}
/// Rotated-90CW variant of anchorOver. In the rotated coordinate system
/// renderCharImpl uses (cursorY - left) instead of (cursorX + left), so
/// every left/width term inverts sign.
constexpr int centerOverRotated90CW(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos - baseLeft - baseWidth / 2 + markWidth / 2 + markLeft;
constexpr int anchorOverRotated90CW(const Anchor anchor, const int baseCursorPos, const int baseLeft,
const int baseWidth, const int markLeft, const int markWidth) {
return baseCursorPos - baseLeft - anchorShift(anchor, baseWidth, markWidth) + markLeft;
}
/// For combining marks that sit entirely above the baseline, compute how many
/// pixels to raise the mark so there is at least MIN_GAP_PX between its bottom
/// edge and the top of the base glyph. Returns 0 for marks that extend to or
/// below the baseline (e.g. cedilla, dot-below, ogonek).
constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) {
/// below the baseline (e.g. cedilla, dot-below, ogonek) and for anchors that
/// keep the font-native height (dagesh must stay inside the letter, the
/// shin/sin dots touch its arms).
constexpr int raiseAboveBase(const Anchor anchor, const int markTop, const int markHeight, const int baseTop) {
if (anchor != Anchor::CenterRaised) return 0;
if (markTop - markHeight <= 0) return 0;
const int gap = markTop - markHeight - baseTop;
return (gap < MIN_GAP_PX) ? (MIN_GAP_PX - gap) : 0;
@@ -61,6 +108,20 @@ constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) {
} // namespace combiningMark
/// GCC/Clang (the ESP32 firmware toolchain) pack structs with __attribute__((packed)).
/// MSVC (host unit tests) has no equivalent attribute and instead needs a #pragma pack
/// region achieving the same 1-byte alignment. These macros keep the on-disk font layout
/// identical across both toolchains.
#if defined(_MSC_VER)
#define EPD_PACKED_BEGIN __pragma(pack(push, 1))
#define EPD_PACKED_END __pragma(pack(pop))
#define EPD_PACKED_ATTR
#else
#define EPD_PACKED_BEGIN
#define EPD_PACKED_END
#define EPD_PACKED_ATTR __attribute__((packed))
#endif
/// Fixed-point conventions used by EpdGlyph and EpdFontData:
/// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel)
/// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel)
@@ -95,17 +156,21 @@ typedef struct {
/// Maps a codepoint to a kerning class ID, sorted by codepoint for binary search.
/// Class IDs are 1-based; codepoints not in the table have implicit class 0 (no kerning).
EPD_PACKED_BEGIN
typedef struct {
uint16_t codepoint; ///< Unicode codepoint
uint8_t classId; ///< 1-based kerning class ID
} __attribute__((packed)) EpdKernClassEntry;
} EPD_PACKED_ATTR EpdKernClassEntry;
EPD_PACKED_END
/// Ligature substitution for a specific glyph pair, sorted by `pair` for binary search.
/// `pair` encodes (leftCodepoint << 16 | rightCodepoint) for single-key lookup.
EPD_PACKED_BEGIN
typedef struct {
uint32_t pair; ///< Packed codepoint pair (left << 16 | right)
uint32_t ligatureCp; ///< Codepoint of the replacement ligature glyph
} __attribute__((packed)) EpdLigaturePair;
} EPD_PACKED_ATTR EpdLigaturePair;
EPD_PACKED_END
/// Data stored for FONT AS A WHOLE
typedef struct {
+10 -5
View File
@@ -1195,7 +1195,8 @@ int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCoun
}
template <typename Iter>
int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask) {
int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask,
const char* extraText) {
if (!loaded_) return -1;
styleMask = resolveStyleMask(styleMask);
if (styleMask == 0) return 0;
@@ -1215,6 +1216,9 @@ int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace,
for (auto it = begin; it != end && !hitCap; ++it) {
hitCap = collectUniqueCodepoints(asCStr(*it), codepoints, cpCount, MAX_UNIQUE_CODEPOINTS);
}
if (extraText && !hitCap) {
hitCap = collectUniqueCodepoints(extraText, codepoints, cpCount, MAX_UNIQUE_CODEPOINTS);
}
if (includeSpace && std::none_of(codepoints, codepoints + cpCount, [](uint32_t c) { return c == ' '; }))
codepoints[cpCount++] = ' ';
@@ -1232,12 +1236,13 @@ int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace,
return totalMissed;
}
int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) {
return buildAdvanceTableRange(&utf8Text, &utf8Text + 1, false, false, styleMask);
int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask, const char* extraText) {
return buildAdvanceTableRange(&utf8Text, &utf8Text + 1, false, false, styleMask, extraText);
}
int SdCardFont::buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask) {
return buildAdvanceTableRange(words.begin(), words.end(), words.size() > 1, includeHyphen, styleMask);
int SdCardFont::buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask,
const char* extraText) {
return buildAdvanceTableRange(words.begin(), words.end(), words.size() > 1, includeHyphen, styleMask, extraText);
}
// --- Stats ---
+10 -4
View File
@@ -47,9 +47,12 @@ class SdCardFont {
// Build a compact advance-only table for layout measurement.
// Extracts ALL unique codepoints from words (no MAX_PAGE_GLYPHS cap),
// batch-reads advanceX from SD, stores in a sorted per-style table.
// extraText: optional additional codepoints to warm in the same SD pass
// (e.g. shaped Arabic presentation forms the measurement path will look up).
// Returns number of codepoints not found in font coverage.
int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F);
int buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask = 0x0F);
int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F, const char* extraText = nullptr);
int buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask = 0x0F,
const char* extraText = nullptr);
// Look up advanceX for a codepoint from the advance table.
// Returns the 12.4 fixed-point advance, or 0 if not found.
@@ -140,11 +143,13 @@ class SdCardFont {
// Full intervals loaded from file (kept in RAM for codepoint lookup)
EpdUnicodeInterval* fullIntervals = nullptr;
EPD_PACKED_BEGIN
struct BmpInterval16 {
uint16_t first;
uint16_t last;
uint16_t offset;
} __attribute__((packed));
} EPD_PACKED_ATTR;
EPD_PACKED_END
static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact");
BmpInterval16* bmpIntervals = nullptr;
bool intervalsAreBmp16 = false;
@@ -249,7 +254,8 @@ class SdCardFont {
int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const;
int fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCount, uint8_t styleMask);
template <typename Iter>
int buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask);
int buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask,
const char* extraText = nullptr);
int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly);
// Global helpers
+5 -1
View File
@@ -11,9 +11,13 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// v28: text decoration bits now include line-through in serialized wordStyles.
// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated
// text blob) instead of length-prefixed strings and per-field arrays.
constexpr uint8_t SECTION_FILE_VERSION = 29;
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
// measures the shaped visual text); cached word positions from v29 no longer
// match what drawText renders.
constexpr uint8_t SECTION_FILE_VERSION = 30;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
+81 -21
View File
@@ -25,6 +25,36 @@ uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style st
namespace {
const char* resolveVisualText(const char* text, std::string& visualBuffer, BidiUtils::BidiBaseDir baseDir);
// Appends the shaped visual form of every RTL token in `text` to `shapedOut`.
// getTextAdvanceX() measures the bidi-reordered, Arabic-shaped codepoint stream,
// so the SD advance table must be warmed with the presentation forms as well as
// the logical codepoints — otherwise every RTL word measurement misses the fast
// path and falls through to onGlyphMiss(), which opens the .cpfont and reads
// glyph metadata + bitmap into the 8-slot overflow ring, once per glyph.
// Tokens without RTL lead bytes (0xD6-0xDB) are skipped with a byte scan, so
// pure-LTR text pays almost nothing.
void appendShapedRtlTokens(const char* text, std::string& shapedOut) {
const auto isBreak = [](const char c) { return c == ' ' || c == '\n' || c == '\r' || c == '\t'; };
std::string token;
std::string visual;
const char* p = text;
while (*p) {
while (*p && isBreak(*p)) ++p;
const char* start = p;
bool hasRtlBytes = false;
while (*p && !isBreak(*p)) {
const auto b = static_cast<unsigned char>(*p);
hasRtlBytes = hasRtlBytes || (b >= 0xD6 && b <= 0xDB);
++p;
}
if (!hasRtlBytes) continue;
token.assign(start, p - start);
if (BidiUtils::applyBidiVisual(token.c_str(), visual, static_cast<int>(BidiUtils::BidiBaseDir::AUTO))) {
shapedOut += visual;
}
}
}
} // namespace
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
@@ -58,7 +88,9 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask) const {
auto it = sdCardFonts_.find(fontId);
if (it != sdCardFonts_.end()) {
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
std::string shaped;
appendShapedRtlTokens(utf8Text, shaped);
int missed = it->second->buildAdvanceTable(utf8Text, styleMask, shaped.empty() ? nullptr : shaped.c_str());
if (missed > 0) {
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
}
@@ -72,7 +104,12 @@ void GfxRenderer::ensureSdCardFontReady(int fontId, const std::vector<std::strin
// Augment the persistent advance-only table for layout measurement.
// The table survives across paragraphs/sections (capped per font), so
// repeated indexing of the same SD font amortizes glyph-metric SD reads.
int missed = it->second->buildAdvanceTable(words, includeHyphen, styleMask);
std::string shaped;
for (const auto& w : words) {
appendShapedRtlTokens(w.c_str(), shaped);
}
int missed =
it->second->buildAdvanceTable(words, includeHyphen, styleMask, shaped.empty() ? nullptr : shaped.c_str());
if (missed > 0) {
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
}
@@ -460,18 +497,21 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&textCursor)))) {
// Skip Hebrew Niqqud (vowel marks)
// Temporary: avoid adding Niqqud to built-in fonts. Remove when custom fonts are supported.
if (cp >= 0x0591 && cp <= 0x05C7) {
continue;
}
if (utf8IsCombiningMark(cp)) {
// RTL vowel marks (Hebrew niqqud, Arabic harakat) ride the combining-mark
// path: zero-advance overlays on the preceding base glyph (applyBidiVisual
// emits base-then-marks per UAX#9 L3). anchorFor pins position-sensitive
// niqqud (dagesh, shin/sin dots, holam) to their spot on the base; other
// marks stay centered, raised above the base or (kasra) at their
// font-native position. Fonts without their glyphs — the built-ins — miss
// the getGlyph lookup and skip them, as before.
if (utf8IsCombiningMark(cp) || BidiUtils::isTransparentMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const int combiningX = combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, combiningGlyph->left,
combiningGlyph->width);
const auto anchor = combiningMark::anchorFor(cp);
const int raiseBy =
combiningMark::raiseAboveBase(anchor, combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const int combiningX = combiningMark::anchorOver(anchor, lastBaseX, lastBaseLeft, lastBaseWidth,
combiningGlyph->left, combiningGlyph->width);
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, yPos - raiseBy, black, style);
continue;
}
@@ -1657,6 +1697,15 @@ int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint3
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
// Measure the exact codepoint stream drawText renders: bidi-reordered and
// Arabic-shaped (contextual presentation forms, Lam-Alef collapse).
// Measuring the raw logical text counts the Alef a ligature absorbs and
// uses base-letter advances instead of presentation-form advances, so RTL
// lines come out wider than they draw — uneven word gaps and a ragged
// right margin.
std::string visual;
text = resolveVisualText(text, visual, BidiUtils::BidiBaseDir::AUTO);
// Advance table fast-path for SD card fonts during layout.
// No kerning/ligature lookup — consistent with previous metadataOnly behavior
// where kern/lig data was not loaded.
@@ -1672,6 +1721,10 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
const auto& font = fontIt->second;
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
// RTL vowel marks (niqqud/harakat) are zero-advance overlays in drawText — no width.
if (BidiUtils::isTransparentMark(cp)) {
continue;
}
int32_t advFP = sdIt->second->getAdvance(cp, styleIdx);
if (advFP == 0 && !utf8IsCombiningMark(cp)) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
@@ -1694,6 +1747,10 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
// RTL vowel marks (niqqud/harakat) are zero-advance overlays in drawText — no width.
if (BidiUtils::isTransparentMark(cp)) {
continue;
}
if (utf8IsCombiningMark(cp)) {
continue;
}
@@ -1770,18 +1827,21 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
// Skip Hebrew Niqqud (vowel marks)
// Temporary: avoid adding Niqqud to built-in fonts. Remove when custom fonts are supported.
if (cp >= 0x0591 && cp <= 0x05C7) {
continue;
}
if (utf8IsCombiningMark(cp)) {
// RTL vowel marks (Hebrew niqqud, Arabic harakat) ride the combining-mark
// path: zero-advance overlays on the preceding base glyph (applyBidiVisual
// emits base-then-marks per UAX#9 L3). anchorFor pins position-sensitive
// niqqud (dagesh, shin/sin dots, holam) to their spot on the base; other
// marks stay centered, raised above the base or (kasra) at their
// font-native position. Fonts without their glyphs — the built-ins — miss
// the getGlyph lookup and skip them, as before.
if (utf8IsCombiningMark(cp) || BidiUtils::isTransparentMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const auto anchor = combiningMark::anchorFor(cp);
const int raiseBy =
combiningMark::raiseAboveBase(anchor, combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const int combiningX = x - raiseBy;
const int combiningY = combiningMark::centerOverRotated90CW(lastBaseY, lastBaseLeft, lastBaseWidth,
const int combiningY = combiningMark::anchorOverRotated90CW(anchor, lastBaseY, lastBaseLeft, lastBaseWidth,
combiningGlyph->left, combiningGlyph->width);
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
continue;
+73 -1
View File
@@ -11,6 +11,13 @@ extern "C" {
#include <Utf8.h>
#include <cstring>
#include <mutex>
// Guards the static bidi_char buffers in applyBidiVisual() and
// computeVisualWordOrder(). The bidi+shaping pipeline is not reentrant;
// this mutex serialises access so multi-core callers don't corrupt each
// other's intermediate state.
static std::mutex bidiMutex;
namespace {
@@ -68,11 +75,25 @@ int detectParagraphLevel(const char* utf8, const int fallbackLevel, const int ma
return fallbackLevel & 1;
}
bool isTransparentMark(const uint32_t cp) {
// RTL-script combining marks: Hebrew niqqud/cantillation and Arabic
// harakat/Quranic annotation. Transparent for Arabic joining (do_shape
// skips them), zero-advance for measurement, and rendered as overlays on
// the preceding base glyph when the active font carries their glyphs.
// The cp >= 0x0591 guard keeps Latin combining marks (U+0300-U+036F, also
// NSM) on their existing utf8IsCombiningMark() rendering path.
return cp >= 0x0591 && bidi_class(cp) == NSM;
}
bool applyBidiVisual(const char* utf8, std::string& out, int paragraphLevel) {
if (!utf8 || !*utf8) return false;
const std::lock_guard<std::mutex> lock(bidiMutex);
static bidi_char line[BIDI_MAX_LINE];
static bidi_char shaped[BIDI_MAX_LINE];
int count = 0;
int lastBase = -1; // last non-formatter character (mintty's ibase)
uint8_t pendingJoiners = 0; // ZWJ/ZWNJ seen since lastBase
auto* p = reinterpret_cast<const unsigned char*>(utf8);
while (*p) {
if (count >= BIDI_MAX_LINE) {
@@ -84,18 +105,66 @@ bool applyBidiVisual(const char* utf8, std::string& out, int paragraphLevel) {
if (!cp || cp == REPLACEMENT_GLYPH) break;
line[count].origwc = line[count].wc = cp;
line[count].index = static_cast<uint16_t>(count);
line[count].joiners = 0;
// Flag Arabic joining formatters mintty-style (termline.c): the ZWJ/ZWNJ
// goes into the low nibble of the character it follows and the high
// nibble of the character it precedes. Flags are assigned in logical
// order here; do_shape() reads them after reordering.
if (cp == 0x200C || cp == 0x200D) {
const uint8_t joiner = (cp == 0x200D) ? ZWJ : ZWNJ;
if (lastBase >= 0) line[lastBase].joiners |= joiner;
pendingJoiners |= joiner;
} else {
line[count].joiners = pendingJoiners << 4;
pendingJoiners = 0;
lastBase = count;
}
count++;
}
if (!count) return false;
const bool autodir = (paragraphLevel < 0);
const int level = autodir ? 0 : (paragraphLevel & 1);
// Order matters (mintty does the same): do_bidi() first to obtain visual
// order, then do_shape() — contextual forms are resolved from *visual*
// adjacency, and shaping presentation forms must never be reordered.
do_bidi(autodir, level, line, count);
do_shape(line, shaped, count);
out.clear();
out.reserve(std::strlen(utf8));
// Lam-Alef collapse sentinel and zero-width joining formatters have done
// their job during shaping and have no glyphs to render.
const auto filtered = [](const uint32_t cp) { return cp == LIGATURE_PLACEHOLDER || cp == 0x200C || cp == 0x200D; };
for (int i = 0; i < count; i++) {
utf8AppendCodepoint(line[i].wc, out);
const uint32_t cp = shaped[i].wc;
if (filtered(cp)) continue;
if (!isTransparentMark(cp)) {
utf8AppendCodepoint(cp, out);
continue;
}
// UAX#9 rule L3: reversing an RTL run leaves combining marks *before*
// their base character. The renderer overlays a mark on the most
// recently drawn glyph, so emit the base first, then its marks in
// logical order. `index` is the original logical position: a base
// following its marks with a *lower* index means the run was reversed.
int j = i; // [i, j) = the run of marks (and filtered entries)
while (j < count && (filtered(shaped[j].wc) || isTransparentMark(shaped[j].wc))) j++;
if (j < count && shaped[j].index < shaped[i].index) {
utf8AppendCodepoint(shaped[j].wc, out);
for (int k = j - 1; k >= i; k--) {
if (isTransparentMark(shaped[k].wc)) utf8AppendCodepoint(shaped[k].wc, out);
}
i = j; // base already emitted
} else {
// Unreversed (or trailing, base-less) marks already follow their base.
for (int k = i; k < j; k++) {
if (isTransparentMark(shaped[k].wc)) utf8AppendCodepoint(shaped[k].wc, out);
}
i = j - 1;
}
}
return true;
}
@@ -105,6 +174,7 @@ bool computeVisualWordOrder(const std::vector<std::string>& words, bool paragrap
visualOrder.clear();
const size_t nWords = words.size();
if (nWords <= 1 || nWords > BIDI_MAX_LINE) return false;
const std::lock_guard<std::mutex> lock(bidiMutex);
static bidi_char line[BIDI_MAX_LINE];
int count = 0;
@@ -121,6 +191,7 @@ bool computeVisualWordOrder(const std::vector<std::string>& words, bool paragrap
if (!cp || cp == REPLACEMENT_GLYPH) break;
line[count].origwc = line[count].wc = cp;
line[count].index = static_cast<uint16_t>(w);
line[count].joiners = 0;
count++;
}
@@ -131,6 +202,7 @@ bool computeVisualWordOrder(const std::vector<std::string>& words, bool paragrap
}
line[count].origwc = line[count].wc = ' ';
line[count].index = static_cast<uint16_t>(nWords);
line[count].joiners = 0;
count++;
}
}
+8
View File
@@ -13,6 +13,14 @@ bool startsWithRtl(const char* utf8, int maxStrongChars = RTL_PARAGRAPH_PROBE_DE
int detectParagraphLevel(const char* utf8, int fallbackLevel = 0, int maxStrongChars = 64);
// True for RTL-script non-spacing marks (Hebrew niqqud/cantillation, Arabic
// harakat and Quranic annotation): zero-width for measurement, transparent for
// Arabic joining, and rendered as overlays on the preceding base glyph when
// the active font carries their glyphs (SD fonts; built-in fonts don't).
// Latin combining marks (U+0300-U+036F) intentionally return false — they are
// handled by the utf8IsCombiningMark() rendering path.
bool isTransparentMark(uint32_t cp);
// paragraphLevel: -1 = auto-detect, 0 = LTR, 1 = RTL
bool applyBidiVisual(const char* utf8, std::string& out, int paragraphLevel = -1);
+48 -6
View File
@@ -1,15 +1,19 @@
/* bidiclasses.t — bidi class table for CrossPoint Hebrew/English epub.
/* bidiclasses.t — bidi class table for CrossPoint RTL (Hebrew/Arabic) 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.
* Hebrew and Arabic-script languages (Arabic, Farsi, Urdu, Sindhi, Pashto,
* Kurdish) are the RTL targets. CrossPoint also 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
* RTL 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.)
* scripts CrossPoint's fonts don't support (CJK, Devanagari, etc.)
* ON is the right class for "unknown" it behaves neutrally.
*
* Arabic ranges are sourced from Unicode UCD extracted/DerivedBidiClass.txt
* (values verified against Unicode 17.0.0).
*
* Entries sorted ascending by first (binary search requirement).
*/
@@ -84,6 +88,36 @@
{0x05D0, 0x05EA, R}, /* alef … tav */
{0x05F0, 0x05F4, R}, /* alternative forms + geresh/gershayim */
/* ── Arabic / Perso-Arabic (DerivedBidiClass.txt) */
/* Letters are AL (Arabic Letter), harakat/marks are NSM, Arabic-Indic
digits are AN, extended (Farsi/Urdu) digits are EN.
0x0606-0x0607, 0x060E-0x060F, 0x06DE, 0x06E9 are ON fall through. */
{0x0600, 0x0605, AN}, /* Arabic number signs (Cf) */
{0x0608, 0x0608, AL}, /* Arabic ray */
{0x0609, 0x060A, ET}, /* per mille / per ten thousand */
{0x060B, 0x060B, AL}, /* afghani sign */
{0x060C, 0x060C, CS}, /* Arabic comma */
{0x060D, 0x060D, AL}, /* Arabic date separator */
{0x0610, 0x061A, NSM}, /* honorifics + small marks */
{0x061B, 0x061F, AL}, /* semicolon, ALM, end-of-text, hamza mark, question mark */
{0x0620, 0x064A, AL}, /* core Arabic letters + tatweel */
{0x064B, 0x065F, NSM}, /* harakat (fathatan … wavy hamza below) */
{0x0660, 0x0669, AN}, /* Arabic-Indic digits ٠-٩ */
{0x066A, 0x066A, ET}, /* Arabic percent sign */
{0x066B, 0x066C, AN}, /* decimal / thousands separators */
{0x066D, 0x066F, AL}, /* five-pointed star, dotless beh/qaf */
{0x0670, 0x0670, NSM}, /* superscript alef */
{0x0671, 0x06D5, AL}, /* extended letters: Farsi, Urdu, Sindhi, Pashto, Kurdish */
{0x06D6, 0x06DC, NSM}, /* Quranic annotation marks */
{0x06DD, 0x06DD, AN}, /* end of ayah */
{0x06DF, 0x06E4, NSM},
{0x06E5, 0x06E6, AL}, /* small waw / small yeh */
{0x06E7, 0x06E8, NSM},
{0x06EA, 0x06ED, NSM},
{0x06EE, 0x06EF, AL}, /* dal/reh with inverted V */
{0x06F0, 0x06F9, EN}, /* extended Arabic-Indic digits ۰-۹ (Farsi/Urdu) EN per UCD */
{0x06FA, 0x06FF, AL},
/* ── Latin Extended Additional (L) ─────────────────────────────────── */
/* Covers accented chars for Vietnamese, Welsh, Romanian, etc.
Not currently rendered by CrossPoint fonts, but costs only 2 table rows. */
@@ -110,6 +144,14 @@
{0x2069, 0x2069, PDI},
{0x206A, 0x206F, BN},
/* ── Arabic presentation forms (output of do_shape()) ───────────────── */
/* Contextual/ligature forms emitted by the shaper must classify as AL so
a reshaped line still resolves RTL. Ranges per DerivedBidiClass.txt;
0xFBC3-0xFBD2 are ON fall through. */
{0xFB50, 0xFBC2, AL}, /* Presentation Forms-A: Perso-Arabic contextual forms */
{0xFBD3, 0xFBFF, AL}, /* Presentation Forms-A: NG … Farsi Yeh forms */
{0xFE70, 0xFE74, AL}, /* Presentation Forms-B: harakat isolated forms */
{0xFE76, 0xFEFC, AL}, /* Presentation Forms-B: contextual forms + Lam-Alef ligatures */
/* ── Byte Order Mark ────────────────────────────────────────────────── */
{0xFEFF, 0xFEFF, BN},
+339
View File
@@ -127,6 +127,345 @@ ucschar mirror(ucschar c) {
return p ? p->to : c;
}
/* ═══════════════════════════════════════════════════════════════════════
* Arabic contextual shaping — do_shape()
*
* Ported from mintty src/minibidi.c (https://github.com/mintty/mintty),
* original author Ahmad Khalifa (www.arabeyes.org), maintained by
* Thomas Wolff. MIT licence.
*
* CrossPoint deviations from the upstream code, each marked inline:
* 1. Joining-context lookups skip NSM marks (harakat). mintty stores
* combining marks out-of-line in terminal cells, so they never appear
* in its bidi_char array; in CrossPoint they are real array entries
* and must be transparent for joining (Unicode joining type T).
* 2. The Alef absorbed into a Lam-Alef ligature is overwritten with
* LIGATURE_PLACEHOLDER instead of a space: a terminal must keep the
* cell, a proportional-text renderer must drop the character.
* 3. The STYPE/SISOLATED macros became functions backed by a second
* lookup table covering Perso-Arabic letters outside mintty's native
* U+0621U+064A range (Farsi پ چ ژ گ, Urdu ٹ ڈ ڑ ں ہ ے, plus Sindhi/
* Pashto/Kurdish letters). Joining types are sourced from Unicode
* ArabicShaping.txt and presentation forms from UnicodeData.txt
* (Arabic Presentation Forms-A), both Unicode 17.0.0. Letters with a
* joining type but no presentation-form codepoints keep their base
* codepoint (neighbours still shape correctly around them).
* U+200C/U+200D also get their ArabicShaping.txt types (U and C) so
* that in-stream ZWJ/ZWNJ — which mintty never has in its array —
* affect adjacency the same way the joiners flags do.
* ═══════════════════════════════════════════════════════════════════════ */
/* Shaping Types (mintty) */
enum {
SL, /* Left-Joining, doesn't exist in U+0600 - U+06FF */
SR, /* Right-Joining, i.e. has Isolated, Final */
SD, /* Dual-Joining, i.e. has Isolated, Final, Initial, Medial */
SU, /* Non-Joining */
SC /* Join-Causing, like U+0640 (TATWEEL) */
};
typedef struct {
uchar type;
uchar form_b; /* isolated form = 0xFE00 + form_b (Presentation Forms-B) */
} shape_node;
/* Kept near the actual table, for verification. (mintty) */
enum { SHAPE_FIRST = 0x621, SHAPE_LAST = 0x64A };
/* mintty's shapetypes[] — verbatim */
static const shape_node shapetypes[] = {
/* index, Typ, Iso, Ligature Index */
/* 621 */ {SU, 0x80},
/* 622 */ {SR, 0x81},
/* 623 */ {SR, 0x83},
/* 624 */ {SR, 0x85},
/* 625 */ {SR, 0x87},
/* 626 */ {SD, 0x89},
/* 627 */ {SR, 0x8D},
/* 628 */ {SD, 0x8F},
/* 629 */ {SR, 0x93},
/* 62A */ {SD, 0x95},
/* 62B */ {SD, 0x99},
/* 62C */ {SD, 0x9D},
/* 62D */ {SD, 0xA1},
/* 62E */ {SD, 0xA5},
/* 62F */ {SR, 0xA9},
/* 630 */ {SR, 0xAB},
/* 631 */ {SR, 0xAD},
/* 632 */ {SR, 0xAF},
/* 633 */ {SD, 0xB1},
/* 634 */ {SD, 0xB5},
/* 635 */ {SD, 0xB9},
/* 636 */ {SD, 0xBD},
/* 637 */ {SD, 0xC1},
/* 638 */ {SD, 0xC5},
/* 639 */ {SD, 0xC9},
/* 63A */ {SD, 0xCD},
/* 63B */ {SU, 0x0},
/* 63C */ {SU, 0x0},
/* 63D */ {SU, 0x0},
/* 63E */ {SU, 0x0},
/* 63F */ {SU, 0x0},
/* 640 */ {SC, 0x0},
/* 641 */ {SD, 0xD1},
/* 642 */ {SD, 0xD5},
/* 643 */ {SD, 0xD9},
/* 644 */ {SD, 0xDD},
/* 645 */ {SD, 0xE1},
/* 646 */ {SD, 0xE5},
/* 647 */ {SD, 0xE9},
/* 648 */ {SR, 0xED},
/* 649 */ {SR, 0xEF},
/* 64A */ {SD, 0xF1}};
/* ── CrossPoint extension: joining types outside U+0621U+064A ──────────
* Source: Unicode ArabicShaping.txt (joining type column). Ranges of
* letters sharing one type are collapsed. Sorted ascending (binary
* search). Anything not listed here or in shapetypes[] is SU. */
static const struct {
ucschar first, last;
uchar type;
} xjointypes[] = {
{0x0620, 0x0620, SD}, /* kashmiri yeh */
{0x066E, 0x066F, SD}, /* dotless beh/qaf */
{0x0671, 0x0673, SR}, /* alef wasla + wavy-hamza alefs */
{0x0675, 0x0677, SR}, /* high-hamza alef/waw */
{0x0678, 0x0687, SD}, /* high-hamza yeh, beh group (ٹ ٺ ٻ … پ), hah group (چ ڇ) */
{0x0688, 0x0699, SR}, /* dal group (ڈ ڊ ڌ …), reh group (ڑ ړ ژ …) */
{0x069A, 0x06BF, SD}, /* seen/sad/feh/qaf/kaf/gaf/lam/noon groups (ک گ ں ھ …) */
{0x06C0, 0x06C0, SR}, /* heh with yeh above */
{0x06C1, 0x06C2, SD}, /* heh goal (ہ) */
{0x06C3, 0x06CB, SR}, /* teh marbuta goal, waw group (ۆ ۇ ۋ …) */
{0x06CC, 0x06CC, SD}, /* farsi yeh (ی) */
{0x06CD, 0x06CD, SR}, /* yeh with tail */
{0x06CE, 0x06CE, SD}, /* farsi yeh with V (Kurdish ێ) */
{0x06CF, 0x06CF, SR}, /* waw with dot above */
{0x06D0, 0x06D1, SD}, /* e (Pashto ې), yeh three dots below */
{0x06D2, 0x06D3, SR}, /* yeh barree (ے ۓ) */
{0x06D5, 0x06D5, SR}, /* ae (Kurdish ە) */
{0x06EE, 0x06EF, SR}, /* dal/reh with inverted V */
{0x06FA, 0x06FC, SD}, /* sheen/dad/ghain with dot below */
{0x06FF, 0x06FF, SD}, /* heh with inverted V */
{0x200C, 0x200C, SU}, /* ZWNJ — joining type U per ArabicShaping.txt */
{0x200D, 0x200D, SC}, /* ZWJ — joining type C per ArabicShaping.txt */
};
/* ── CrossPoint extension: presentation forms outside U+0621U+064A ─────
* Source: UnicodeData.txt, Arabic Presentation Forms-A (U+FB50U+FBFF).
* forms = number of consecutive presentation forms allocated for the
* letter, always in the order isolated, final, initial, medial (matching
* the SHAPE_* offsets below): 2 = isolated+final, 4 = all.
* Letters absent here have no presentation forms and keep their base
* codepoint. Sorted ascending (binary search). */
static const struct {
ucschar cp;
ucschar isolated;
uchar forms;
} xshapeforms[] = {
{0x0671, 0xFB50, 2}, /* alef wasla */
{0x0679, 0xFB66, 4}, /* tteh (Urdu ٹ) */
{0x067A, 0xFB5E, 4}, /* tteheh */
{0x067B, 0xFB52, 4}, /* beeh (Sindhi ٻ) */
{0x067E, 0xFB56, 4}, /* peh (Farsi پ) */
{0x067F, 0xFB62, 4}, /* teheh */
{0x0680, 0xFB5A, 4}, /* beheh (Sindhi ڀ) */
{0x0683, 0xFB76, 4}, /* nyeh (Sindhi ڃ) */
{0x0684, 0xFB72, 4}, /* dyeh (Sindhi ڄ) */
{0x0686, 0xFB7A, 4}, /* tcheh (Farsi چ) */
{0x0687, 0xFB7E, 4}, /* tcheheh (Sindhi ڇ) */
{0x0688, 0xFB88, 2}, /* ddal (Urdu ڈ) */
{0x068C, 0xFB84, 2}, /* dahal (Sindhi ڌ) */
{0x068D, 0xFB82, 2}, /* ddahal (Sindhi ڍ) */
{0x068E, 0xFB86, 2}, /* dul (Sindhi ڎ) */
{0x0691, 0xFB8C, 2}, /* rreh (Urdu ڑ) */
{0x0698, 0xFB8A, 2}, /* jeh (Farsi ژ) */
{0x06A4, 0xFB6A, 4}, /* veh (Kurdish ڤ) */
{0x06A6, 0xFB6E, 4}, /* peheh (Sindhi ڦ) */
{0x06A9, 0xFB8E, 4}, /* keheh (Farsi/Urdu ک) */
{0x06AD, 0xFBD3, 4}, /* ng */
{0x06AF, 0xFB92, 4}, /* gaf (Farsi/Urdu گ) */
{0x06B1, 0xFB9A, 4}, /* ngoeh (Sindhi ڱ) */
{0x06B3, 0xFB96, 4}, /* gueh (Sindhi ڳ) */
{0x06BA, 0xFB9E, 2}, /* noon ghunna (Urdu ں) — dual-joining but only
isolated+final forms exist; initial/medial
contexts keep the base codepoint */
{0x06BB, 0xFBA0, 4}, /* rnoon (Sindhi ڻ) */
{0x06BE, 0xFBAA, 4}, /* heh doachashmee (Urdu ھ) */
{0x06C0, 0xFBA4, 2}, /* heh with yeh above */
{0x06C1, 0xFBA6, 4}, /* heh goal (Urdu ہ) */
{0x06C5, 0xFBE0, 2}, /* kirghiz oe */
{0x06C6, 0xFBD9, 2}, /* oe (Kurdish ۆ) */
{0x06C7, 0xFBD7, 2}, /* u (ۇ) */
{0x06C8, 0xFBDB, 2}, /* yu */
{0x06C9, 0xFBE2, 2}, /* kirghiz yu */
{0x06CB, 0xFBDE, 2}, /* ve */
{0x06CC, 0xFBFC, 4}, /* farsi yeh (Farsi/Urdu ی) */
{0x06D0, 0xFBE4, 4}, /* e (Pashto ې) */
{0x06D2, 0xFBAE, 2}, /* yeh barree (Urdu ے) */
{0x06D3, 0xFBB0, 2}, /* yeh barree with hamza above (ۓ) */
};
/* Contextual form offsets from the isolated form — identical ordering in
Presentation Forms-A and -B, matching mintty's SFINAL/SINITIAL/SMEDIAL
(+1/+2/+3) macros. */
enum { SHAPE_ISOLATED = 0, SHAPE_FINAL = 1, SHAPE_INITIAL = 2, SHAPE_MEDIAL = 3 };
/* STYPE equivalent (mintty macro → function to add the extended table) */
static uchar stype(ucschar c) {
if (c >= SHAPE_FIRST && c <= SHAPE_LAST) return shapetypes[c - SHAPE_FIRST].type;
int i = -1, j = lengthof(xjointypes);
while (j - i > 1) {
int k = (i + j) / 2;
if (c < xjointypes[k].first)
j = k;
else if (c > xjointypes[k].last)
i = k;
else
return xjointypes[k].type;
}
return SU;
}
/* SISOLATED/SFINAL/SINITIAL/SMEDIAL equivalent.
form is one of the SHAPE_* offsets; returns c unchanged when the letter
has no presentation form allocated for that context. */
static ucschar shape_form(ucschar c, uchar form) {
if (c >= SHAPE_FIRST && c <= SHAPE_LAST) {
const uchar form_b = shapetypes[c - SHAPE_FIRST].form_b;
return form_b ? 0xFE00 + form_b + form : c;
}
int i = -1, j = lengthof(xshapeforms);
while (j - i > 1) {
int k = (i + j) / 2;
if (c < xshapeforms[k].cp)
j = k;
else if (c > xshapeforms[k].cp)
i = k;
else
return form < xshapeforms[k].forms ? xshapeforms[k].isolated + form : c;
}
return c;
}
/* CrossPoint deviation 1: NSM marks (harakat) sit between letters in our
array but are transparent for joining (Unicode joining type T). These
helpers find the effective joining neighbour. */
static int next_non_nsm(const bidi_char* line, int i, int count) {
int j = i + 1;
while (j < count && bidi_class(line[j].wc) == NSM) j++;
return j;
}
static int prev_non_nsm(const bidi_char* line, int i) {
int j = i - 1;
while (j >= 0 && bidi_class(line[j].wc) == NSM) j--;
return j;
}
/* The Main shaping function (mintty, structure preserved).
*
* line: visual-order buffer — must have been passed through do_bidi() first
* to: output buffer for the shaped data
* count: number of characters in line
*/
int do_shape(bidi_char* line, bidi_char* to, int count) {
for (int i = 0; i < count; i++) {
to[i] = line[i];
int tempShape = stype(line[i].wc);
switch (tempShape) {
case SR: { /* Right-Joining, i.e. has Isolated, Final */
const int nx = next_non_nsm(line, i, count); /* deviation 1: was i + 1 */
tempShape = (nx < count) ? stype(line[nx].wc) : SU;
if (tempShape == SL || tempShape == SD || tempShape == SC)
to[i].wc = shape_form(line[i].wc, SHAPE_FINAL);
else
to[i].wc = shape_form(line[i].wc, SHAPE_ISOLATED);
break;
}
case SD: { /* Dual-Joining, i.e. has Isolated, Final, Initial, Medial */
const int nx = next_non_nsm(line, i, count); /* deviation 1: was i + 1 */
const int pv = prev_non_nsm(line, i); /* deviation 1: was i - 1 */
/* Make Ligatures */
tempShape = (nx < count) ? stype(line[nx].wc) : SU;
if (line[i].wc == 0x644) { /* Lam: the Alef variant is at pv (visually left) */
int ligFlag = 0;
switch (pv >= 0 ? line[pv].wc : 0) {
case 0x622: /* Alef with Madda above → لآ */
ligFlag = 1;
to[i].wc = (tempShape == SL || tempShape == SD || tempShape == SC) ? 0xFEF6 : 0xFEF5;
break;
case 0x623: /* Alef with Hamza above → لأ */
ligFlag = 1;
to[i].wc = (tempShape == SL || tempShape == SD || tempShape == SC) ? 0xFEF8 : 0xFEF7;
break;
case 0x625: /* Alef with Hamza below → لإ */
ligFlag = 1;
to[i].wc = (tempShape == SL || tempShape == SD || tempShape == SC) ? 0xFEFA : 0xFEF9;
break;
case 0x627: /* Alef → لا */
ligFlag = 1;
to[i].wc = (tempShape == SL || tempShape == SD || tempShape == SC) ? 0xFEFC : 0xFEFB;
break;
}
if (ligFlag) {
to[pv].wc = LIGATURE_PLACEHOLDER; /* deviation 2: mintty writes 0x20 */
break;
}
}
/* Arabic joining formatters: adapt forms (mintty) */
const uchar joiners = line[i].joiners & 0xF;
const uchar prevjoiners = line[i].joiners >> 4;
if (prevjoiners == ZWNJ) {
/* backward join blocked; initial only if the visually-right
(logically next) neighbour joins, else isolated */
tempShape = (pv >= 0) ? stype(line[pv].wc) : SU;
if (tempShape == SR || tempShape == SD || tempShape == SC)
to[i].wc = shape_form(line[i].wc, SHAPE_INITIAL);
else
to[i].wc = shape_form(line[i].wc, SHAPE_ISOLATED);
} else if (prevjoiners == (ZWJ | ZWNJ)) {
to[i].wc = shape_form(line[i].wc, SHAPE_MEDIAL);
} else if (prevjoiners == ZWJ) {
to[i].wc = shape_form(line[i].wc, SHAPE_FINAL);
} else if (joiners & ZWNJ) {
/* forward join blocked; final only if the visually-left
(logically previous) neighbour joins, else isolated —
tempShape still holds stype(nx) from above */
if (tempShape == SL || tempShape == SD || tempShape == SC)
to[i].wc = shape_form(line[i].wc, SHAPE_FINAL);
else
to[i].wc = shape_form(line[i].wc, SHAPE_ISOLATED);
} else if (tempShape == SL || tempShape == SD || tempShape == SC) {
/* visually-right neighbour joins → final, or medial if the
visually-left neighbour joins too */
tempShape = (pv >= 0) ? stype(line[pv].wc) : SU;
if (tempShape == SR || tempShape == SD || tempShape == SC)
to[i].wc = shape_form(line[i].wc, SHAPE_MEDIAL);
else
to[i].wc = shape_form(line[i].wc, SHAPE_FINAL);
} else {
/* visually-right neighbour doesn't join → isolated, or initial
if the visually-left neighbour joins */
tempShape = (pv >= 0) ? stype(line[pv].wc) : SU;
if (tempShape == SR || tempShape == SD || tempShape == SC)
to[i].wc = shape_form(line[i].wc, SHAPE_INITIAL);
else
to[i].wc = shape_form(line[i].wc, SHAPE_ISOLATED);
}
break;
}
default:
/* SU, SL, SC and non-Arabic characters pass through unchanged */
break;
}
}
return 1;
}
/* ═══════════════════════════════════════════════════════════════════════
* Directional Status Stack
* (replaces GCC nested functions — ESP32C3 has no executable stack)
+52 -5
View File
@@ -5,8 +5,10 @@
* 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.
* Includes: UAX#9 bidi (do_bidi) and Arabic contextual shaping (do_shape),
* both ported from mintty src/minibidi.c (Ahmad Khalifa, Thomas Wolff).
* Stripped of: box-drawing mirror, terminal dependencies, GCC nested
* functions, VLAs, and Unicode data for scripts CrossPoint doesn't render.
*/
#include <stdbool.h>
@@ -34,13 +36,18 @@ typedef uint32_t ucschar; /* Unicode codepoint; BMP-only content fits uint16_t
#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 */
/* origwc: the codepoint as it came from the epub text stream
wc: working codepoint (may be replaced by mirrored form after
do_bidi, or by an Arabic contextual form after do_shape)
index: original logical position, so the caller can reorder glyphs
joiners: ZWJ/ZWNJ context for Arabic shaping, mintty layout:
low nibble = joiners that logically FOLLOW this character,
high nibble = joiners that logically PRECEDE this character */
typedef struct {
ucschar origwc;
ucschar wc;
uint16_t index;
uint8_t joiners;
} bidi_char;
/* ── Bidi character classes (UAX #9) ────────────────────────────────── */
@@ -71,6 +78,20 @@ enum {
PDI, /* Pop Directional Isolate */
};
/* ── Arabic joining formatter flags (bidi_char.joiners nibbles) ─────── */
/* Values match mintty's minibidi.h: ZWNJ 0x01, ZWJ 0x02. do_shape()
compares nibble values directly, so these must not be changed. */
enum {
ZWNJ = 0x01, /* U+200C ZERO WIDTH NON-JOINER */
ZWJ = 0x02, /* U+200D ZERO WIDTH JOINER */
};
/* Sentinel written by do_shape() over the Alef absorbed into a Lam-Alef
ligature. Callers must filter it out when emitting shaped text.
(Upstream mintty writes a space instead — a terminal must keep the cell;
a proportional-text renderer must drop the character entirely.) */
#define LIGATURE_PLACEHOLDER 0xFFFFu
/* ── Public API ──────────────────────────────────────────────────────── */
/*
@@ -94,6 +115,32 @@ bool is_rtl_class(uchar bc);
*/
ucschar mirror(ucschar ch);
/*
* do_shape(line, to, count)
*
* Applies Arabic contextual shaping (and Lam-Alef ligation) to
* `line[0..count-1]`, writing the result to `to[0..count-1]`.
*
* MUST be called AFTER do_bidi(): the algorithm resolves joining from
* VISUAL adjacency (line[i-1] is the visually-left neighbour, line[i+1]
* the visually-right one), exactly like upstream mintty.
*
* line: visual-order input; the joiners field must be populated by the
* caller (in logical order, before do_bidi) for ZWJ/ZWNJ support
* to: output buffer, same size as line; non-Arabic entries are copied
* through unchanged. An Alef absorbed by a Lam-Alef ligature is
* replaced with LIGATURE_PLACEHOLDER — filter it on emission.
* count: number of characters (≤ BIDI_MAX_LINE)
*
* Returns 1.
*
* Ported from mintty src/minibidi.c (Ahmad Khalifa, Thomas Wolff,
* MIT licence), https://github.com/mintty/mintty — with CrossPoint
* extensions for Perso-Arabic letters and in-stream diacritics, see
* minibidi.c for details.
*/
int do_shape(bidi_char* line, bidi_char* to, int count);
/*
* do_bidi(autodir, paragraphLevel, line, count)
*
+3 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(crosspoint_reader_tests CXX)
project(crosspoint_reader_tests C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -43,3 +43,5 @@ add_subdirectory(release_json_parser)
add_subdirectory(differential_rounding)
add_subdirectory(hyphenation_eval)
add_subdirectory(utf8_compose)
add_subdirectory(minibidi_arabic)
add_subdirectory(combining_marks)
+14
View File
@@ -0,0 +1,14 @@
add_executable(CombiningMarkAnchorTest
CombiningMarkAnchorTest.cpp
)
target_include_directories(CombiningMarkAnchorTest PRIVATE
${REPO_ROOT}/lib/EpdFont
)
target_link_libraries(CombiningMarkAnchorTest PRIVATE
crosspoint_test_common
GTest::gtest_main
)
gtest_discover_tests(CombiningMarkAnchorTest)
@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include "lib/EpdFont/EpdFontData.h"
// ============================================================================
// Anchor selection and placement for combining marks without GPOS tables.
//
// Hebrew niqqud whose identity depends on position must not use the default
// centre-and-raise heuristic: dagesh sits inside the letter body, the
// shin/sin dots sit over the letter's right/left arm, and holam hangs over
// the left corner (see PR #2541 review feedback).
// ============================================================================
using combiningMark::Anchor;
using combiningMark::anchorFor;
using combiningMark::anchorOver;
using combiningMark::anchorOverRotated90CW;
using combiningMark::raiseAboveBase;
TEST(AnchorFor, PositionSensitiveNiqqud) {
EXPECT_EQ(anchorFor(0x05BC), Anchor::CenterNative); // dagesh/mapiq
EXPECT_EQ(anchorFor(0x05BA), Anchor::CenterNative); // holam haser for vav
EXPECT_EQ(anchorFor(0x05C1), Anchor::RightNative); // shin dot
EXPECT_EQ(anchorFor(0x05C2), Anchor::LeftNative); // sin dot
EXPECT_EQ(anchorFor(0x05B9), Anchor::LeftNative); // holam
}
TEST(AnchorFor, EverythingElseKeepsCentreRaisedDefault) {
EXPECT_EQ(anchorFor(0x05B0), Anchor::CenterRaised); // Hebrew sheva
EXPECT_EQ(anchorFor(0x05B7), Anchor::CenterRaised); // Hebrew patach
EXPECT_EQ(anchorFor(0x064E), Anchor::CenterRaised); // Arabic fatha
EXPECT_EQ(anchorFor(0x0651), Anchor::CenterRaised); // Arabic shadda
EXPECT_EQ(anchorFor(0x0301), Anchor::CenterRaised); // combining acute
}
// Base glyph: cursor 100, left 1, width 12. Mark: left 2, width 4.
TEST(AnchorOver, HorizontalPlacementPerAnchor) {
// Centered: base bitmap spans [101, 113), centre 107; mark starts at 105.
EXPECT_EQ(anchorOver(Anchor::CenterRaised, 100, 1, 12, 2, 4), 105 - 2);
EXPECT_EQ(anchorOver(Anchor::CenterNative, 100, 1, 12, 2, 4), 105 - 2);
// Left-aligned: mark bitmap starts at the base bitmap's left edge (101).
EXPECT_EQ(anchorOver(Anchor::LeftNative, 100, 1, 12, 2, 4), 101 - 2);
// Right-aligned: mark bitmap ends at the base bitmap's right edge (113).
EXPECT_EQ(anchorOver(Anchor::RightNative, 100, 1, 12, 2, 4), 109 - 2);
}
// The rotated coordinate system inverts every left/width term, so the mark's
// offset from the base cursor must be the exact mirror of the unrotated one.
TEST(AnchorOverRotated90CW, MirrorsUnrotatedOffsets) {
for (const Anchor anchor : {Anchor::CenterRaised, Anchor::CenterNative, Anchor::LeftNative, Anchor::RightNative}) {
const int offset = anchorOver(anchor, 100, 1, 12, 2, 4) - 100;
EXPECT_EQ(anchorOverRotated90CW(anchor, 100, 1, 12, 2, 4), 100 - offset);
}
}
TEST(RaiseAboveBase, NativeAnchorsKeepFontDesignedHeight) {
// A dagesh-like dot designed inside the letter body (top 8 of a base whose
// top is 12) must NOT be hoisted above the letter.
EXPECT_EQ(raiseAboveBase(Anchor::CenterNative, 8, 3, 12), 0);
// Shin/sin dots overlapping the letter's top must not be pushed clear.
EXPECT_EQ(raiseAboveBase(Anchor::RightNative, 13, 3, 12), 0);
EXPECT_EQ(raiseAboveBase(Anchor::LeftNative, 13, 3, 12), 0);
}
TEST(RaiseAboveBase, CentreRaisedBehaviourUnchanged) {
// Above-baseline mark colliding with the base: raised to restore a 1px gap.
// gap = markTop - markHeight - baseTop = 10 - 3 - 12 = -5 -> raise 6.
EXPECT_EQ(raiseAboveBase(Anchor::CenterRaised, 10, 3, 12), 6);
// Already clear of the base: no raise.
EXPECT_EQ(raiseAboveBase(Anchor::CenterRaised, 16, 3, 12), 0);
// Below-baseline mark (kasra, cedilla): stays at font-native position.
EXPECT_EQ(raiseAboveBase(Anchor::CenterRaised, 2, 4, 12), 0);
}
+19
View File
@@ -0,0 +1,19 @@
add_executable(MiniBidiArabicTest
MiniBidiArabicTest.cpp
${REPO_ROOT}/lib/MiniBidi/minibidi.c
${REPO_ROOT}/lib/MiniBidi/BidiUtils.cpp
${REPO_ROOT}/lib/Utf8/Utf8.cpp
)
target_include_directories(MiniBidiArabicTest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/stubs # no-op Logging.h (real one needs Arduino)
${REPO_ROOT}/lib/MiniBidi
${REPO_ROOT}/lib/Utf8
)
target_link_libraries(MiniBidiArabicTest PRIVATE
crosspoint_test_common
GTest::gtest_main
)
gtest_discover_tests(MiniBidiArabicTest)
+254
View File
@@ -0,0 +1,254 @@
// MiniBidiArabicTest — Arabic/Farsi/Urdu bidi + contextual shaping.
//
// Exercises the full BidiUtils::applyBidiVisual() pipeline (the single code
// path shared by GfxRenderer::getTextWidth() and drawText()): UAX#9
// reordering (do_bidi) followed by mintty-ported Arabic shaping (do_shape).
//
// Inputs are built from codepoint arrays in LOGICAL order; expectations are
// codepoint arrays in VISUAL order (left to right, as drawn on screen).
// Presentation-form expectations were derived by hand from Unicode
// ArabicShaping.txt joining types and the Arabic Presentation Forms-A/B
// blocks of UnicodeData.txt.
#include <gtest/gtest.h>
#include <cstdint>
#include <string>
#include <vector>
#include "BidiUtils.h"
#include "Utf8.h"
namespace {
std::string encode(const std::vector<uint32_t>& codepoints) {
std::string utf8;
for (const uint32_t cp : codepoints) utf8AppendCodepoint(cp, utf8);
return utf8;
}
std::vector<uint32_t> decode(const std::string& utf8) {
std::vector<uint32_t> codepoints;
auto* p = reinterpret_cast<const unsigned char*>(utf8.c_str());
while (*p) {
const uint32_t cp = utf8NextCodepoint(&p);
if (!cp) break;
codepoints.push_back(cp);
}
return codepoints;
}
// Runs the shared bidi+shape pipeline with auto-detected paragraph direction
// and returns the visual-order codepoints.
std::vector<uint32_t> shapeVisual(const std::vector<uint32_t>& logical) {
const std::string in = encode(logical);
std::string out;
if (!BidiUtils::applyBidiVisual(in.c_str(), out, /*paragraphLevel=*/-1)) {
return decode(in); // pipeline declined: unchanged text
}
return decode(out);
}
using CP = std::vector<uint32_t>;
} // namespace
/* ── Core Arabic contextual forms ────────────────────────────────────── */
// A single letter renders in its isolated presentation form.
TEST(ArabicShaping, SingleLetterIsolated) {
EXPECT_EQ(shapeVisual({0x0628}), (CP{0xFE8F})); // ب
}
// دار: dal and alef are right-joining, so no letter connects forward —
// all three stay isolated. Visual order is reversed.
TEST(ArabicShaping, RightJoinersStayIsolated) {
EXPECT_EQ(shapeVisual({0x062F, 0x0627, 0x0631}), (CP{0xFEAD, 0xFE8D, 0xFEA9}));
}
// بيت: initial + medial + final chain of dual-joining letters.
TEST(ArabicShaping, DualJoiningChain) {
EXPECT_EQ(shapeVisual({0x0628, 0x064A, 0x062A}), (CP{0xFE96, 0xFEF4, 0xFE91}));
}
// محمد: medial forms across four letters ending in right-joining dal.
TEST(ArabicShaping, MedialForms) {
EXPECT_EQ(shapeVisual({0x0645, 0x062D, 0x0645, 0x062F}), (CP{0xFEAA, 0xFEE4, 0xFEA4, 0xFEE3}));
}
// Tatweel (U+0640) is join-causing and passes through unchanged.
TEST(ArabicShaping, TatweelJoinsBothSides) {
EXPECT_EQ(shapeVisual({0x0628, 0x0640, 0x062A}), (CP{0xFE96, 0x0640, 0xFE91}));
}
/* ── Lam-Alef ligatures ──────────────────────────────────────────────── */
// All four Lam-Alef variants, isolated (nothing joins into the Lam).
// The absorbed Alef must be collapsed out of the output entirely.
TEST(ArabicShaping, LamAlefIsolatedVariants) {
EXPECT_EQ(shapeVisual({0x0644, 0x0627}), (CP{0xFEFB})); // لا
EXPECT_EQ(shapeVisual({0x0644, 0x0622}), (CP{0xFEF5})); // لآ
EXPECT_EQ(shapeVisual({0x0644, 0x0623}), (CP{0xFEF7})); // لأ
EXPECT_EQ(shapeVisual({0x0644, 0x0625}), (CP{0xFEF9})); // لإ
}
// All four Lam-Alef variants in final form (a joining letter precedes Lam).
TEST(ArabicShaping, LamAlefFinalVariants) {
EXPECT_EQ(shapeVisual({0x0628, 0x0644, 0x0627}), (CP{0xFEFC, 0xFE91}));
EXPECT_EQ(shapeVisual({0x0628, 0x0644, 0x0622}), (CP{0xFEF6, 0xFE91}));
EXPECT_EQ(shapeVisual({0x0628, 0x0644, 0x0623}), (CP{0xFEF8, 0xFE91}));
EXPECT_EQ(shapeVisual({0x0628, 0x0644, 0x0625}), (CP{0xFEFA, 0xFE91}));
}
// The specific bug flagged in PR #2398 review: a diacritic between Lam and
// Alef must not break the ligature. The fatha stays in the stream (a
// zero-advance overlay at render time); the Alef is absorbed. Per UAX#9 L3
// the mark is emitted after the ligature it decorates.
TEST(ArabicShaping, LamAlefWithDiacriticBetween) {
EXPECT_EQ(shapeVisual({0x0644, 0x064E, 0x0627}), (CP{0xFEFB, 0x064E}));
}
/* ── Harakat (diacritics) ────────────────────────────────────────────── */
// كَتَبَ fully vocalized: harakat are transparent for joining — the letter
// skeleton shapes exactly as كتب — and remain in the output stream. UAX#9
// rule L3: each mark is emitted after its base, so the renderer can overlay
// it on the most recently drawn glyph.
TEST(ArabicShaping, VocalizedTextJoinsAcrossHarakat) {
EXPECT_EQ(shapeVisual({0x0643, 0x064E, 0x062A, 0x064E, 0x0628, 0x064E}),
(CP{0xFE90, 0x064E, 0xFE98, 0x064E, 0xFEDB, 0x064E}));
}
// Stacked marks (shadda + fatha on one base) keep their logical order after
// the base, so above-base stacking renders bottom-up as authored.
TEST(ArabicShaping, StackedMarksFollowBaseInLogicalOrder) {
EXPECT_EQ(shapeVisual({0x0628, 0x0651, 0x064E}), (CP{0xFE8F, 0x0651, 0x064E}));
}
// Hebrew niqqud rides the same L3 path: mark follows its base after the
// RTL run is reversed.
TEST(HebrewShaping, NiqqudFollowsBase) {
EXPECT_EQ(shapeVisual({0x05E9, 0x05B0, 0x05DC}), (CP{0x05DC, 0x05E9, 0x05B0}));
}
/* ── Farsi extra letters ─────────────────────────────────────────────── */
// Peh joins like beh, using Presentation Forms-A codepoints.
TEST(FarsiShaping, PehContextualForms) { EXPECT_EQ(shapeVisual({0x067E, 0x067E}), (CP{0xFB57, 0xFB58})); }
// گاز: gaf initial, alef final, jeh-group zain isolated.
TEST(FarsiShaping, GafWord) { EXPECT_EQ(shapeVisual({0x06AF, 0x0627, 0x0632}), (CP{0xFEAF, 0xFE8E, 0xFB94})); }
// سیب: Farsi yeh (U+06CC) takes its medial Presentation Forms-A form.
TEST(FarsiShaping, FarsiYehMedial) { EXPECT_EQ(shapeVisual({0x0633, 0x06CC, 0x0628}), (CP{0xFE90, 0xFBFF, 0xFEB3})); }
/* ── Urdu extra letters ──────────────────────────────────────────────── */
// ٹ (U+0679) and ڑ (U+0691) were specifically flagged in PR #2398 review as
// missing from a literal mintty port; ڈ (U+0688) completes the retroflex set.
TEST(UrduShaping, RetroflexLetters) { EXPECT_EQ(shapeVisual({0x0679, 0x0688, 0x0691}), (CP{0xFB8C, 0xFB89, 0xFB68})); }
// میں: noon ghunna (U+06BA) is dual-joining but only has isolated/final
// presentation forms — final position works.
TEST(UrduShaping, NoonGhunnaFinal) { EXPECT_EQ(shapeVisual({0x0645, 0x06CC, 0x06BA}), (CP{0xFB9F, 0xFBFF, 0xFEE3})); }
// ہے: heh goal initial + yeh barree final.
TEST(UrduShaping, HehGoalYehBarree) { EXPECT_EQ(shapeVisual({0x06C1, 0x06D2}), (CP{0xFBAF, 0xFBA8})); }
/* ── Sindhi / Pashto / Kurdish samples ───────────────────────────────── */
// Sindhi ٻار: beeh (U+067B) initial form from Presentation Forms-A.
TEST(SindhiShaping, BeehInitial) { EXPECT_EQ(shapeVisual({0x067B, 0x0627, 0x0631}), (CP{0xFEAD, 0xFE8E, 0xFB54})); }
// Pashto ښه: seen-with-dots (U+069A) has a joining type but NO presentation
// forms — it keeps its base codepoint while its neighbour still takes the
// correct joined form.
TEST(PashtoShaping, LetterWithoutPresentationFormsFallsBack) {
EXPECT_EQ(shapeVisual({0x069A, 0x0647}), (CP{0xFEEA, 0x069A}));
}
// Pashto کور: keheh initial, waw final, reh isolated.
TEST(PashtoShaping, KehehWord) { EXPECT_EQ(shapeVisual({0x06A9, 0x0648, 0x0631}), (CP{0xFEAD, 0xFEEE, 0xFB90})); }
// Kurdish ڕۆژ: all right-joining; ڕ (U+0695) has no presentation forms and
// stays as its base codepoint, ۆ and ژ take Presentation Forms-A isolated.
TEST(KurdishShaping, RightJoiningLetters) {
EXPECT_EQ(shapeVisual({0x0695, 0x06C6, 0x0698}), (CP{0xFB8A, 0xFBD9, 0x0695}));
}
/* ── ZWJ / ZWNJ joining formatters ───────────────────────────────────── */
// ZWNJ blocks joining across it but leaves the outer sides to normal
// contextual rules (Farsi morphology, e.g. می‌خواهم). The formatter itself
// must be filtered from the output. Here meem joins into yeh (initial +
// final) while the ZWNJ keeps yeh from connecting to khah.
TEST(JoinerShaping, ZwnjBreaksJoin) {
// می‌خ: trailing khah has no forward partner → isolated.
EXPECT_EQ(shapeVisual({0x0645, 0x06CC, 0x200C, 0x062E}), (CP{0xFEA5, 0xFBFD, 0xFEE3}));
// می‌خو: waw follows khah → khah initial, waw final.
EXPECT_EQ(shapeVisual({0x0645, 0x06CC, 0x200C, 0x062E, 0x0648}), (CP{0xFEEE, 0xFEA7, 0xFBFD, 0xFEE3}));
}
// ZWJ forces a join where none would occur: a lone beh followed by ZWJ takes
// initial form; preceded by ZWJ it takes final form.
TEST(JoinerShaping, ZwjForcesJoin) {
EXPECT_EQ(shapeVisual({0x0628, 0x200D}), (CP{0xFE91}));
EXPECT_EQ(shapeVisual({0x200D, 0x0628}), (CP{0xFE90}));
}
/* ── Mixed-direction text ────────────────────────────────────────────── */
// كتاب abc 123 — RTL paragraph: the Arabic (shaped) ends up rightmost;
// "abc 123" resolves to a single LTR run (rule W7 turns the European
// numerals L after the strong L of "abc") and is placed as one block to
// its left, keeping internal left-to-right order.
TEST(MixedDirection, ArabicLatinNumerals) {
EXPECT_EQ(shapeVisual({0x0643, 0x062A, 0x0627, 0x0628, ' ', 'a', 'b', 'c', ' ', '1', '2', '3'}),
(CP{'a', 'b', 'c', ' ', '1', '2', '3', ' ', 0xFE8F, 0xFE8E, 0xFE98, 0xFEDB}));
}
// كتاب ١٢٣ — Arabic-Indic digits keep logical order (leftmost run reads
// ١٢٣, not reversed).
TEST(MixedDirection, ArabicIndicNumerals) {
EXPECT_EQ(shapeVisual({0x0643, 0x062A, 0x0627, 0x0628, ' ', 0x0661, 0x0662, 0x0663}),
(CP{0x0661, 0x0662, 0x0663, ' ', 0xFE8F, 0xFE8E, 0xFE98, 0xFEDB}));
}
// Watch-item regression probe for the "random spaces" report from #2398
// testing: spaces must be neither duplicated nor dropped by the pipeline.
TEST(MixedDirection, SpaceCountPreserved) {
const CP visual = shapeVisual({0x0643, 0x062A, 0x0627, 0x0628, ' ', 0x0642, 0x0644, 0x0645, ' ', 'o', 'k'});
int spaces = 0;
for (const uint32_t cp : visual) spaces += (cp == ' ');
EXPECT_EQ(spaces, 2);
EXPECT_EQ(visual.size(), 11u); // 9 letters + 2 spaces, nothing absorbed or invented
}
/* ── Regression: existing behaviour unchanged ────────────────────────── */
// Hebrew reorders but never shapes.
TEST(Regression, HebrewUntouchedByShaper) {
EXPECT_EQ(shapeVisual({0x05E9, 0x05DC, 0x05D5, 0x05DD}), (CP{0x05DD, 0x05D5, 0x05DC, 0x05E9}));
}
// Pure Latin text passes through unchanged.
TEST(Regression, LatinPassthrough) { EXPECT_EQ(shapeVisual({'h', 'e', 'l', 'l', 'o'}), (CP{'h', 'e', 'l', 'l', 'o'})); }
/* ── isTransparentMark ───────────────────────────────────────────────── */
TEST(TransparentMark, RtlMarksAreTransparent) {
EXPECT_TRUE(BidiUtils::isTransparentMark(0x05B0)); // Hebrew sheva
EXPECT_TRUE(BidiUtils::isTransparentMark(0x0591)); // Hebrew accent etnahta
EXPECT_TRUE(BidiUtils::isTransparentMark(0x064E)); // Arabic fatha
EXPECT_TRUE(BidiUtils::isTransparentMark(0x0651)); // Arabic shadda
EXPECT_TRUE(BidiUtils::isTransparentMark(0x0670)); // superscript alef
EXPECT_TRUE(BidiUtils::isTransparentMark(0x06D6)); // Quranic annotation
}
TEST(TransparentMark, LettersAndLatinMarksAreNot) {
EXPECT_FALSE(BidiUtils::isTransparentMark(0x0300)); // Latin combining grave — different path
EXPECT_FALSE(BidiUtils::isTransparentMark(0x0628)); // Arabic beh
EXPECT_FALSE(BidiUtils::isTransparentMark(0x05D0)); // Hebrew alef
EXPECT_FALSE(BidiUtils::isTransparentMark(0x0661)); // Arabic-Indic digit
EXPECT_FALSE(BidiUtils::isTransparentMark('a'));
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Host-test stub for lib/Logging/Logging.h, which depends on Arduino's
// HardwareSerial and cannot compile on the host. Logging is a no-op here.
#define LOG_ERR(origin, format, ...)
#define LOG_INF(origin, format, ...)
#define LOG_DBG(origin, format, ...)