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. + +Comparison of the same page with and without Focus Reading enabled + +*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 Noto Serif font* + +Focus Reading with Merriweather font + +*Focus Reading with Merriweather font* + +Focus Reading with Atkinson Hyperlegible Next 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); } }