From f5bc554ae7cd0efd3c24d034df263521ac1e0b40 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Fri, 29 May 2026 10:25:17 +0300 Subject: [PATCH] feat: add RTL support in epub and txt readers (#1700) Co-authored-by: Zach Nelson --- lib/EpdFont/scripts/fontconvert_sdcard.py | 1 + lib/Epub/Epub/ParsedText.cpp | 358 ++++++++--- lib/Epub/Epub/ParsedText.h | 15 +- lib/Epub/Epub/Section.cpp | 2 +- lib/Epub/Epub/blocks/BlockStyle.h | 13 + lib/Epub/Epub/blocks/TextBlock.cpp | 17 +- lib/Epub/Epub/css/CssParser.cpp | 24 +- lib/Epub/Epub/css/CssParser.h | 2 +- lib/Epub/Epub/css/CssStyle.h | 18 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 56 +- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 5 + lib/GfxRenderer/GfxRenderer.cpp | 83 ++- lib/GfxRenderer/GfxRenderer.h | 17 +- lib/MiniBidi/BidiUtils.cpp | 234 ++++++++ lib/MiniBidi/BidiUtils.h | 22 + lib/MiniBidi/bidi_pairs.t | 39 ++ lib/MiniBidi/bidiclasses.t | 115 ++++ lib/MiniBidi/minibidi.c | 565 ++++++++++++++++++ lib/MiniBidi/minibidi.h | 115 ++++ lib/Utf8/Utf8.cpp | 18 + lib/Utf8/Utf8.h | 2 + src/activities/reader/TxtReaderActivity.cpp | 12 +- test/epubs/test_supsub.epub | Bin 6445 -> 6445 bytes .../RTL/Bidi-Test_ch1_p1_0pct_398667.bmp | Bin 0 -> 48062 bytes .../RTL/Bidi-Test_ch1_p2_50pct_422825.bmp | Bin 0 -> 48062 bytes test/language/RTL/README.md | 7 + test/language/RTL/RTL_test.epub | Bin 0 -> 1729 bytes 27 files changed, 1621 insertions(+), 119 deletions(-) create mode 100644 lib/MiniBidi/BidiUtils.cpp create mode 100644 lib/MiniBidi/BidiUtils.h create mode 100644 lib/MiniBidi/bidi_pairs.t create mode 100644 lib/MiniBidi/bidiclasses.t create mode 100644 lib/MiniBidi/minibidi.c create mode 100644 lib/MiniBidi/minibidi.h create mode 100644 test/language/RTL/Bidi-Test_ch1_p1_0pct_398667.bmp create mode 100644 test/language/RTL/Bidi-Test_ch1_p2_50pct_422825.bmp create mode 100644 test/language/RTL/README.md create mode 100644 test/language/RTL/RTL_test.epub diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index 1c5dad35..a6d9db5e 100755 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -43,6 +43,7 @@ INTERVAL_PRESETS = { (0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)], "greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)], "cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)], + "hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)], "georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)], "armenian": [(0x0530, 0x058F)], "ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)], diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 09412d19..74a29d1a 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -1,5 +1,6 @@ #include "ParsedText.h" +#include #include #include @@ -18,6 +19,19 @@ namespace { // Soft hyphen byte pattern used throughout EPUBs (UTF-8 for U+00AD). constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD"; constexpr size_t SOFT_HYPHEN_BYTES = 2; +// Paragraph-level direction: scan the first N words to find base direction. +constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3; +// Per-word: scan enough chars to see through leading neutrals (quotes, numbers) +// before giving up. 64 is a hedge for pathological cases like long numeric tokens. +constexpr int RTL_PER_WORD_PROBE_DEPTH = 64; + +// Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB. +bool mayContainRtlBytes(const char* str) { + for (const auto* p = reinterpret_cast(str); *p; ++p) { + if (*p >= 0xD6 && *p <= 0xDB) return true; + } + return false; +} // Returns the first rendered codepoint of a word (skipping leading soft hyphens). uint32_t firstCodepoint(const std::string& word) { @@ -115,6 +129,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, if (underline) { baseStyle = static_cast(baseStyle | EpdFontFamily::UNDERLINE); } + const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) && + BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH); // Already-bold text should stay fully bold; focus splitting would make its suffix regular later. if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) { @@ -122,6 +138,9 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, wordStyles.push_back(baseStyle); wordContinues.push_back(attachToPrevious); wordIsFocusSuffix.push_back(false); + if (wordStartsRtl) { + hasRtlWord = true; + } return; } @@ -237,6 +256,17 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, size_t segmentLen = end - segmentStart; std::string_view segment(reinterpret_cast(segmentStart), segmentLen); processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true); + if (wordStartsRtl) { + hasRtlWord = true; + } +} + +int ParsedText::resolveFirstLineIndent(const bool isFirstLine) const { + if (isFirstLine && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) && + isNaturalAlign) { + return blockStyle.textIndent; + } + return 0; } // Consumes data to minimize memory usage void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, @@ -246,6 +276,23 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo return; } + // Per-paragraph RTL auto-detection: only when CSS/HTML didn't explicitly set direction. + // Explicit dir="ltr" must be respected and not overridden by content heuristic. + if (!blockStyle.directionDefined && hasRtlWord) { + // Check the first few words for RTL letter codepoints (no heap allocation). + const size_t wordsToScan = std::min(words.size(), RTL_PARAGRAPH_PROBE_WORDS); + for (size_t i = 0; i < wordsToScan; ++i) { + if (BidiUtils::startsWithRtl(words[i].c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH)) { + blockStyle.isRtl = true; + break; + } + } + } + + isNaturalAlign = + blockStyle.alignment == CssTextAlign::Justify || + (blockStyle.isRtl ? blockStyle.alignment == CssTextAlign::Right : blockStyle.alignment == CssTextAlign::Left); + // Apply fixed transforms before any per-line layout work. applyParagraphIndent(); @@ -309,15 +356,7 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c return {}; } - // Calculate first line indent (only for left/justified text). - // Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on. - // Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies — - // it is structural (positions the bullet/marker), not decorative. - const int firstLineIndent = - blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? blockStyle.textIndent - : 0; + const int firstLineIndent = resolveFirstLineIndent(true); // Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation. for (size_t i = 0; i < wordWidths.size(); ++i) { @@ -431,7 +470,7 @@ void ParsedText::applyParagraphIndent() { if (blockStyle.textIndentDefined) { // CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace // The actual indent positioning is handled in extractLine() - } else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) { + } else if (isNaturalAlign) { // No CSS text-indent defined - use EmSpace fallback for visual indent words.front().insert(0, "\xe2\x80\x83"); } @@ -441,15 +480,7 @@ void ParsedText::applyParagraphIndent() { std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, std::vector& continuesVec) { - // Calculate first line indent (only for left/justified text). - // Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on. - // Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies — - // it is structural (positions the bullet/marker), not decorative. - const int firstLineIndent = - blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? blockStyle.textIndent - : 0; + const int firstLineIndent = resolveFirstLineIndent(true); std::vector lineBreakIndices; size_t currentIndex = 0; @@ -612,16 +643,22 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; const size_t lineWordCount = lineBreak - lastBreakAt; - // Calculate first line indent (only for left/justified text). - // Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on. - // Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies — - // it is structural (positions the bullet/marker), not decorative. - const bool isFirstLine = breakIndex == 0; - const int firstLineIndent = - isFirstLine && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) && - (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) - ? blockStyle.textIndent - : 0; + const int firstLineIndent = resolveFirstLineIndent(breakIndex == 0); + + // Build line data by moving from the original vectors using index range + std::vector lineWords; + lineWords.reserve(lineWordCount); + std::vector lineWordStyles; + lineWordStyles.reserve(lineWordCount); + + for (size_t i = 0; i < lineWordCount; ++i) { + std::string word = std::move(words[lastBreakAt + i]); + if (containsSoftHyphen(word)) { + stripSoftHyphensInPlace(word); + } + lineWords.push_back(std::move(word)); + lineWordStyles.push_back(wordStyles[lastBreakAt + i]); + } // Calculate total word width for this line, count actual word gaps, // and accumulate total natural gap widths (including space kerning adjustments). @@ -634,19 +671,17 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const // Count gaps: each word after the first creates a gap, unless it's a continuation if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) { actualGapCount++; - totalNaturalGaps += - renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]), - firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]); + totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]), + firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]); } else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) { // Non-breaking space tokens (" " with continues=true) are visible, stretchable spaces — // count them as justifiable gaps so justifyExtra is distributed to them too. - if (words[lastBreakAt + wordIdx] == " ") { + if (lineWords[wordIdx] == " ") { actualGapCount++; } // Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation) - totalNaturalGaps += - renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]), - firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]); + totalNaturalGaps += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx - 1]), + firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]); } } @@ -654,73 +689,229 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const int effectivePageWidth = pageWidth - firstLineIndent; const bool isLastLine = breakIndex == lineBreakIndices.size() - 1; + // For RTL, implicit/default Left alignment becomes Right alignment. + // Explicit text-align:left must remain left for CSS correctness. + const CssTextAlign effectiveAlignment = + (blockStyle.isRtl && !blockStyle.textAlignDefined && blockStyle.alignment == CssTextAlign::Left) + ? CssTextAlign::Right + : blockStyle.alignment; + // For justified text, compute per-gap extra to distribute remaining space evenly const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; - const int justifyExtra = (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1) + const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1) ? spareSpace / static_cast(actualGapCount) : 0; - // Calculate initial x position (first line starts at indent for left/justified text; - // may be negative for hanging indents, e.g. margin-left:3em; text-indent:-1em). - auto xpos = static_cast(firstLineIndent); - if (blockStyle.alignment == CssTextAlign::Right) { - xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; - } else if (blockStyle.alignment == CssTextAlign::Center) { - xpos = (effectivePageWidth - lineWordWidthSum - totalNaturalGaps) / 2; - } + // BiDi processing: reorder words with UAX#9 in full-line context. + visualOrderScratch.clear(); + visualOrderScratch.reserve(lineWordCount); + // Skip expensive visual-order resolution for pure LTR paragraphs that have no RTL words. + const bool shouldResolveVisualOrder = blockStyle.isRtl || hasRtlWord; + const bool willReorder = + shouldResolveVisualOrder && BidiUtils::computeVisualWordOrder(lineWords, blockStyle.isRtl, visualOrderScratch); - // Pre-calculate X positions for words - // Continuation words attach to the previous word with no space before them std::vector lineXPos; lineXPos.reserve(lineWordCount); - for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { - lineXPos.push_back(xpos); + if (willReorder) { + reorderedWordsScratch.clear(); + reorderedStylesScratch.clear(); + reorderedWidthsScratch.clear(); + reorderedContinuesScratch.clear(); + reorderedFocusSuffixScratch.clear(); + reorderedWordsScratch.reserve(visualOrderScratch.size()); + reorderedStylesScratch.reserve(visualOrderScratch.size()); + reorderedWidthsScratch.reserve(visualOrderScratch.size()); + reorderedContinuesScratch.reserve(visualOrderScratch.size()); + reorderedFocusSuffixScratch.reserve(visualOrderScratch.size()); - const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; - if (nextIsContinuation) { - int advance = wordWidths[lastBreakAt + wordIdx]; - // Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation) - advance += - renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx]), - firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]); - // Non-breaking space tokens are stretchable — expand them during justification like normal spaces. - if (words[lastBreakAt + wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && - blockStyle.alignment == CssTextAlign::Justify && !isLastLine) { - advance += justifyExtra; + for (size_t i = 0; i < visualOrderScratch.size(); ++i) { + const uint16_t src = visualOrderScratch[i]; + reorderedWordsScratch.push_back(std::move(lineWords[src])); + reorderedStylesScratch.push_back(lineWordStyles[src]); + reorderedWidthsScratch.push_back(wordWidths[lastBreakAt + src]); + reorderedFocusSuffixScratch.push_back(wordIsFocusSuffix[lastBreakAt + src]); + + // Continuation means "no break/gap between two adjacent logical tokens". + // After visual reordering (common in RTL), an adjacent logical pair can appear + // as either (prev -> curr) or (curr -> prev) in visual order; preserve both. + bool continues = false; + if (i > 0) { + const size_t prevSrc = visualOrderScratch[i - 1]; + const size_t currSrc = src; + const bool forwardAdjacent = currSrc == prevSrc + 1; + const bool reverseAdjacent = prevSrc == currSrc + 1; + + if (forwardAdjacent && continuesVec[lastBreakAt + currSrc]) { + continues = true; + } else if (reverseAdjacent && continuesVec[lastBreakAt + prevSrc]) { + continues = true; + } + } + reorderedContinuesScratch.push_back(continues); + } + + int reorderedWordWidthSum = 0; + size_t reorderedGapCount = 0; + int reorderedNaturalGaps = 0; + for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) { + reorderedWordWidthSum += reorderedWidthsScratch[wordIdx]; + if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) { + reorderedGapCount++; + reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), + firstCodepoint(reorderedWordsScratch[wordIdx]), + reorderedStylesScratch[wordIdx - 1]); + } else if (wordIdx > 0 && reorderedContinuesScratch[wordIdx]) { + if (reorderedWordsScratch[wordIdx] == " ") { + reorderedGapCount++; + } + reorderedNaturalGaps += + renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), + firstCodepoint(reorderedWordsScratch[wordIdx]), reorderedStylesScratch[wordIdx - 1]); + } + } + + const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps; + const int reorderedJustifyExtra = + (effectiveAlignment == CssTextAlign::Justify && !isLastLine && reorderedGapCount >= 1) + ? reorderedSpare / static_cast(reorderedGapCount) + : 0; + + const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine) + ? reorderedJustifyExtra * static_cast(reorderedGapCount) + : 0; + const int contentWidth = reorderedWordWidthSum + reorderedNaturalGaps + justifyContribution; + + int xpos = 0; + if (blockStyle.isRtl) { + if (effectiveAlignment == CssTextAlign::Right || effectiveAlignment == CssTextAlign::Justify) { + xpos = effectivePageWidth - contentWidth; + } else if (effectiveAlignment == CssTextAlign::Center) { + xpos = (effectivePageWidth - contentWidth) / 2; } - xpos += advance; } else { - int gap = 0; - if (wordIdx + 1 < lineWordCount) { - gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx]), - firstCodepoint(words[lastBreakAt + wordIdx + 1]), - wordStyles[lastBreakAt + wordIdx]); + xpos = firstLineIndent; + if (effectiveAlignment == CssTextAlign::Right) { + xpos = effectivePageWidth - contentWidth; + } else if (effectiveAlignment == CssTextAlign::Center) { + xpos = (effectivePageWidth - contentWidth) / 2; } - if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine) { - gap += justifyExtra; + } + + for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) { + lineXPos.push_back(static_cast(xpos < 0 ? 0 : xpos)); + xpos += reorderedWidthsScratch[wordIdx]; + + const bool nextIsContinuation = + wordIdx + 1 < reorderedWidthsScratch.size() && reorderedContinuesScratch[wordIdx + 1]; + if (nextIsContinuation) { + int advance = + renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), + firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]); + if (reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] && + effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + advance += reorderedJustifyExtra; + } + xpos += advance; + } else if (wordIdx + 1 < reorderedWidthsScratch.size()) { + int gap = renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), + firstCodepoint(reorderedWordsScratch[wordIdx + 1]), + reorderedStylesScratch[wordIdx]); + if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + gap += reorderedJustifyExtra; + } + xpos += gap; + } + } + + lineWords.swap(reorderedWordsScratch); + lineWordStyles.swap(reorderedStylesScratch); + } else { + // Standard LTR/RTL positioning loop when no visual reordering is needed + if (blockStyle.isRtl) { + // RTL: position words from right to left + auto xpos = static_cast(effectivePageWidth); + if (effectiveAlignment == CssTextAlign::Left) { + // Explicit left alignment in RTL context + xpos = lineWordWidthSum + totalNaturalGaps; + } else if (effectiveAlignment == CssTextAlign::Center) { + xpos = (effectivePageWidth + lineWordWidthSum + totalNaturalGaps) / 2; + } + // For Right and Justify, start from right edge (xpos = effectivePageWidth) + + for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { + xpos -= wordWidths[lastBreakAt + wordIdx]; + lineXPos.push_back(static_cast(xpos < 0 ? 0 : xpos)); + + const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; + if (nextIsContinuation) { + // Cross-boundary kerning for continuation words + int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && + effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + advance += justifyExtra; + } + xpos -= advance; + } else { + int gap = 0; + if (wordIdx + 1 < lineWordCount) { + gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + } + if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + gap += justifyExtra; + } + xpos -= gap; + } + } + } else { + // LTR: position words from left to right + auto xpos = static_cast(firstLineIndent); + if (effectiveAlignment == CssTextAlign::Right) { + xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; + } else if (effectiveAlignment == CssTextAlign::Center) { + xpos = (effectivePageWidth - lineWordWidthSum - totalNaturalGaps) / 2; + } + + for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { + lineXPos.push_back(static_cast(xpos < 0 ? 0 : xpos)); + + const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; + if (nextIsContinuation) { + int advance = wordWidths[lastBreakAt + wordIdx]; + advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && + effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + advance += justifyExtra; + } + xpos += advance; + } else { + int gap = 0; + if (wordIdx + 1 < lineWordCount) { + gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + } + if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { + gap += justifyExtra; + } + xpos += wordWidths[lastBreakAt + wordIdx] + gap; + } } - xpos += wordWidths[lastBreakAt + wordIdx] + gap; } } - // Build line data by moving from the original vectors using index range - std::vector lineWords(std::make_move_iterator(words.begin() + lastBreakAt), - std::make_move_iterator(words.begin() + lineBreak)); - std::vector lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak); - - for (auto& word : lineWords) { - if (containsSoftHyphen(word)) { - stripSoftHyphensInPlace(word); - } - } + const auto isFocusSuffixAt = [&](const size_t idx) { + return willReorder ? reorderedFocusSuffixScratch[idx] : wordIsFocusSuffix[lastBreakAt + idx]; + }; // Fast path: when no word on this line was split for focus reading, skip the merge work // entirely and pass empty boundary/suffixX vectors. TextBlock pays zero per-word RAM cost // for these annotations when the vectors are empty. bool lineHasFocusSplit = false; for (size_t i = 0; i < lineWordCount; i++) { - if (wordIsFocusSuffix[lastBreakAt + i]) { + if (isFocusSuffixAt(i)) { lineHasFocusSplit = true; break; } @@ -747,17 +938,18 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const outSuffixX.reserve(lineWordCount); for (size_t i = 0; i < lineWordCount; i++) { - if (wordIsFocusSuffix[lastBreakAt + i] && !outWords.empty()) { + if (isFocusSuffixAt(i) && !outWords.empty()) { // Focus suffix: merge string into the preceding bold-prefix entry. outWords.back() += lineWords[i]; } else { // Normal word: check for a following focus suffix to record the byte boundary. uint8_t boundary = 0; uint16_t suffixX = 0; - if (i + 1 < lineWordCount && wordIsFocusSuffix[lastBreakAt + i + 1]) { + if (i + 1 < lineWordCount && isFocusSuffixAt(i + 1)) { boundary = static_cast(std::min(lineWords[i].size(), size_t{255})); // Suffix x offset = layout-time advance of the bold prefix, already known from xpos table. - suffixX = static_cast(lineXPos[i + 1] - lineXPos[i]); + const int suffixDelta = static_cast(lineXPos[i + 1]) - static_cast(lineXPos[i]); + suffixX = static_cast(suffixDelta > 0 ? suffixDelta : 0); } outWords.push_back(std::move(lineWords[i])); outXPos.push_back(lineXPos[i]); diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 167d3d26..df5dd3f1 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -21,8 +21,17 @@ class ParsedText { bool extraParagraphSpacing; bool hyphenationEnabled; bool focusReadingEnabled; + bool isNaturalAlign; + bool hasRtlWord; + std::vector reorderedWordsScratch; + std::vector reorderedStylesScratch; + std::vector reorderedWidthsScratch; + std::vector reorderedContinuesScratch; + std::vector reorderedFocusSuffixScratch; + std::vector visualOrderScratch; void applyParagraphIndent(); + int resolveFirstLineIndent(bool isFirstLine) const; std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, @@ -41,7 +50,9 @@ class ParsedText { : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled), - focusReadingEnabled(focusReadingEnabled) {} + focusReadingEnabled(focusReadingEnabled), + isNaturalAlign(false), + hasRtlWord(false) {} ~ParsedText() = default; void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); @@ -52,4 +63,4 @@ class ParsedText { void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function)>& processLine, bool includeLastLine = true); -}; \ No newline at end of file +}; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 6b5fa7f6..4ccfac0f 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -10,7 +10,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 24; +constexpr uint8_t SECTION_FILE_VERSION = 25; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index b63b57b6..23ed790b 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -29,6 +29,8 @@ struct BlockStyle { int16_t textIndent = 0; bool textIndentDefined = false; // true if text-indent was explicitly set in CSS bool textAlignDefined = false; // true if text-align was explicitly set in CSS + bool isRtl = false; // true if resolved direction is RTL + bool directionDefined = false; // true if direction was explicitly set in CSS/HTML // Combined insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } @@ -84,6 +86,12 @@ struct BlockStyle { result.paddingBottom = static_cast(child.paddingBottom + paddingBottom); } + // Direction is not axis-specific. Inherit from parent when child doesn't define it. + if (!child.directionDefined && directionDefined) { + result.isRtl = isRtl; + result.directionDefined = true; + } + return result; } @@ -119,6 +127,11 @@ struct BlockStyle { } else { blockStyle.alignment = paragraphAlignment; } + // RTL direction from CSS/HTML + if (cssStyle.hasDirection()) { + blockStyle.isRtl = (cssStyle.direction == CssTextDirection::Rtl); + blockStyle.directionDefined = true; + } return blockStyle; } }; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 0735b3c2..a9fefb25 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -1,5 +1,6 @@ #include "TextBlock.h" +#include #include #include #include @@ -22,6 +23,8 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int for (size_t i = 0; i < words.size(); i++) { const int wordX = wordXpos[i] + x; const EpdFontFamily::Style currentStyle = wordStyles[i]; + const auto baseDir = static_cast( + BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0)); const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0; // SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside @@ -48,16 +51,16 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); memcpy(boldBuf, words[i].c_str(), boldLen); boldBuf[boldLen] = '\0'; - renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle); + renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir); const int suffixX = wordX + wordFocusSuffixX[i]; - renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle); + renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir); } else { - renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle); + renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir); } if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) { const std::string& w = words[i]; - const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle); + const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); // y is the top of the text line; add ascender to reach baseline, then offset 2px below const int underlineY = wordY + ascender + 2; @@ -69,7 +72,7 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int static_cast(w[2]) == 0x83) { const char* visiblePtr = w.c_str() + 3; const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); - const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); + const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle, baseDir); startX = wordX + prefixWidth; underlineWidth = visibleWidth; } @@ -118,6 +121,8 @@ bool TextBlock::serialize(HalFile& file) const { serialization::writePod(file, blockStyle.paddingRight); serialization::writePod(file, blockStyle.textIndent); serialization::writePod(file, blockStyle.textIndentDefined); + serialization::writePod(file, blockStyle.isRtl); + serialization::writePod(file, blockStyle.directionDefined); return true; } @@ -171,6 +176,8 @@ std::unique_ptr TextBlock::deserialize(HalFile& file) { serialization::readPod(file, blockStyle.paddingRight); serialization::readPod(file, blockStyle.textIndent); serialization::readPod(file, blockStyle.textIndentDefined); + serialization::readPod(file, blockStyle.isRtl); + serialization::readPod(file, blockStyle.directionDefined); return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), std::move(wordFocusBoundary), std::move(wordFocusSuffixX), diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index b4a9f4f1..cd023c21 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -344,6 +344,15 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty const std::string_view displayValue = stripTrailingImportant(propValueBuf); style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block; style.defined.display = 1; + } else if (propNameBuf == "direction") { + const std::string_view directionValue = stripTrailingImportant(propValueBuf); + if (directionValue == "rtl") { + style.direction = CssTextDirection::Rtl; + style.defined.direction = 1; + } else if (directionValue == "ltr") { + style.direction = CssTextDirection::Ltr; + style.defined.direction = 1; + } } else if (propNameBuf == "vertical-align") { const std::string v = normalized(propValueBuf); if (v == "super") { @@ -710,6 +719,7 @@ bool CssParser::saveToCache() const { file.write(static_cast(style.fontStyle)); file.write(static_cast(style.fontWeight)); file.write(static_cast(style.textDecoration)); + file.write(static_cast(style.direction)); // Write CssLength fields (value + unit) auto writeLength = [&file](const CssLength& len) { @@ -731,7 +741,7 @@ bool CssParser::saveToCache() const { file.write(static_cast(style.display)); file.write(static_cast(style.verticalAlign)); - // Write defined flags as uint16_t + // Write defined flags as uint32_t uint32_t definedBits = 0; if (style.defined.textAlign) definedBits |= 1 << 0; if (style.defined.fontStyle) definedBits |= 1 << 1; @@ -749,7 +759,8 @@ bool CssParser::saveToCache() const { if (style.defined.imageHeight) definedBits |= 1 << 13; if (style.defined.imageWidth) definedBits |= 1 << 14; if (style.defined.display) definedBits |= 1 << 15; - if (style.defined.verticalAlign) definedBits |= 1 << 16; + if (style.defined.direction) definedBits |= 1 << 16; + if (style.defined.verticalAlign) definedBits |= 1 << 17; file.write(reinterpret_cast(&definedBits), sizeof(definedBits)); } @@ -862,6 +873,12 @@ bool CssParser::loadFromCache() { } style.textDecoration = static_cast(enumVal); + if (file.read(&enumVal, 1) != 1) { + rulesBySelector_.clear(); + return false; + } + style.direction = static_cast(enumVal); + // Read CssLength fields auto readLength = [&file](CssLength& len) -> bool { if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) { @@ -921,7 +938,8 @@ bool CssParser::loadFromCache() { style.defined.imageHeight = (definedBits & 1 << 13) != 0; style.defined.imageWidth = (definedBits & 1 << 14) != 0; style.defined.display = (definedBits & 1 << 15) != 0; - style.defined.verticalAlign = (definedBits & 1 << 16) != 0; + style.defined.direction = (definedBits & 1 << 16) != 0; + style.defined.verticalAlign = (definedBits & 1 << 17) != 0; rulesBySelector_[selector] = style; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 77795e6d..f5ead1f7 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -31,7 +31,7 @@ class CssParser { public: // Bump when CSS cache format or rules change; section caches are invalidated when this changes - static constexpr uint8_t CSS_CACHE_VERSION = 5; + static constexpr uint8_t CSS_CACHE_VERSION = 6; explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {} ~CssParser() = default; diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 9af9fa6c..3fd47c8b 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -5,6 +5,7 @@ // Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 }; enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3, Percent = 4 }; +enum class CssTextDirection : uint8_t { Ltr = 0, Rtl = 1 }; // Represents a CSS length value with its unit, allowing deferred resolution to pixels struct CssLength { @@ -78,6 +79,7 @@ struct CssPropertyFlags { uint16_t imageHeight : 1; uint16_t imageWidth : 1; uint16_t display : 1; + uint16_t direction : 1; uint16_t verticalAlign : 1; CssPropertyFlags() @@ -97,22 +99,27 @@ struct CssPropertyFlags { imageHeight(0), imageWidth(0), display(0), + direction(0), verticalAlign(0) {} [[nodiscard]] bool anySet() const { return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight || - imageWidth || display || verticalAlign; + imageWidth || display || direction || verticalAlign; } void clearAll() { textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; marginTop = marginBottom = marginLeft = marginRight = 0; paddingTop = paddingBottom = paddingLeft = paddingRight = 0; - imageHeight = imageWidth = display = verticalAlign = 0; + imageHeight = imageWidth = display = direction = verticalAlign = 0; } }; +// Cache serializes defined flags as uint32_t with bit indices 0..17. +static_assert(sizeof(CssPropertyFlags) <= sizeof(uint32_t), + "CssPropertyFlags exceeds 32 bits; update cache read/write in CssParser.cpp"); + // Represents a collection of CSS style properties // Only stores properties relevant to e-ink text rendering // Length values are stored as CssLength (value + unit) for deferred resolution @@ -121,6 +128,7 @@ struct CssStyle { CssFontStyle fontStyle = CssFontStyle::Normal; CssFontWeight fontWeight = CssFontWeight::Normal; CssTextDecoration textDecoration = CssTextDecoration::None; + CssTextDirection direction = CssTextDirection::Ltr; CssLength textIndent; // First-line indent (deferred resolution) CssLength marginTop; // Vertical spacing before block @@ -205,6 +213,10 @@ struct CssStyle { display = base.display; defined.display = 1; } + if (base.hasDirection()) { + direction = base.direction; + defined.direction = 1; + } if (base.hasVerticalAlign()) { verticalAlign = base.verticalAlign; defined.verticalAlign = 1; @@ -227,6 +239,7 @@ struct CssStyle { [[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; } [[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; } [[nodiscard]] bool hasDisplay() const { return defined.display; } + [[nodiscard]] bool hasDirection() const { return defined.direction; } [[nodiscard]] bool hasVerticalAlign() const { return defined.verticalAlign; } void reset() { @@ -234,6 +247,7 @@ struct CssStyle { fontStyle = CssFontStyle::Normal; fontWeight = CssFontWeight::Normal; textDecoration = CssTextDecoration::None; + direction = CssTextDirection::Ltr; textIndent = CssLength{}; marginTop = marginBottom = marginLeft = marginRight = CssLength{}; paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 1c763cba..9d1277b9 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -67,6 +67,13 @@ bool isTableStructuralTag(const char* name) { return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0; } +void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css) { + if (css.hasDirection()) { + entry.hasDirection = true; + entry.direction = css.direction; + } +} + // Update effective bold/italic/underline based on block style and inline style stack void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles @@ -74,6 +81,8 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic; effectiveUnderline = currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline; + effectiveDirectionDefined = currentCssStyle.hasDirection(); + effectiveDirection = currentCssStyle.direction; effectiveSup = false; effectiveSub = false; @@ -88,6 +97,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { if (entry.hasUnderline) { effectiveUnderline = entry.underline; } + if (entry.hasDirection) { + effectiveDirectionDefined = true; + effectiveDirection = entry.direction; + } if (entry.hasSup) { effectiveSup = entry.sup; if (entry.sup) effectiveSub = false; @@ -97,6 +110,19 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { if (entry.sub) effectiveSup = false; } } + + // Keep inherited direction in the active empty text block so upcoming block starts + // can inherit from non-block ancestors such as / . + if (currentTextBlock && currentTextBlock->isEmpty()) { + auto& style = currentTextBlock->getBlockStyle(); + if (effectiveDirectionDefined) { + style.directionDefined = true; + style.isRtl = (effectiveDirection == CssTextDirection::Rtl); + } else { + style.directionDefined = false; + style.isRtl = false; + } + } } void ChapterHtmlSlimParser::flushPendingAnchor() { @@ -254,9 +280,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->xpathListItemIndex++; } - // Extract class, style, and id attributes + // Extract class, style, id, and dir attributes for CSS/RTL processing std::string classAttr; std::string styleAttr; + std::string dirAttr; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "class") == 0) { @@ -267,6 +294,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // Defer both anchor recording and TOC page breaks until startNewTextBlock, // after the previous block is flushed to pages via makePages(). self->pendingAnchorId = atts[i + 1]; + } else if (strcmp(atts[i], "dir") == 0) { + dirAttr = atts[i + 1]; } } } @@ -286,6 +315,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } + // HTML dir attribute overrides CSS direction (case-insensitive per HTML spec) + if (!dirAttr.empty()) { + if (strcasecmp(dirAttr.c_str(), "rtl") == 0) { + cssStyle.direction = CssTextDirection::Rtl; + cssStyle.defined.direction = 1; + } else if (strcasecmp(dirAttr.c_str(), "ltr") == 0) { + cssStyle.direction = CssTextDirection::Ltr; + cssStyle.defined.direction = 1; + } + } + + // Direction is inherited in HTML/CSS. If this element does not define one, carry + // the currently active inherited direction into its computed style. + if (!cssStyle.hasDirection() && self->effectiveDirectionDefined) { + cssStyle.direction = self->effectiveDirection; + cssStyle.defined.direction = 1; + } + // Skip elements with display:none before all fast paths (tables, links, etc.). if (cssStyle.hasDisplay() && cssStyle.display == CssDisplay::None) { self->skipUntilDepth = self->depth; @@ -686,6 +733,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.depth = self->depth; entry.hasUnderline = true; entry.underline = true; + ChapterHtmlSlimParser::applyDirectionToEntry(entry, cssStyle); self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); @@ -770,6 +818,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.hasItalic = true; entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; } + ChapterHtmlSlimParser::applyDirectionToEntry(entry, cssStyle); self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); } else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) { @@ -792,6 +841,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.hasUnderline = true; entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; } + ChapterHtmlSlimParser::applyDirectionToEntry(entry, cssStyle); self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); } else if (matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS))) { @@ -814,6 +864,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.hasUnderline = true; entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; } + ChapterHtmlSlimParser::applyDirectionToEntry(entry, cssStyle); self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); } else if (strcmp(name, "sup") == 0 || strcmp(name, "sub") == 0) { @@ -835,7 +886,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) { // Handle span and other inline elements for CSS styling if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration() || - cssStyle.hasVerticalAlign()) { + cssStyle.hasDirection() || cssStyle.hasVerticalAlign()) { // Flush buffer before style change so preceding text gets current style if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); @@ -855,6 +906,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.hasUnderline = true; entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; } + ChapterHtmlSlimParser::applyDirectionToEntry(entry, cssStyle); if (cssStyle.hasVerticalAlign()) { if (cssStyle.verticalAlign == CssVerticalAlign::Super) { entry.hasSup = true; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 2c050b33..d2fd7778 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -61,6 +61,8 @@ class ChapterHtmlSlimParser { bool hasBold = false, bold = false; bool hasItalic = false, italic = false; bool hasUnderline = false, underline = false; + bool hasDirection = false; + CssTextDirection direction = CssTextDirection::Ltr; bool hasSup = false, sup = false; bool hasSub = false, sub = false; }; @@ -70,6 +72,8 @@ class ChapterHtmlSlimParser { bool effectiveBold = false; bool effectiveItalic = false; bool effectiveUnderline = false; + bool effectiveDirectionDefined = false; + CssTextDirection effectiveDirection = CssTextDirection::Ltr; bool effectiveSup = false; bool effectiveSub = false; int tableDepth = 0; @@ -97,6 +101,7 @@ class ChapterHtmlSlimParser { void flushPendingAnchor(); void flushPartWordBuffer(); void makePages(); + static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css); void emitHorizontalRule(const BlockStyle& blockStyle); // XML callbacks static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 0a0b2b03..e5246a07 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1,5 +1,6 @@ #include "GfxRenderer.h" +#include #include #include #include @@ -21,6 +22,10 @@ uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style st } } // namespace +namespace { +const char* resolveVisualText(const char* text, std::string& visualBuffer, BidiUtils::BidiBaseDir baseDir); +} // namespace + const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { if (fontData->groups != nullptr) { auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr; @@ -352,26 +357,42 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { } } -int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { +int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style, + const BidiUtils::BidiBaseDir baseDir) const { + if (text == nullptr || *text == '\0') { + return 0; + } + const auto fontIt = fontMap.find(fontId); if (fontIt == fontMap.end()) { LOG_ERR("GFX", "Font %d not found", fontId); return 0; } + std::string visual; + const char* renderedText = resolveVisualText(text, visual, baseDir); + int w = 0, h = 0; - fontIt->second.getTextDimensions(text, &w, &h, style); + fontIt->second.getTextDimensions(renderedText, &w, &h, style); return w; } void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black, - const EpdFontFamily::Style style) const { - const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2; - drawText(fontId, x, y, text, black, style); + const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const { + const int x = (getScreenWidth() - getTextWidth(fontId, text, style, baseDir)) / 2; + drawText(fontId, x, y, text, black, style, baseDir); } void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, - const EpdFontFamily::Style style) const { + const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const { + // cannot draw a NULL / empty string + if (text == nullptr || *text == '\0') { + return; + } + + std::string visual; + const char* renderedText = resolveVisualText(text, visual, baseDir); + const int yPos = y + getFontAscenderSize(fontId); int lastBaseX = x; int lastBaseLeft = 0; @@ -379,13 +400,8 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha int lastBaseTop = 0; int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap - // cannot draw a NULL / empty string - if (text == nullptr || *text == '\0') { - return; - } - if (fontCacheManager_ && fontCacheManager_->isScanning()) { - fontCacheManager_->recordText(text, fontId, style); + fontCacheManager_->recordText(renderedText, fontId, style); return; } @@ -396,9 +412,16 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } const auto& font = fontIt->second; + const char* textCursor = renderedText; uint32_t cp; uint32_t prevCp = 0; - while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + while ((cp = utf8NextCodepoint(reinterpret_cast(&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)) { const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); if (!combiningGlyph) continue; @@ -409,7 +432,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha continue; } - cp = font.applyLigatures(cp, text, style); + cp = font.applyLigatures(cp, textCursor, style); // Differential rounding: snap (previous advance + current kern) as one unit so // identical character pairs always produce the same pixel step regardless of @@ -443,6 +466,32 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +namespace { +const char* resolveVisualText(const char* text, std::string& visualBuffer, const BidiUtils::BidiBaseDir baseDir) { + if (!text || *text == '\0') return text; + + if (baseDir != BidiUtils::BidiBaseDir::RTL) { + // Byte-level scan: skip BiDi when no RTL script lead bytes are present. + // Hebrew UTF-8 lead bytes: 0xD6-0xD7; Arabic/Syriac: 0xD8-0xDB. + // This covers all RTL content without false negatives and avoids triggering + // the full UAX#9 algorithm for Latin-extended, em-dashes, accented text, etc. + bool hasRtlBytes = false; + for (const unsigned char* q = reinterpret_cast(text); *q; ++q) { + if (*q >= 0xD6 && *q <= 0xDB) { + hasRtlBytes = true; + break; + } + } + if (!hasRtlBytes) return text; + } + + if (BidiUtils::applyBidiVisual(text, visualBuffer, static_cast(baseDir)) && !visualBuffer.empty()) { + return visualBuffer.c_str(); + } + return text; +} +} // namespace + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (fontCacheManager_ && fontCacheManager_->isScanning()) return; if (x1 == x2) { @@ -1481,6 +1530,12 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y uint32_t cp; uint32_t prevCp = 0; while ((cp = utf8NextCodepoint(reinterpret_cast(&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)) { const EpdGlyph* combiningGlyph = font.getGlyph(cp, style); if (!combiningGlyph) continue; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 78f10922..16aa6de9 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -3,6 +3,14 @@ #include #include +namespace BidiUtils { +// Paragraph base direction for the Unicode BiDi algorithm (UAX#9). +// AUTO: scan text for first strong directional character (P2/P3 rules) +// LTR: force left-to-right paragraph embedding level +// RTL: force right-to-left paragraph embedding level +enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; +} // namespace BidiUtils + class FontCacheManager; class SdCardFont; @@ -175,11 +183,14 @@ class GfxRenderer { void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text - int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR, + BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + EpdFontFamily::Style style = EpdFontFamily::REGULAR, + BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + EpdFontFamily::Style style = EpdFontFamily::REGULAR, + BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; int getSpaceWidth(int fontId, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; /// Returns the total inter-word advance: fp4::toPixel(spaceAdvance + kern(leftCp,' ') + kern(' ',rightCp)). /// Using a single snap avoids the +/-1 px rounding error that arises when space advance and kern are diff --git a/lib/MiniBidi/BidiUtils.cpp b/lib/MiniBidi/BidiUtils.cpp new file mode 100644 index 00000000..07f061f8 --- /dev/null +++ b/lib/MiniBidi/BidiUtils.cpp @@ -0,0 +1,234 @@ +#include "BidiUtils.h" + +extern "C" { +#include "minibidi.h" +} + +#undef when +#undef otherwise + +#include +#include + +#include + +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(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(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(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(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& words, bool paragraphIsRtl, + std::vector& 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(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(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(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(nWords) - 1; i >= 0; i--) { + visualOrder.push_back(static_cast(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(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(i); + } + + if (firstNatural[w] == UINT16_MAX && isNaturalDirectionClass(bidi_class(line[i].wc))) { + firstNatural[w] = static_cast(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(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 diff --git a/lib/MiniBidi/BidiUtils.h b/lib/MiniBidi/BidiUtils.h new file mode 100644 index 00000000..c36c9a36 --- /dev/null +++ b/lib/MiniBidi/BidiUtils.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +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& words, bool paragraphIsRtl, + std::vector& visualOrder); + +} // namespace BidiUtils diff --git a/lib/MiniBidi/bidi_pairs.t b/lib/MiniBidi/bidi_pairs.t new file mode 100644 index 00000000..845886bf --- /dev/null +++ b/lib/MiniBidi/bidi_pairs.t @@ -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}, /* › → ‹ */ diff --git a/lib/MiniBidi/bidiclasses.t b/lib/MiniBidi/bidiclasses.t new file mode 100644 index 00000000..f300d3ae --- /dev/null +++ b/lib/MiniBidi/bidiclasses.t @@ -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}, diff --git a/lib/MiniBidi/minibidi.c b/lib/MiniBidi/minibidi.c new file mode 100644 index 00000000..26283702 --- /dev/null +++ b/lib/MiniBidi/minibidi.c @@ -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; + + /* ── X1–X8: 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, ¤tEmbedding, ¤tOverride, ¤tIsolate); + levels[i] = currentEmbedding; + when PDI : if (isolateLevel > 0) { + while (!currentIsolate && dss_count(&dss) > 0) + dss_pop(&dss, ¤tEmbedding, ¤tOverride, ¤tIsolate); + dss_pop(&dss, ¤tEmbedding, ¤tOverride, ¤tIsolate); + 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; +} diff --git a/lib/MiniBidi/minibidi.h b/lib/MiniBidi/minibidi.h new file mode 100644 index 00000000..26555d1b --- /dev/null +++ b/lib/MiniBidi/minibidi.h @@ -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 +#include +#include + +/* ── 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 P–L) 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 */ diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index cdcc495b..b59a14d4 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -56,6 +56,24 @@ uint32_t utf8NextCodepoint(const unsigned char** string) { return cp; } +void utf8AppendCodepoint(uint32_t cp, std::string& out) { + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } +} + int utf8SafeTruncateBuffer(const char* buf, int len) { if (len <= 0) return 0; diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 4ac83a6b..e7238f85 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -5,6 +5,8 @@ #define REPLACEMENT_GLYPH 0xFFFD uint32_t utf8NextCodepoint(const unsigned char** string); +// Appends a Unicode codepoint to a std::string in UTF-8 encoding. +void utf8AppendCodepoint(uint32_t cp, std::string& out); // Remove the last UTF-8 codepoint from a std::string and return the new size. size_t utf8RemoveLastChar(std::string& str); // Truncate string by removing N UTF-8 codepoints from the end. diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 4309a4a2..0f3a7992 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -1,5 +1,6 @@ #include "TxtReaderActivity.h" +#include #include #include #include @@ -358,20 +359,25 @@ void TxtReaderActivity::renderPage() { for (const auto& line : currentPageLines) { if (!line.empty()) { int x = cachedOrientedMarginLeft; + const bool lineIsRtl = BidiUtils::startsWithRtl(line.c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH); + uint8_t effectiveAlignment = cachedParagraphAlignment; + if (lineIsRtl && (effectiveAlignment == CrossPointSettings::LEFT_ALIGN || + effectiveAlignment == CrossPointSettings::JUSTIFIED)) { + effectiveAlignment = CrossPointSettings::RIGHT_ALIGN; + } + const int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR); // Apply text alignment - switch (cachedParagraphAlignment) { + switch (effectiveAlignment) { case CrossPointSettings::LEFT_ALIGN: default: // x already set to left margin break; case CrossPointSettings::CENTER_ALIGN: { - int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR); x = cachedOrientedMarginLeft + (contentWidth - textWidth) / 2; break; } case CrossPointSettings::RIGHT_ALIGN: { - int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR); x = cachedOrientedMarginLeft + contentWidth - textWidth; break; } diff --git a/test/epubs/test_supsub.epub b/test/epubs/test_supsub.epub index d3a987823c42a44effbe2c3bc52fad2f8483e929..74cdf27b8f85ac5c2bb53cbf767f9b9903f73720 100644 GIT binary patch delta 247 zcmZ2$wAP3>z?+#xgaHIxXYQWJYt9U!+%JHr%~Fi>7{QF)Oxqxg5|+6Th6vkL2;&X= z1_)z5=UxaSp8FJpp~I`p1lAtJ?+<27ULqI<7WgZ86Qbm%@NqC>@?ueAkj&(pqK;r% uR?Hqu$3y9bQ2H^HRuzZHr$gzLQ2I5L)|G(B=Sx_D%~>O11Eya~H~|2(`&{$@ delta 247 zcmZ2$wAP3>z?+#xgaHH&SL~X|Yt9U!+%JHr%~Fi>7{QF)Oxqxg5|+6Th6vkL2;&X= z1_)z5=UxaSp8FJpp~I`p1lAtJ?+<27ULqI<7WgZ86Qbm%@NqC>@?ueAkj&(pqK;r% uR?Hqu$3y9bQ2H^HRuzZHr$gzLQ2I5L)|G(B=Sx_D%~>O11Eya~H~|1%j%Ne_ diff --git a/test/language/RTL/Bidi-Test_ch1_p1_0pct_398667.bmp b/test/language/RTL/Bidi-Test_ch1_p1_0pct_398667.bmp new file mode 100644 index 0000000000000000000000000000000000000000..bbb4a734da1a8869a22e10ee5057455275bf80fe GIT binary patch literal 48062 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 F{vYsf*~I_= literal 0 HcmV?d00001 diff --git a/test/language/RTL/Bidi-Test_ch1_p2_50pct_422825.bmp b/test/language/RTL/Bidi-Test_ch1_p2_50pct_422825.bmp new file mode 100644 index 0000000000000000000000000000000000000000..9e1ef9867139293b60f03874007c0428a9cfff74 GIT binary patch literal 48062 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