diff --git a/docs/focus-reading.md b/docs/focus-reading.md
new file mode 100644
index 00000000..5063e4b4
--- /dev/null
+++ b/docs/focus-reading.md
@@ -0,0 +1,33 @@
+# Focus Reading
+
+Focus Reading is a reading aid that bolds the first portion of each word, guiding your eyes to natural fixation points and helping you read faster with less effort. Some readers — particularly those with ADHD — find it helps them stay engaged with the text and reduces mind-wandering. It is inspired by the Bionic Reading technique.
+
+
+
+*Left: Focus Reading off. Right: Focus Reading on. Both using Literata.*
+
+## Enabling Focus Reading
+
+1. Open **Settings > Reader**
+2. Toggle **Focus Reading** on
+
+Toggling the setting will trigger a re-index of your current book, the same as when changing font settings. Once indexing is complete, page turns proceed as normal. No changes are made to your EPUB files.
+
+## Examples
+
+
+
+*Focus Reading with Noto Serif font*
+
+
+
+*Focus Reading with Merriweather font*
+
+
+
+*Focus Reading with Atkinson Hyperlegible Next font*
+
+## Notes
+
+- Focus Reading only applies to regular body text. Already-bold text (headings, emphasis) is left unchanged.
+- The setting is per-device, not per-book — it applies to all books while enabled.
diff --git a/docs/images/focus-reading/focus-reading-atkinson.jpg b/docs/images/focus-reading/focus-reading-atkinson.jpg
new file mode 100644
index 00000000..8ec77b10
Binary files /dev/null and b/docs/images/focus-reading/focus-reading-atkinson.jpg differ
diff --git a/docs/images/focus-reading/focus-reading-merriweather.jpg b/docs/images/focus-reading/focus-reading-merriweather.jpg
new file mode 100644
index 00000000..59d82371
Binary files /dev/null and b/docs/images/focus-reading/focus-reading-merriweather.jpg differ
diff --git a/docs/images/focus-reading/focus-reading-notoserif.jpg b/docs/images/focus-reading/focus-reading-notoserif.jpg
new file mode 100644
index 00000000..8ffc869d
Binary files /dev/null and b/docs/images/focus-reading/focus-reading-notoserif.jpg differ
diff --git a/docs/images/focus-reading/focus-reading.jpg b/docs/images/focus-reading/focus-reading.jpg
new file mode 100644
index 00000000..97318e7b
Binary files /dev/null and b/docs/images/focus-reading/focus-reading.jpg differ
diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp
index 6f31f5f2..193b320e 100644
--- a/lib/Epub/Epub/ParsedText.cpp
+++ b/lib/Epub/Epub/ParsedText.cpp
@@ -74,21 +74,170 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
}
+// Checks if a UTF-8 codepoint should be counted as part of a word for Focus Reading
+bool isWordCharacter(uint32_t cp) {
+ // ASCII range (Catches 95%+ of characters immediately)
+ if (cp < 128) {
+ // Bitwise trick: (cp | 0x20) converts uppercase ASCII to lowercase.
+ // This checks for A-Z and a-z mathematically, avoiding memory lookups and
+ return ((cp | 0x20) >= 'a' && (cp | 0x20) <= 'z') || cp == '\'';
+ }
+
+ // General Punctuation Block, Currency, Math, Arrows, & Symbols (0x2000 - 0x2BFF)
+ if (cp >= 0x2000 && cp <= 0x2BFF) {
+ // Explicitly allow smart quotes, reject all other general punctuation (em-dashes, etc.)
+ return cp == 0x2018 || cp == 0x2019;
+ }
+
+ // Latin-1 Punctuation Block (0x00A1 - 0x00BF)
+ if (cp >= 0x00A1 && cp <= 0x00BF) {
+ // Allow ordinal indicators and micro sign, reject the rest (¡, ¿, «, », etc.)
+ return cp == 0x00AA || cp == 0x00B5 || cp == 0x00BA;
+ }
+
+ // Rejects Two-em dash, Three-em dash, Double oblique hyphen, etc.
+ if (cp >= 0x2E00 && cp <= 0x2E7F) return false;
+
+ // Rejects Modifier Minus (0x02D7), Small Hyphen (0xFE63), and Fullwidth Hyphen (0xFF0D)
+ if (cp == 0x02D7 || cp == 0xFE63 || cp == 0xFF0D) return false;
+ // Assume all other Unicode ranges (accented letters, Cyrillic, Greek, etc.) are valid
+
+ return true;
+}
+
} // namespace
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
const bool attachToPrevious) {
if (word.empty()) return;
- words.push_back(std::move(word));
- EpdFontFamily::Style combinedStyle = fontStyle;
+ EpdFontFamily::Style baseStyle = fontStyle;
if (underline) {
- combinedStyle = static_cast(combinedStyle | EpdFontFamily::UNDERLINE);
+ baseStyle = static_cast(baseStyle | EpdFontFamily::UNDERLINE);
}
- wordStyles.push_back(combinedStyle);
- wordContinues.push_back(attachToPrevious);
-}
+ // Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
+ if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
+ words.push_back(std::move(word));
+ wordStyles.push_back(baseStyle);
+ wordContinues.push_back(attachToPrevious);
+ wordIsFocusSuffix.push_back(false);
+ return;
+ }
+
+ // --- FOCUS READING LOGIC BELOW ---
+
+ // Pre-reserve capacity to prevent mid-word heap reallocations.
+ size_t maxPossibleNewTokens = word.length();
+ size_t requiredSize = words.size() + maxPossibleNewTokens;
+
+ if (words.capacity() < requiredSize) {
+ // Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
+ size_t newCapacity = words.capacity() * 2;
+
+ // Ensure the doubled capacity is actually enough for this specific word
+ if (newCapacity < requiredSize) {
+ newCapacity = requiredSize;
+ }
+ // Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
+ if (newCapacity < 16) {
+ newCapacity = 16;
+ }
+
+ words.reserve(newCapacity);
+ wordStyles.reserve(newCapacity);
+ wordContinues.reserve(newCapacity);
+ wordIsFocusSuffix.reserve(newCapacity);
+ }
+
+ // Lambda helper to process and push individual sub-segments of the string
+ // Use std::string_view to avoid heap allocations when slicing
+ auto processSegment = [&](std::string_view segment, bool isWord, bool attach) {
+ if (!isWord) {
+ // Punctuation and Numbers stay regular
+ words.emplace_back(segment);
+ wordStyles.push_back(baseStyle);
+ wordContinues.push_back(attach);
+ wordIsFocusSuffix.push_back(false);
+ } else {
+ size_t charCount = 0;
+ const unsigned char* countPtr = reinterpret_cast(segment.data());
+ const unsigned char* countEnd = countPtr + segment.length();
+
+ while (countPtr < countEnd) {
+ utf8NextCodepoint(&countPtr);
+ charCount++;
+ }
+
+ // Target 45% for 1-bold at 4 chars and 3-bold at 7 chars with floor truncation
+ constexpr size_t FOCUS_READING_PERCENT = 45;
+ size_t targetBoldChars = (charCount * FOCUS_READING_PERCENT) / 100;
+ targetBoldChars = std::clamp(targetBoldChars, 1, 9);
+
+ if (targetBoldChars >= charCount) {
+ // Whole segment is bold - no suffix split needed
+ words.emplace_back(segment);
+ wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD));
+ wordContinues.push_back(attach);
+ wordIsFocusSuffix.push_back(false);
+ } else {
+ countPtr = reinterpret_cast(segment.data());
+ for (size_t i = 0; i < targetBoldChars; ++i) {
+ utf8NextCodepoint(&countPtr);
+ }
+ size_t splitByteOffset = countPtr - reinterpret_cast(segment.data());
+
+ // Bold prefix
+ words.emplace_back(segment.substr(0, splitByteOffset));
+ wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD));
+ wordContinues.push_back(attach);
+ wordIsFocusSuffix.push_back(false);
+
+ // Regular suffix - marked so extractLine can merge it back into single TextBlock entry
+ words.emplace_back(segment.substr(splitByteOffset));
+ wordStyles.push_back(baseStyle);
+ wordContinues.push_back(true);
+ wordIsFocusSuffix.push_back(true);
+ }
+ }
+ };
+
+ // Tokenize the string by alternating states (Word vs. Non-Word)
+ const unsigned char* ptr = reinterpret_cast(word.c_str());
+ const unsigned char* end = ptr + word.length();
+
+ const unsigned char* segmentStart = ptr;
+ uint32_t firstCp = utf8NextCodepoint(&ptr); // Consume the first char to determine initial state
+ bool inWordSegment = isWordCharacter(firstCp);
+
+ bool isFirstSegment = true;
+
+ while (ptr < end) {
+ const unsigned char* currentCpStart = ptr;
+ uint32_t cp = utf8NextCodepoint(&ptr);
+ bool isWordChar = isWordCharacter(cp);
+
+ // Whenever the character type flips, slice off the segment we just completed and process it
+ if (isWordChar != inWordSegment) {
+ size_t segmentLen = currentCpStart - segmentStart;
+ std::string_view segment(reinterpret_cast(segmentStart), segmentLen);
+
+ // Only the very first segment inherits the original attachToPrevious flag.
+ // Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
+ processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
+
+ // Setup for the next segment
+ segmentStart = currentCpStart;
+ inWordSegment = isWordChar;
+ isFirstSegment = false;
+ }
+ }
+
+ // Process the final remaining segment
+ size_t segmentLen = end - segmentStart;
+ std::string_view segment(reinterpret_cast(segmentStart), segmentLen);
+ processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
+}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function)>& processLine,
@@ -153,6 +302,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
words.erase(words.begin(), words.begin() + consumed);
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
+ wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
}
}
@@ -436,6 +586,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
+ // The hyphen remainder is not a focus suffix - it starts fresh on the next line.
+ wordIsFocusSuffix.insert(wordIsFocusSuffix.begin() + wordIndex + 1, false);
// Continuation flag handling after splitting a word into prefix + remainder.
//
@@ -567,6 +719,63 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
}
- processLine(
- std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
+ // 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]) {
+ lineHasFocusSplit = true;
+ break;
+ }
+ }
+
+ if (!lineHasFocusSplit) {
+ processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
+ std::vector{}, std::vector{}, blockStyle));
+ return;
+ }
+
+ // Slow path: merge focus suffix tokens back into their preceding word entry so each
+ // original word occupies one TextBlock slot. Splits are recorded as per-word annotations
+ // applied at render time, cutting the token count significantly when the feature is active.
+ std::vector outWords;
+ std::vector outXPos;
+ std::vector outStyles;
+ std::vector outBoundaries;
+ std::vector outSuffixX;
+ outWords.reserve(lineWordCount);
+ outXPos.reserve(lineWordCount);
+ outStyles.reserve(lineWordCount);
+ outBoundaries.reserve(lineWordCount);
+ outSuffixX.reserve(lineWordCount);
+
+ for (size_t i = 0; i < lineWordCount; i++) {
+ if (wordIsFocusSuffix[lastBreakAt + 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]) {
+ boundary = static_cast(std::min(lineWords[i].size(), size_t{255}));
+ // Suffix x offset = layout-time advance of the bold prefix, already known from xpos table.
+ suffixX = static_cast(lineXPos[i + 1] - lineXPos[i]);
+ }
+ outWords.push_back(std::move(lineWords[i]));
+ outXPos.push_back(lineXPos[i]);
+ // For focus entries with a suffix, strip BOLD from the stored style.
+ // Render re-applies it to the prefix portion only, via the boundary field.
+ const EpdFontFamily::Style storedStyle =
+ boundary > 0 ? static_cast(lineWordStyles[i] & ~EpdFontFamily::BOLD)
+ : lineWordStyles[i];
+ outStyles.push_back(storedStyle);
+ outBoundaries.push_back(boundary);
+ outSuffixX.push_back(suffixX);
+ }
+ }
+
+ processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles),
+ std::move(outBoundaries), std::move(outSuffixX), blockStyle));
}
diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h
index 9d43400e..167d3d26 100644
--- a/lib/Epub/Epub/ParsedText.h
+++ b/lib/Epub/Epub/ParsedText.h
@@ -15,10 +15,12 @@ class GfxRenderer;
class ParsedText {
std::vector words;
std::vector wordStyles;
- std::vector wordContinues; // true = word attaches to previous (no space before it)
+ std::vector wordContinues; // true = word attaches to previous (no space before it)
+ std::vector wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
BlockStyle blockStyle;
bool extraParagraphSpacing;
bool hyphenationEnabled;
+ bool focusReadingEnabled;
void applyParagraphIndent();
std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
@@ -35,8 +37,11 @@ class ParsedText {
public:
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
- const BlockStyle& blockStyle = BlockStyle())
- : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
+ const bool focusReadingEnabled = false, const BlockStyle& blockStyle = BlockStyle())
+ : blockStyle(blockStyle),
+ extraParagraphSpacing(extraParagraphSpacing),
+ hyphenationEnabled(hyphenationEnabled),
+ focusReadingEnabled(focusReadingEnabled) {}
~ParsedText() = default;
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp
index 57a92cd8..7cbd37e1 100644
--- a/lib/Epub/Epub/Section.cpp
+++ b/lib/Epub/Epub/Section.cpp
@@ -13,8 +13,8 @@ namespace {
constexpr uint8_t SECTION_FILE_VERSION = 23;
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(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) +
- sizeof(uint32_t);
+ sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
+ sizeof(uint32_t) + sizeof(uint32_t);
struct PageLutEntry {
uint32_t fileOffset;
@@ -43,7 +43,8 @@ uint32_t Section::onPageComplete(std::unique_ptr 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 uint8_t imageRendering,
+ const bool focusReadingEnabled) {
if (!file) {
LOG_DBG("SCT", "File not open for writing header");
return;
@@ -51,8 +52,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(uint32_t),
+ sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
+ sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -64,6 +65,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, hyphenationEnabled);
serialization::writePod(file, embeddedStyle);
serialization::writePod(file, imageRendering);
+ serialization::writePod(file, focusReadingEnabled);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast(0)); // Placeholder for anchor map offset (patched later)
@@ -74,7 +76,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 uint8_t imageRendering, const bool focusReadingEnabled) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -99,6 +101,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
bool fileHyphenationEnabled;
bool fileEmbeddedStyle;
uint8_t fileImageRendering;
+ bool fileFocusReadingEnabled;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
@@ -108,13 +111,13 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, fileHyphenationEnabled);
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileImageRendering);
+ serialization::readPod(file, fileFocusReadingEnabled);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
- imageRendering != fileImageRendering) {
- // Explicit close() required: member variable persists beyond function scope
+ imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) {
file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache();
@@ -148,7 +151,8 @@ bool Section::clearCache() const {
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& popupFn) {
+ const uint8_t imageRendering, const bool focusReadingEnabled,
+ const std::function& popupFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@@ -199,7 +203,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, imageRendering, focusReadingEnabled);
std::vector lut = {};
// Derive the content base directory and image cache path prefix for the parser
@@ -219,7 +223,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
ChapterHtmlSlimParser visitor(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
- viewportHeight, hyphenationEnabled,
+ viewportHeight, hyphenationEnabled, focusReadingEnabled,
[this, &lut](std::unique_ptr page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
},
diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h
index 9688e276..aea02d34 100644
--- a/lib/Epub/Epub/Section.h
+++ b/lib/Epub/Epub/Section.h
@@ -18,7 +18,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, uint8_t imageRendering, bool focusReadingEnabled);
uint32_t onPageComplete(std::unique_ptr page);
public:
@@ -33,11 +33,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);
+ uint8_t imageRendering, bool focusReadingEnabled);
bool clearCache() const;
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& popupFn = nullptr);
+ uint8_t imageRendering, bool focusReadingEnabled,
+ const std::function& popupFn = nullptr);
std::unique_ptr loadPageFromSectionFile();
// Look up the page number for an anchor id from the section cache file.
diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp
index e3e35d42..9acd8b03 100644
--- a/lib/Epub/Epub/blocks/TextBlock.cpp
+++ b/lib/Epub/Epub/blocks/TextBlock.cpp
@@ -4,18 +4,44 @@
#include
#include
+#include
+
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
- // Validate iterator bounds before rendering
- if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
- LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
- (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
+ // Focus annotations are optional: empty vectors mean no word in this block has a split.
+ // When present, they must be sized in lockstep with words[].
+ const bool hasFocus = !wordFocusBoundary.empty();
+ if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
+ (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
+ LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
+ (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(),
+ (uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size());
return;
}
for (size_t i = 0; i < words.size(); i++) {
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
- renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
+ const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
+
+ if (boundary > 0) {
+ // Focus split: draw bold prefix, then the regular suffix at a pre-computed x offset.
+ // The bold prefix is bounded to 9 codepoints by the clamp on targetBoldChars in
+ // ParsedText::addWord; 9 UTF-8 codepoints occupy at most 9 * 4 = 36 bytes, +1 for null = 37.
+ // suffixX is computed at cache-creation time to avoid font metric lookups at render time.
+ static constexpr size_t MAX_FOCUS_PREFIX_BYTES = 9 * 4 + 1;
+ char boldBuf[40];
+ static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
+ "boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
+ const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD);
+ const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1});
+ memcpy(boldBuf, words[i].c_str(), boldLen);
+ boldBuf[boldLen] = '\0';
+ renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle);
+ const int suffixX = wordX + wordFocusSuffixX[i];
+ renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle);
+ } else {
+ renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
+ }
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = words[i];
@@ -42,9 +68,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
}
bool TextBlock::serialize(FsFile& file) const {
- if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
- LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
- wordXpos.size(), wordStyles.size());
+ // Focus annotations are optional; vectors are either empty (no splits in this block)
+ // or sized in lockstep with words[].
+ const bool hasFocus = !wordFocusBoundary.empty();
+ if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
+ (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
+ LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
+ static_cast(words.size()), static_cast(wordXpos.size()),
+ static_cast(wordStyles.size()), static_cast(wordFocusBoundary.size()),
+ static_cast(wordFocusSuffixX.size()));
return false;
}
@@ -53,6 +85,13 @@ bool TextBlock::serialize(FsFile& file) const {
for (const auto& w : words) serialization::writeString(file, w);
for (auto x : wordXpos) serialization::writePod(file, x);
for (auto s : wordStyles) serialization::writePod(file, s);
+ // Focus block: 1-byte presence flag, followed by per-word vectors only when present.
+ // Saves 3 bytes/word when focus reading is disabled or no word on this line was split.
+ serialization::writePod(file, static_cast(hasFocus ? 1 : 0));
+ if (hasFocus) {
+ for (auto b : wordFocusBoundary) serialization::writePod(file, b);
+ for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx);
+ }
// Style (alignment + margins/padding/indent)
serialization::writePod(file, blockStyle.alignment);
@@ -76,6 +115,8 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) {
std::vector words;
std::vector wordXpos;
std::vector wordStyles;
+ std::vector wordFocusBoundary;
+ std::vector wordFocusSuffixX;
BlockStyle blockStyle;
// Word count
@@ -94,6 +135,16 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) {
for (auto& w : words) serialization::readString(file, w);
for (auto& x : wordXpos) serialization::readPod(file, x);
for (auto& s : wordStyles) serialization::readPod(file, s);
+ // Focus block: presence flag, then vectors only if present. Empty vectors when absent
+ // signal "no splits in this block" to render() (zero per-word RAM cost).
+ uint8_t hasFocus;
+ serialization::readPod(file, hasFocus);
+ if (hasFocus) {
+ wordFocusBoundary.resize(wc);
+ wordFocusSuffixX.resize(wc);
+ for (auto& b : wordFocusBoundary) serialization::readPod(file, b);
+ for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx);
+ }
// Style (alignment + margins/padding/indent)
serialization::readPod(file, blockStyle.alignment);
@@ -109,6 +160,7 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.textIndentDefined);
- return std::unique_ptr(
- new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle));
+ return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
+ std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
+ blockStyle));
}
diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h
index 85fdd55a..e60b283c 100644
--- a/lib/Epub/Epub/blocks/TextBlock.h
+++ b/lib/Epub/Epub/blocks/TextBlock.h
@@ -15,14 +15,28 @@ class TextBlock final : public Block {
std::vector words;
std::vector wordXpos;
std::vector wordStyles;
+ // Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold,
+ // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]).
+ // N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by
+ // FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t.
+ // Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost
+ // when focus reading is disabled, or on lines that happen to contain no splittable words).
+ std::vector wordFocusBoundary;
+ // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0.
+ // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0.
+ // Empty in lockstep with wordFocusBoundary.
+ std::vector wordFocusSuffixX;
BlockStyle blockStyle;
public:
explicit TextBlock(std::vector words, std::vector word_xpos,
- std::vector word_styles, const BlockStyle& blockStyle = BlockStyle())
+ std::vector word_styles, std::vector focus_boundary,
+ std::vector focus_suffix_x, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
+ wordFocusBoundary(std::move(focus_boundary)),
+ wordFocusSuffixX(std::move(focus_suffix_x)),
blockStyle(blockStyle) {}
~TextBlock() override = default;
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
index c9f89e2e..a25a1dbd 100644
--- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
+++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
@@ -141,7 +141,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)});
pendingAnchorId.clear();
}
- currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
+ currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
index 5802bb94..aeaea7f5 100644
--- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
+++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
@@ -47,6 +47,7 @@ class ChapterHtmlSlimParser {
uint16_t viewportWidth;
uint16_t viewportHeight;
bool hyphenationEnabled;
+ bool focusReadingEnabled;
const CssParser* cssParser;
bool embeddedStyle;
uint8_t imageRendering;
@@ -101,6 +102,7 @@ class ChapterHtmlSlimParser {
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 focusReadingEnabled,
const std::function, uint16_t, uint16_t)>& completePageFn,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const uint8_t imageRendering = 0,
@@ -116,6 +118,7 @@ class ChapterHtmlSlimParser {
viewportWidth(viewportWidth),
viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled),
+ focusReadingEnabled(focusReadingEnabled),
completePageFn(completePageFn),
popupFn(popupFn),
cssParser(cssParser),
diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml
index 245ecc10..f854cae8 100644
--- a/lib/I18n/translations/belarusian.yaml
+++ b/lib/I18n/translations/belarusian.yaml
@@ -259,6 +259,7 @@ STR_SECTION_PREFIX: "Раздзел"
STR_UPLOAD: "Адправіць"
STR_BOOK_S_STYLE: "Стыль кнігі"
STR_EMBEDDED_STYLE: "Убудаваны стыль"
+STR_FOCUS_READING: "Фокуснае чытанне"
STR_OPDS_SERVER_URL: "URL OPDS сервера"
STR_SCREENSHOT_BUTTON: "Зрабіць здымак экрана"
STR_IMAGES: "Выявы"
diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml
index 377ab05e..40b02ea0 100644
--- a/lib/I18n/translations/catalan.yaml
+++ b/lib/I18n/translations/catalan.yaml
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secció "
STR_UPLOAD: "Puja"
STR_BOOK_S_STYLE: "Estil del llibre"
STR_EMBEDDED_STYLE: "Estil incrustat"
+STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_FOOTNOTES: "Notes al peu"
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml
index a769aa07..379bd690 100644
--- a/lib/I18n/translations/czech.yaml
+++ b/lib/I18n/translations/czech.yaml
@@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Sekce"
STR_UPLOAD: "Nahrát"
STR_BOOK_S_STYLE: "Styl knihy"
STR_EMBEDDED_STYLE: "Vložený styl"
+STR_FOCUS_READING: "Soustředěné čtení"
STR_OPDS_SERVER_URL: "URL serveru OPDS"
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml
index 91b13025..9a1b979d 100644
--- a/lib/I18n/translations/danish.yaml
+++ b/lib/I18n/translations/danish.yaml
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Afsnit "
STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Bogens stil"
STR_EMBEDDED_STYLE: "Indlejret stil"
+STR_FOCUS_READING: "Fokuslæsning"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_FOOTNOTES: "Fodnoter"
STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml
index 47041116..12593f0a 100644
--- a/lib/I18n/translations/dutch.yaml
+++ b/lib/I18n/translations/dutch.yaml
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Sectie "
STR_UPLOAD: "Uploaden"
STR_BOOK_S_STYLE: "Stijl van boek"
STR_EMBEDDED_STYLE: "Ingebedde stijl"
+STR_FOCUS_READING: "Gefocust lezen"
STR_OPDS_SERVER_URL: "OPDS-server URL"
STR_FOOTNOTES: "Voetnoten"
STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml
index 969af61c..e9c5a192 100644
--- a/lib/I18n/translations/english.yaml
+++ b/lib/I18n/translations/english.yaml
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Book's Style"
STR_EMBEDDED_STYLE: "Embedded Style"
+STR_FOCUS_READING: "Focus Reading"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_SET_SLEEP_COVER: "Set Cover"
STR_FOOTNOTES: "Footnotes"
diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml
index 99a4d974..93672291 100644
--- a/lib/I18n/translations/finnish.yaml
+++ b/lib/I18n/translations/finnish.yaml
@@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Osio "
STR_UPLOAD: "Lähetä"
STR_BOOK_S_STYLE: "Kirjan tyyli"
STR_EMBEDDED_STYLE: "Upotettu tyyli"
+STR_FOCUS_READING: "Keskittynyt lukeminen"
STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml
index fb299ab7..0f43e82b 100644
--- a/lib/I18n/translations/french.yaml
+++ b/lib/I18n/translations/french.yaml
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Envoyer"
STR_BOOK_S_STYLE: "Style du livre"
STR_EMBEDDED_STYLE: "Style intégré"
+STR_FOCUS_READING: "Lecture focalisée"
STR_OPDS_SERVER_URL: "URL serveur OPDS"
STR_FOOTNOTES: "Notes de bas de page"
STR_NO_FOOTNOTES: "Aucune note sur cette page"
diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml
index 28312b81..d2f8d0e6 100644
--- a/lib/I18n/translations/german.yaml
+++ b/lib/I18n/translations/german.yaml
@@ -289,6 +289,7 @@ STR_SECTION_PREFIX: "Abschnitt"
STR_UPLOAD: "Hochladen"
STR_BOOK_S_STYLE: "Buch-Stil"
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
+STR_FOCUS_READING: "Fokus-Lesen"
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
STR_FOOTNOTES: "Fußnoten"
diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml
index 759768aa..070f2a28 100644
--- a/lib/I18n/translations/hungarian.yaml
+++ b/lib/I18n/translations/hungarian.yaml
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Szakasz "
STR_UPLOAD: "Feltöltés"
STR_BOOK_S_STYLE: "Könyv stílusa"
STR_EMBEDDED_STYLE: "Beágyazott stílus"
+STR_FOCUS_READING: "Fókuszált olvasás"
STR_OPDS_SERVER_URL: "OPDS szerver URL"
STR_FOOTNOTES: "Lábjegyzetek"
STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml
index 3c3bb17b..4a180b4f 100644
--- a/lib/I18n/translations/italian.yaml
+++ b/lib/I18n/translations/italian.yaml
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Sezione "
STR_UPLOAD: "Carica"
STR_BOOK_S_STYLE: "Stile libro"
STR_EMBEDDED_STYLE: "Stile integrato dell'epub"
+STR_FOCUS_READING: "Lettura focalizzata"
STR_OPDS_SERVER_URL: "Server OPDS"
STR_FOOTNOTES: "Note a piè pagina"
STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml
index fbee8021..5d9c03e9 100644
--- a/lib/I18n/translations/kazakh.yaml
+++ b/lib/I18n/translations/kazakh.yaml
@@ -258,6 +258,7 @@ STR_SECTION_PREFIX: "Бөлім "
STR_UPLOAD: "Жүктеп салу"
STR_BOOK_S_STYLE: "Кітап стилі"
STR_EMBEDDED_STYLE: "Кірістірілген стиль"
+STR_FOCUS_READING: "Зейінді оқу"
STR_OPDS_SERVER_URL: "OPDS сервері URL"
STR_NO_FILES_FOUND: "Файлдар табылмады"
STR_IMAGES: "Суреттер"
diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml
index bae0764a..d7619a1f 100644
--- a/lib/I18n/translations/lithuanian.yaml
+++ b/lib/I18n/translations/lithuanian.yaml
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Dalis "
STR_UPLOAD: "Įkelti"
STR_BOOK_S_STYLE: "Knygos stilius"
STR_EMBEDDED_STYLE: "Integruotas stilius"
+STR_FOCUS_READING: "Sufokusuotas skaitymas"
STR_OPDS_SERVER_URL: "OPDS URL"
STR_FOOTNOTES: "Išnašos"
STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml
index b85a8719..721dddba 100644
--- a/lib/I18n/translations/polish.yaml
+++ b/lib/I18n/translations/polish.yaml
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sekcja "
STR_UPLOAD: "Wyślij"
STR_BOOK_S_STYLE: "Styl książki"
STR_EMBEDDED_STYLE: "Style wbudowane w EPUB"
+STR_FOCUS_READING: "Czytanie skupione"
STR_OPDS_SERVER_URL: "URL serwera OPDS"
STR_SET_SLEEP_COVER: "Ustaw okładkę"
STR_FOOTNOTES: "Przypisy"
diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml
index e6249adb..97b447b9 100644
--- a/lib/I18n/translations/portuguese.yaml
+++ b/lib/I18n/translations/portuguese.yaml
@@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Seção"
STR_UPLOAD: "Enviar"
STR_BOOK_S_STYLE: "Estilo do livro"
STR_EMBEDDED_STYLE: "Estilo embutido"
+STR_FOCUS_READING: "Leitura focada"
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
STR_SCREENSHOT_BUTTON: "Capturar tela"
STR_TILT_PAGE_TURN: "Virar página por inclinação"
diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml
index d4e004db..84d7cac9 100644
--- a/lib/I18n/translations/romanian.yaml
+++ b/lib/I18n/translations/romanian.yaml
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secţiune "
STR_UPLOAD: "Încărcare"
STR_BOOK_S_STYLE: "Stilul cărţii"
STR_EMBEDDED_STYLE: "Stil încorporat"
+STR_FOCUS_READING: "Lectură concentrată"
STR_OPDS_SERVER_URL: "URL server OPDS"
STR_FOOTNOTES: "Note de subsol"
STR_NO_FOOTNOTES: "Nicio notă de subsol"
diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml
index 6f00d6c3..005a5eea 100644
--- a/lib/I18n/translations/russian.yaml
+++ b/lib/I18n/translations/russian.yaml
@@ -291,6 +291,7 @@ STR_SECTION_PREFIX: "Раздел "
STR_UPLOAD: "Отправить"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Встроенный стиль"
+STR_FOCUS_READING: "Фокусное чтение"
STR_OPDS_SERVER_URL: "URL OPDS сервера"
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
STR_AUTO_TURN_ENABLED: "Автоперелистывание: "
diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml
index 20103772..7e506661 100644
--- a/lib/I18n/translations/slovenian.yaml
+++ b/lib/I18n/translations/slovenian.yaml
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Razdelek "
STR_UPLOAD: "Naloži"
STR_BOOK_S_STYLE: "Slog knjige"
STR_EMBEDDED_STYLE: "Vgrajen slog"
+STR_FOCUS_READING: "Fokusirano branje"
STR_OPDS_SERVER_URL: "URL OPDS strežnika"
STR_FOOTNOTES: "Opombe"
STR_NO_FOOTNOTES: "Na tej strani ni opomb"
diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml
index ea2e56dc..9b4af843 100644
--- a/lib/I18n/translations/spanish.yaml
+++ b/lib/I18n/translations/spanish.yaml
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Secc.:"
STR_UPLOAD: "Subir"
STR_BOOK_S_STYLE: "Estilo del libro"
STR_EMBEDDED_STYLE: "Estilo integrado"
+STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_FOOTNOTES: "Pie de página"
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml
index 84ac108e..3ab175c5 100644
--- a/lib/I18n/translations/swedish.yaml
+++ b/lib/I18n/translations/swedish.yaml
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sektion"
STR_UPLOAD: "Uppladdning"
STR_BOOK_S_STYLE: "Bokstil"
STR_EMBEDDED_STYLE: "Inbäddad stil"
+STR_FOCUS_READING: "Fokusläsning"
STR_OPDS_SERVER_URL: "OPDS-serveradress"
STR_SET_SLEEP_COVER: "Ställ in omslag"
STR_FOOTNOTES: "Fotnoter"
diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml
index 066af673..6e0784ce 100644
--- a/lib/I18n/translations/turkish.yaml
+++ b/lib/I18n/translations/turkish.yaml
@@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Bölüm "
STR_UPLOAD: "Yükle"
STR_BOOK_S_STYLE: "Kitabın Stili"
STR_EMBEDDED_STYLE: "Gömülü Stil"
+STR_FOCUS_READING: "Odaklanmış Okuma"
STR_OPDS_SERVER_URL: "OPDS Sunucu Adresi"
STR_AUTO_TURN_ENABLED: "Otomatik Çevirme Etkin: "
STR_AUTO_TURN_PAGES_PER_MIN: "Otomatik Çevirme (Dakikada Sayfa)"
diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml
index dfe0d019..dc286ffd 100644
--- a/lib/I18n/translations/ukrainian.yaml
+++ b/lib/I18n/translations/ukrainian.yaml
@@ -292,6 +292,7 @@ STR_SECTION_PREFIX: "Розділ "
STR_UPLOAD: "Завантажити"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Вбудований стиль"
+STR_FOCUS_READING: "Фокусне читання"
STR_OPDS_SERVER_URL: "URL сервера OPDS"
STR_FOOTNOTES: "Примітки"
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
diff --git a/open-x4-sdk b/open-x4-sdk
index 7d86603a..a64a3c29 160000
--- a/open-x4-sdk
+++ b/open-x4-sdk
@@ -1 +1 @@
-Subproject commit 7d86603ad27709a9a766bb5ad893cfc39e60777e
+Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360
diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h
index bd6a8502..0b769005 100644
--- a/src/CrossPointSettings.h
+++ b/src/CrossPointSettings.h
@@ -213,6 +213,8 @@ class CrossPointSettings {
uint8_t fadingFix = 0;
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
uint8_t embeddedStyle = 1;
+ // Focus Reading - emphasizes the first part of words with bold
+ uint8_t focusReadingEnabled = 0;
// SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = "";
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
diff --git a/src/SettingsList.h b/src/SettingsList.h
index 9af7101e..d8a118c3 100644
--- a/src/SettingsList.h
+++ b/src/SettingsList.h
@@ -145,6 +145,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist
"paragraphAlignment", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_EMBEDDED_STYLE, &CrossPointSettings::embeddedStyle, "embeddedStyle",
StrId::STR_CAT_READER),
+ SettingInfo::Toggle(StrId::STR_FOCUS_READING, &CrossPointSettings::focusReadingEnabled, "focusReadingEnabled",
+ StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
StrId::STR_CAT_READER),
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp
index 3de6d69d..7732762b 100644
--- a/src/activities/reader/EpubReaderActivity.cpp
+++ b/src/activities/reader/EpubReaderActivity.cpp
@@ -609,7 +609,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
- SETTINGS.imageRendering)) {
+ SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_DBG("ERS", "Cache not found, building...");
GUI.drawPopup(renderer, tr(STR_INDEXING));
@@ -619,7 +619,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
- SETTINGS.imageRendering, popupFn)) {
+ SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showPendingSyncSaveError();
@@ -750,7 +750,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
- SETTINGS.imageRendering)) {
+ SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
return;
}
@@ -758,7 +758,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
- SETTINGS.imageRendering)) {
+ SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
}
}