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

Co-authored-by: Zach Nelson <zach@zdnelson.com>
This commit is contained in:
Uri Tauber
2026-05-29 03:25:17 -04:00
committed by GitHub
co-authored by Zach Nelson
parent cc2079a578
commit f5bc554ae7
27 changed files with 1621 additions and 119 deletions
+275 -83
View File
@@ -1,5 +1,6 @@
#include "ParsedText.h"
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Utf8.h>
@@ -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<const unsigned char*>(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<EpdFontFamily::Style>(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<const char*>(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<size_t> 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<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
const int pageWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& 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<size_t> 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<std::string> lineWords;
lineWords.reserve(lineWordCount);
std::vector<EpdFontFamily::Style> 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<int>(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<int16_t>(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<int16_t> 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<int>(reorderedGapCount)
: 0;
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? reorderedJustifyExtra * static_cast<int>(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<int16_t>(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<int>(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<int16_t>(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<int16_t>(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<int16_t>(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<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
std::make_move_iterator(words.begin() + lineBreak));
std::vector<EpdFontFamily::Style> 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<uint8_t>(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<uint16_t>(lineXPos[i + 1] - lineXPos[i]);
const int suffixDelta = static_cast<int>(lineXPos[i + 1]) - static_cast<int>(lineXPos[i]);
suffixX = static_cast<uint16_t>(suffixDelta > 0 ? suffixDelta : 0);
}
outWords.push_back(std::move(lineWords[i]));
outXPos.push_back(lineXPos[i]);
+13 -2
View File
@@ -21,8 +21,17 @@ class ParsedText {
bool extraParagraphSpacing;
bool hyphenationEnabled;
bool focusReadingEnabled;
bool isNaturalAlign;
bool hasRtlWord;
std::vector<std::string> reorderedWordsScratch;
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
std::vector<uint16_t> reorderedWidthsScratch;
std::vector<bool> reorderedContinuesScratch;
std::vector<bool> reorderedFocusSuffixScratch;
std::vector<uint16_t> visualOrderScratch;
void applyParagraphIndent();
int resolveFirstLineIndent(bool isFirstLine) const;
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<size_t> 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<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
};
};
+1 -1
View File
@@ -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) +
+13
View File
@@ -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<int16_t>(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;
}
};
+12 -5
View File
@@ -1,5 +1,6 @@
#include "TextBlock.h"
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h>
@@ -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::BidiBaseDir>(
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<size_t>({static_cast<size_t>(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<uint8_t>(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> 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<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
+21 -3
View File
@@ -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<uint8_t>(style.fontStyle));
file.write(static_cast<uint8_t>(style.fontWeight));
file.write(static_cast<uint8_t>(style.textDecoration));
file.write(static_cast<uint8_t>(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<uint8_t>(style.display));
file.write(static_cast<uint8_t>(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<const uint8_t*>(&definedBits), sizeof(definedBits));
}
@@ -862,6 +873,12 @@ bool CssParser::loadFromCache() {
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
return false;
}
style.direction = static_cast<CssTextDirection>(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;
}
+1 -1
View File
@@ -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;
+16 -2
View File
@@ -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{};
@@ -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 <html dir="rtl"> / <body dir="rtl">.
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;
@@ -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);