feat: add RTL support in epub and txt readers (#1700)
Co-authored-by: Zach Nelson <zach@zdnelson.com>
This commit is contained in:
co-authored by
Zach Nelson
parent
cc2079a578
commit
f5bc554ae7
+275
-83
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user