Merge pull request #156 from jpirnay/feat-bionic
feat: Add yet another bionic reader implementation
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "hyphenation/HyphenationCommon.h"
|
||||
#include "hyphenation/Hyphenator.h"
|
||||
|
||||
constexpr int MAX_COST = std::numeric_limits<int>::max();
|
||||
@@ -123,6 +124,71 @@ std::string buildLinePreview(const std::vector<std::string>& words, const std::v
|
||||
return preview;
|
||||
}
|
||||
|
||||
constexpr int kBionicReadingMinCodepoints = 4;
|
||||
constexpr int kBionicReadingMinBoldPrefix = 1;
|
||||
constexpr int kBionicReadingBoldPrefixNumerator = 1;
|
||||
constexpr int kBionicReadingBoldPrefixDenominator = 2;
|
||||
|
||||
struct TokenSpan {
|
||||
size_t start;
|
||||
size_t end;
|
||||
bool isWord;
|
||||
};
|
||||
|
||||
static int computeBionicBoldPrefixCount(const int codepointCount) {
|
||||
return std::max(kBionicReadingMinBoldPrefix,
|
||||
(codepointCount * kBionicReadingBoldPrefixNumerator + kBionicReadingBoldPrefixDenominator - 1) /
|
||||
kBionicReadingBoldPrefixDenominator);
|
||||
}
|
||||
|
||||
static bool isBionicWordCodepoint(const uint32_t cp) {
|
||||
if (cp == 0) {
|
||||
return false;
|
||||
}
|
||||
if (utf8IsCombiningMark(cp)) {
|
||||
return true;
|
||||
}
|
||||
return isAlphabetic(cp) || isAsciiDigit(cp) || isApostrophe(cp);
|
||||
}
|
||||
|
||||
// Split a word token into contiguous spans of "word-like" characters and non-word characters.
|
||||
// This avoids applying bionic bolding to punctuation, digits-only runs, or other separators.
|
||||
// Only spans marked as word-like are eligible for the bionic prefix transform.
|
||||
static std::vector<TokenSpan> tokenizeBionicWord(const std::string& word) {
|
||||
std::vector<TokenSpan> spans;
|
||||
spans.reserve(2);
|
||||
|
||||
const unsigned char* base = reinterpret_cast<const unsigned char*>(word.c_str());
|
||||
const unsigned char* ptr = base;
|
||||
const unsigned char* segmentStart = ptr;
|
||||
bool currentIsWord = false;
|
||||
bool haveCurrent = false;
|
||||
|
||||
while (true) {
|
||||
const unsigned char* cpStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
bool cpIsWord = isBionicWordCodepoint(cp);
|
||||
if (!haveCurrent) {
|
||||
currentIsWord = cpIsWord;
|
||||
haveCurrent = true;
|
||||
} else if (!utf8IsCombiningMark(cp) && cpIsWord != currentIsWord) {
|
||||
spans.push_back({static_cast<size_t>(segmentStart - base), static_cast<size_t>(cpStart - base), currentIsWord});
|
||||
segmentStart = cpStart;
|
||||
currentIsWord = cpIsWord;
|
||||
}
|
||||
}
|
||||
|
||||
if (haveCurrent) {
|
||||
spans.push_back({static_cast<size_t>(segmentStart - base), word.size(), currentIsWord});
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
|
||||
@@ -149,6 +215,9 @@ void ParsedText::layoutAndExtractLines(
|
||||
|
||||
// Apply fixed transforms before any per-line layout work.
|
||||
applyParagraphIndent();
|
||||
if (bionicReadingEnabled) {
|
||||
applyBionicReadingTransform();
|
||||
}
|
||||
|
||||
// Ensure SD card font glyph metrics are loaded before measuring word widths.
|
||||
// For flash-based fonts isSdCardFont() returns false and this block is skipped
|
||||
@@ -506,6 +575,85 @@ void ParsedText::applyParagraphIndent() {
|
||||
}
|
||||
}
|
||||
|
||||
void ParsedText::applyBionicReadingTransform() {
|
||||
if (words.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<std::string> transformedWords;
|
||||
std::vector<EpdFontFamily::Style> transformedStyles;
|
||||
std::vector<bool> transformedContinues;
|
||||
transformedWords.reserve(words.size() * 2);
|
||||
transformedStyles.reserve(wordStyles.size() * 2);
|
||||
transformedContinues.reserve(wordContinues.size() * 2);
|
||||
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
std::string source = std::move(words[i]);
|
||||
const auto originalStyle = wordStyles[i];
|
||||
const bool originalAttachToPrevious = wordContinues[i];
|
||||
const char* raw = source.c_str();
|
||||
|
||||
const auto spans = tokenizeBionicWord(source);
|
||||
if (spans.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool attachToPrevious = originalAttachToPrevious;
|
||||
for (size_t spanIndex = 0; spanIndex < spans.size(); ++spanIndex) {
|
||||
const TokenSpan span = spans[spanIndex];
|
||||
const size_t spanLength = span.end - span.start;
|
||||
std::string token;
|
||||
if (spans.size() == 1 && spanIndex == 0) {
|
||||
token = std::move(source);
|
||||
} else {
|
||||
token.assign(raw + span.start, spanLength);
|
||||
}
|
||||
|
||||
if (span.isWord) {
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(token.c_str());
|
||||
int codepointCount = 0;
|
||||
while (utf8NextCodepoint(&ptr)) {
|
||||
codepointCount++;
|
||||
}
|
||||
|
||||
if (codepointCount >= kBionicReadingMinCodepoints) {
|
||||
const int boldPrefixCount = computeBionicBoldPrefixCount(codepointCount);
|
||||
ptr = reinterpret_cast<const unsigned char*>(token.c_str());
|
||||
const unsigned char* prefixEnd = ptr;
|
||||
for (int j = 0; j < boldPrefixCount && *prefixEnd; ++j) {
|
||||
utf8NextCodepoint(&prefixEnd);
|
||||
}
|
||||
const size_t prefixByteCount =
|
||||
static_cast<size_t>(prefixEnd - reinterpret_cast<const unsigned char*>(token.c_str()));
|
||||
if (prefixByteCount < token.size()) {
|
||||
std::string suffix(reinterpret_cast<const char*>(prefixEnd), token.size() - prefixByteCount);
|
||||
token.resize(prefixByteCount);
|
||||
const auto boldStyle = static_cast<EpdFontFamily::Style>(originalStyle | EpdFontFamily::BOLD);
|
||||
transformedWords.push_back(std::move(token));
|
||||
transformedStyles.push_back(boldStyle);
|
||||
transformedContinues.push_back(attachToPrevious);
|
||||
|
||||
transformedWords.push_back(std::move(suffix));
|
||||
transformedStyles.push_back(originalStyle);
|
||||
transformedContinues.push_back(true);
|
||||
attachToPrevious = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformedWords.push_back(std::move(token));
|
||||
transformedStyles.push_back(originalStyle);
|
||||
transformedContinues.push_back(attachToPrevious);
|
||||
attachToPrevious = true;
|
||||
}
|
||||
}
|
||||
|
||||
words = std::move(transformedWords);
|
||||
wordStyles = std::move(transformedStyles);
|
||||
wordContinues = std::move(transformedContinues);
|
||||
}
|
||||
|
||||
// Builds break indices while opportunistically splitting the word that would overflow the current line.
|
||||
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||
const int pageWidth, std::vector<uint16_t>& wordWidths,
|
||||
|
||||
@@ -26,8 +26,10 @@ class ParsedText {
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
bool hyphenationEnabled;
|
||||
bool bionicReadingEnabled;
|
||||
|
||||
void applyParagraphIndent();
|
||||
void applyBionicReadingTransform();
|
||||
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,
|
||||
@@ -60,8 +62,11 @@ class ParsedText {
|
||||
|
||||
public:
|
||||
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
|
||||
const BlockStyle& blockStyle = BlockStyle())
|
||||
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
||||
const BlockStyle& blockStyle = BlockStyle(), const bool bionicReadingEnabled = false)
|
||||
: blockStyle(blockStyle),
|
||||
extraParagraphSpacing(extraParagraphSpacing),
|
||||
hyphenationEnabled(hyphenationEnabled),
|
||||
bionicReadingEnabled(bionicReadingEnabled) {}
|
||||
~ParsedText() = default;
|
||||
|
||||
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 23;
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 24;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
|
||||
sizeof(int) + // fontId
|
||||
sizeof(float) + // lineCompression
|
||||
@@ -23,6 +23,7 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
|
||||
sizeof(uint16_t) + // pageCount (stored as 16-bit in header)
|
||||
sizeof(bool) + // hyphenationEnabled
|
||||
sizeof(bool) + // embeddedStyle
|
||||
sizeof(bool) + // bionicReadingEnabled
|
||||
sizeof(uint8_t) + // imageRendering
|
||||
sizeof(uint32_t) + // page LUT offset
|
||||
sizeof(uint32_t) + // anchor map offset
|
||||
@@ -55,7 +56,8 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const bool embeddedStyle, const uint8_t imageRendering) {
|
||||
const bool embeddedStyle, const bool bionicReadingEnabled,
|
||||
const uint8_t imageRendering) {
|
||||
if (!file) {
|
||||
LOG_DBG("SCT", "File not open for writing header");
|
||||
return;
|
||||
@@ -63,8 +65,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t),
|
||||
sizeof(embeddedStyle) + sizeof(bionicReadingEnabled) + sizeof(imageRendering) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
@@ -75,6 +77,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
serialization::writePod(file, viewportHeight);
|
||||
serialization::writePod(file, hyphenationEnabled);
|
||||
serialization::writePod(file, embeddedStyle);
|
||||
serialization::writePod(file, bionicReadingEnabled);
|
||||
serialization::writePod(file, imageRendering);
|
||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
|
||||
@@ -85,7 +88,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering) {
|
||||
const bool bionicReadingEnabled, const uint8_t imageRendering) {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
@@ -107,6 +110,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
uint8_t fileParagraphAlignment;
|
||||
bool fileHyphenationEnabled;
|
||||
bool fileEmbeddedStyle;
|
||||
bool fileBionicReadingEnabled;
|
||||
uint8_t fileImageRendering;
|
||||
serialization::readPod(file, fileFontId);
|
||||
serialization::readPod(file, fileLineCompression);
|
||||
@@ -116,13 +120,14 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
serialization::readPod(file, fileViewportHeight);
|
||||
serialization::readPod(file, fileHyphenationEnabled);
|
||||
serialization::readPod(file, fileEmbeddedStyle);
|
||||
serialization::readPod(file, fileBionicReadingEnabled);
|
||||
serialization::readPod(file, fileImageRendering);
|
||||
|
||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
||||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
|
||||
imageRendering != fileImageRendering) {
|
||||
bionicReadingEnabled != fileBionicReadingEnabled || imageRendering != fileImageRendering) {
|
||||
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||
clearCache(); // closes file before removal
|
||||
return false;
|
||||
@@ -188,7 +193,8 @@ bool Section::clearCache() {
|
||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering, const std::function<void(int)>& progressFn) {
|
||||
const bool bionicReadingEnabled, const uint8_t imageRendering,
|
||||
const std::function<void(int)>& progressFn) {
|
||||
const uint32_t phaseTotalStart = millis();
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
|
||||
@@ -210,7 +216,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
return false;
|
||||
}
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering);
|
||||
std::vector<uint32_t> lut = {};
|
||||
|
||||
// Derive the content base directory and image cache path prefix for the parser
|
||||
@@ -243,7 +249,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
epub, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
|
||||
hyphenationEnabled,
|
||||
hyphenationEnabled, bionicReadingEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), progressFn, cssParser);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
|
||||
@@ -20,7 +20,7 @@ class Section {
|
||||
|
||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
||||
bool embeddedStyle, uint8_t imageRendering);
|
||||
bool embeddedStyle, bool bionicReadingEnabled, uint8_t imageRendering);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
struct TocBoundary {
|
||||
@@ -50,11 +50,12 @@ class Section {
|
||||
~Section() = default;
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering);
|
||||
bool bionicReadingEnabled, uint8_t imageRendering);
|
||||
bool clearCache();
|
||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, const std::function<void(int)>& progressFn = nullptr);
|
||||
bool bionicReadingEnabled, uint8_t imageRendering,
|
||||
const std::function<void(int)>& progressFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
|
||||
// Given a page in this section, return the TOC index for that page.
|
||||
|
||||
@@ -255,7 +255,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
|
||||
pendingAnchorId.clear();
|
||||
}
|
||||
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
||||
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle, bionicReadingEnabled));
|
||||
wordsExtractedInBlock = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ class ChapterHtmlSlimParser final : public Print {
|
||||
int currentFootnoteLinkTextLen = 0;
|
||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||
int wordsExtractedInBlock = 0;
|
||||
bool bionicReadingEnabled = false;
|
||||
|
||||
// Per-chapter caches: resolveStyle and parseInlineStyle are called for every HTML element;
|
||||
// caching by (tag|classAttr) and styleAttr avoids repeated string operations and hash lookups.
|
||||
@@ -149,16 +150,14 @@ class ChapterHtmlSlimParser final : public Print {
|
||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||
|
||||
public:
|
||||
explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, GfxRenderer& renderer, const int fontId,
|
||||
const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||
const bool embeddedStyle, const std::string& contentBase,
|
||||
const std::string& imageBasePath, const uint8_t imageRendering = 0,
|
||||
std::vector<std::string> tocAnchors = {},
|
||||
const std::function<void(int)>& progressFn = nullptr,
|
||||
const CssParser* cssParser = nullptr)
|
||||
explicit ChapterHtmlSlimParser(
|
||||
std::shared_ptr<Epub> epub, GfxRenderer& renderer, const int fontId, const float lineCompression,
|
||||
const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool bionicReadingEnabled,
|
||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const bool embeddedStyle,
|
||||
const std::string& contentBase, const std::string& imageBasePath, const uint8_t imageRendering = 0,
|
||||
std::vector<std::string> tocAnchors = {}, const std::function<void(int)>& progressFn = nullptr,
|
||||
const CssParser* cssParser = nullptr)
|
||||
|
||||
: epub(epub),
|
||||
renderer(renderer),
|
||||
@@ -169,6 +168,7 @@ class ChapterHtmlSlimParser final : public Print {
|
||||
viewportWidth(viewportWidth),
|
||||
viewportHeight(viewportHeight),
|
||||
hyphenationEnabled(hyphenationEnabled),
|
||||
bionicReadingEnabled(bionicReadingEnabled),
|
||||
completePageFn(completePageFn),
|
||||
progressFn(progressFn),
|
||||
cssParser(cssParser),
|
||||
|
||||
@@ -598,3 +598,5 @@ STR_KB_HINT_SECONDARY_CHAR: "Hold SELECT for secondary char"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets"
|
||||
STR_BIONIC_READING: "Bionic Reading"
|
||||
STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading"
|
||||
Reference in New Issue
Block a user