## Summary This PR introduces **Focus Reading**, a generic implementation of artificial fixation points (similar to Bionic Reading) designed to improve reading speed and focus by bolding the initial characters of words. This is achieved by dynamically bolding characters during indexing. <img width="500" alt="Focus Reading on X3" src="https://github.com/user-attachments/assets/94a632a5-82da-47be-957c-538b35bf84d9" /> ### Implementation Details #### Core Text Engine (`ParsedText`) - Modified `ParsedText::addWord` to implement a custom bolding algorithm. It uses a 45% ratio for bolding, with a minimum of 1 character and a maximum of 9. - UTF-8 Safety: Integrated `utf8NextCodepoint` to ensure character counting and string slicing occur at safe byte boundaries, preventing corruption of multi-byte characters (e.g., accented letters or smart quotes). - Intelligent Tokenization: This correctly identifies and separates "word" characters (letters, apostrophes, hyphens) from "non-word" characters (numbers, brackets, smart quotes). - Formatting Preservation: The logic ensures that punctuation is not "stolen" for the bolding count and that existing styles (like italics or underlines) are preserved across the bold/regular split. - Processing at indexing stage reduces CPU load at render-time and ensures layout/fit is unaffected. - Split details are tracked with `wordIsFocusSuffix`. After splitting and layout, suffixes are merged back into their preceding word entries to prevent a doubling of RAM usage. #### Settings and UI - Version Management: Bumped `SECTION_FILE_VERSION` to `21` - Global Settings: Added `focusReadingEnabled` to `CrossPointSettings`. - User Interface: Added a new toggle in the "Reader" section of the settings menu, positioned after the "Embedded Style" option. - Localization: Added the `STR_FOCUS_READING` string #### Plumbing - Plumbed the `focusReadingEnabled` boolean through `EpubReaderActivity`, `Section`, and `ChapterHtmlSlimParser` to ensure the user's setting reaches the `ParsedText` constructor during chapter indexing. ## Additional Context ### Files Changed - `lib/Epub/Epub/ParsedText.h / .cpp`: Core fixation logic and UTF-8 tokenization. - `lib/Epub/Epub/Section.h / .cpp`: Cache header updates and invalidation logic. - `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h / .cpp`: Plumbing the setting to text blocks. - `src/CrossPointSettings.h`: Data persistence for the new setting. - `src/SettingsList.h`: UI toggle implementation. - `src/activities/reader/EpubReaderActivity.cpp`: Handling settings changes during reading sessions. - `lib/I18n/translations/*.yaml`: UI strings. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_
167 lines
7.9 KiB
C++
167 lines
7.9 KiB
C++
#include "TextBlock.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
#include <Logging.h>
|
|
#include <Serialization.h>
|
|
|
|
#include <cstring>
|
|
|
|
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
|
// 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];
|
|
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<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
|
|
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
|
|
memcpy(boldBuf, words[i].c_str(), boldLen);
|
|
boldBuf[boldLen] = '\0';
|
|
renderer.drawText(fontId, wordX, 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];
|
|
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
|
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
|
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
|
|
|
int startX = wordX;
|
|
int underlineWidth = fullWordWidth;
|
|
|
|
// if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line
|
|
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
|
static_cast<uint8_t>(w[2]) == 0x83) {
|
|
const char* visiblePtr = w.c_str() + 3;
|
|
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
|
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
|
startX = wordX + prefixWidth;
|
|
underlineWidth = visibleWidth;
|
|
}
|
|
|
|
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool TextBlock::serialize(FsFile& file) const {
|
|
// 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<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
|
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
|
|
static_cast<uint32_t>(wordFocusSuffixX.size()));
|
|
return false;
|
|
}
|
|
|
|
// Word data
|
|
serialization::writePod(file, static_cast<uint16_t>(words.size()));
|
|
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<uint8_t>(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);
|
|
serialization::writePod(file, blockStyle.textAlignDefined);
|
|
serialization::writePod(file, blockStyle.marginTop);
|
|
serialization::writePod(file, blockStyle.marginBottom);
|
|
serialization::writePod(file, blockStyle.marginLeft);
|
|
serialization::writePod(file, blockStyle.marginRight);
|
|
serialization::writePod(file, blockStyle.paddingTop);
|
|
serialization::writePod(file, blockStyle.paddingBottom);
|
|
serialization::writePod(file, blockStyle.paddingLeft);
|
|
serialization::writePod(file, blockStyle.paddingRight);
|
|
serialization::writePod(file, blockStyle.textIndent);
|
|
serialization::writePod(file, blockStyle.textIndentDefined);
|
|
|
|
return true;
|
|
}
|
|
|
|
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|
uint16_t wc;
|
|
std::vector<std::string> words;
|
|
std::vector<int16_t> wordXpos;
|
|
std::vector<EpdFontFamily::Style> wordStyles;
|
|
std::vector<uint8_t> wordFocusBoundary;
|
|
std::vector<uint16_t> wordFocusSuffixX;
|
|
BlockStyle blockStyle;
|
|
|
|
// Word count
|
|
serialization::readPod(file, wc);
|
|
|
|
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
|
if (wc > 10000) {
|
|
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
|
return nullptr;
|
|
}
|
|
|
|
// Word data
|
|
words.resize(wc);
|
|
wordXpos.resize(wc);
|
|
wordStyles.resize(wc);
|
|
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);
|
|
serialization::readPod(file, blockStyle.textAlignDefined);
|
|
serialization::readPod(file, blockStyle.marginTop);
|
|
serialization::readPod(file, blockStyle.marginBottom);
|
|
serialization::readPod(file, blockStyle.marginLeft);
|
|
serialization::readPod(file, blockStyle.marginRight);
|
|
serialization::readPod(file, blockStyle.paddingTop);
|
|
serialization::readPod(file, blockStyle.paddingBottom);
|
|
serialization::readPod(file, blockStyle.paddingLeft);
|
|
serialization::readPod(file, blockStyle.paddingRight);
|
|
serialization::readPod(file, blockStyle.textIndent);
|
|
serialization::readPod(file, blockStyle.textIndentDefined);
|
|
|
|
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
|
|
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
|
|
blockStyle));
|
|
}
|