## 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**_
467 lines
16 KiB
C++
467 lines
16 KiB
C++
#include "Section.h"
|
|
|
|
#include <HalStorage.h>
|
|
#include <Logging.h>
|
|
#include <Serialization.h>
|
|
|
|
#include "Epub/css/CssParser.h"
|
|
#include "Page.h"
|
|
#include "hyphenation/Hyphenator.h"
|
|
#include "parsers/ChapterHtmlSlimParser.h"
|
|
|
|
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(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
|
sizeof(uint32_t) + sizeof(uint32_t);
|
|
|
|
struct PageLutEntry {
|
|
uint32_t fileOffset;
|
|
uint16_t paragraphIndex;
|
|
uint16_t listItemIndex;
|
|
};
|
|
} // namespace
|
|
|
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
|
if (!file) {
|
|
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
|
return 0;
|
|
}
|
|
|
|
const uint32_t position = file.position();
|
|
if (!page->serialize(file)) {
|
|
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
|
return 0;
|
|
}
|
|
LOG_DBG("SCT", "Page %d processed", pageCount);
|
|
|
|
pageCount++;
|
|
return position;
|
|
}
|
|
|
|
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 focusReadingEnabled) {
|
|
if (!file) {
|
|
LOG_DBG("SCT", "File not open for writing header");
|
|
return;
|
|
}
|
|
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(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);
|
|
serialization::writePod(file, lineCompression);
|
|
serialization::writePod(file, extraParagraphSpacing);
|
|
serialization::writePod(file, paragraphAlignment);
|
|
serialization::writePod(file, viewportWidth);
|
|
serialization::writePod(file, viewportHeight);
|
|
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<uint32_t>(0)); // Placeholder for LUT offset (patched later)
|
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
|
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for paragraph LUT offset (patched later)
|
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for li LUT offset (patched later)
|
|
}
|
|
|
|
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
|
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
|
const uint8_t imageRendering, const bool focusReadingEnabled) {
|
|
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
|
return false;
|
|
}
|
|
|
|
// Match parameters
|
|
{
|
|
uint8_t version;
|
|
serialization::readPod(file, version);
|
|
if (version != SECTION_FILE_VERSION) {
|
|
// Explicit close() required: member variable persists beyond function scope
|
|
file.close();
|
|
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
|
clearCache();
|
|
return false;
|
|
}
|
|
|
|
int fileFontId;
|
|
uint16_t fileViewportWidth, fileViewportHeight;
|
|
float fileLineCompression;
|
|
bool fileExtraParagraphSpacing;
|
|
uint8_t fileParagraphAlignment;
|
|
bool fileHyphenationEnabled;
|
|
bool fileEmbeddedStyle;
|
|
uint8_t fileImageRendering;
|
|
bool fileFocusReadingEnabled;
|
|
serialization::readPod(file, fileFontId);
|
|
serialization::readPod(file, fileLineCompression);
|
|
serialization::readPod(file, fileExtraParagraphSpacing);
|
|
serialization::readPod(file, fileParagraphAlignment);
|
|
serialization::readPod(file, fileViewportWidth);
|
|
serialization::readPod(file, fileViewportHeight);
|
|
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 || focusReadingEnabled != fileFocusReadingEnabled) {
|
|
file.close();
|
|
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
|
clearCache();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
serialization::readPod(file, pageCount);
|
|
// Explicit close() required: member variable persists beyond function scope
|
|
file.close();
|
|
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
|
return true;
|
|
}
|
|
|
|
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
|
bool Section::clearCache() const {
|
|
if (!Storage.exists(filePath.c_str())) {
|
|
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
|
return true;
|
|
}
|
|
|
|
if (!Storage.remove(filePath.c_str())) {
|
|
LOG_ERR("SCT", "Failed to clear cache");
|
|
return false;
|
|
}
|
|
|
|
LOG_DBG("SCT", "Cache cleared successfully");
|
|
return true;
|
|
}
|
|
|
|
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 bool focusReadingEnabled,
|
|
const std::function<void()>& popupFn) {
|
|
const auto localPath = epub->getSpineItem(spineIndex).href;
|
|
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
|
|
|
// Create cache directory if it doesn't exist
|
|
{
|
|
const auto sectionsDir = epub->getCachePath() + "/sections";
|
|
Storage.mkdir(sectionsDir.c_str());
|
|
}
|
|
|
|
// Retry logic for SD card timing issues
|
|
bool success = false;
|
|
uint32_t fileSize = 0;
|
|
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
|
if (attempt > 0) {
|
|
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
|
delay(50); // Brief delay before retry
|
|
}
|
|
|
|
// Remove any incomplete file from previous attempt before retrying
|
|
if (Storage.exists(tmpHtmlPath.c_str())) {
|
|
Storage.remove(tmpHtmlPath.c_str());
|
|
}
|
|
|
|
FsFile tmpHtml;
|
|
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
|
continue;
|
|
}
|
|
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
|
fileSize = tmpHtml.size();
|
|
// Explicitly close() file before calling Storage.remove()
|
|
tmpHtml.close();
|
|
|
|
// If streaming failed, remove the incomplete file immediately
|
|
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
|
Storage.remove(tmpHtmlPath.c_str());
|
|
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
|
}
|
|
}
|
|
|
|
if (!success) {
|
|
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
|
return false;
|
|
}
|
|
|
|
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
|
|
|
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
|
return false;
|
|
}
|
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
|
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
|
std::vector<PageLutEntry> lut = {};
|
|
|
|
// Derive the content base directory and image cache path prefix for the parser
|
|
size_t lastSlash = localPath.find_last_of('/');
|
|
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
|
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
|
|
|
CssParser* cssParser = nullptr;
|
|
if (embeddedStyle) {
|
|
cssParser = epub->getCssParser();
|
|
if (cssParser) {
|
|
if (!cssParser->loadFromCache()) {
|
|
LOG_ERR("SCT", "Failed to load CSS from cache");
|
|
}
|
|
}
|
|
}
|
|
|
|
ChapterHtmlSlimParser visitor(
|
|
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
|
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
|
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
|
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
|
},
|
|
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
|
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
|
success = visitor.parseAndBuildPages();
|
|
|
|
Storage.remove(tmpHtmlPath.c_str());
|
|
if (!success) {
|
|
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
|
// Explicitly close() file before calling Storage.remove()
|
|
file.close();
|
|
Storage.remove(filePath.c_str());
|
|
if (cssParser) {
|
|
cssParser->clear();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const uint32_t lutOffset = file.position();
|
|
bool hasFailedLutRecords = false;
|
|
// Write LUT
|
|
for (const auto& entry : lut) {
|
|
if (entry.fileOffset == 0) {
|
|
hasFailedLutRecords = true;
|
|
break;
|
|
}
|
|
serialization::writePod(file, entry.fileOffset);
|
|
}
|
|
|
|
if (hasFailedLutRecords) {
|
|
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
|
// Explicitly close() file before calling Storage.remove()
|
|
file.close();
|
|
Storage.remove(filePath.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
|
const uint32_t anchorMapOffset = file.position();
|
|
const auto& anchors = visitor.getAnchors();
|
|
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
|
for (const auto& [anchor, page] : anchors) {
|
|
serialization::writeString(file, anchor);
|
|
serialization::writePod(file, page);
|
|
}
|
|
|
|
const uint32_t paragraphLutOffset = file.position();
|
|
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
|
for (const auto& entry : lut) {
|
|
serialization::writePod(file, entry.paragraphIndex);
|
|
}
|
|
|
|
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
|
for (const auto& entry : lut) {
|
|
serialization::writePod(file, entry.listItemIndex);
|
|
}
|
|
|
|
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
|
serialization::writePod(file, pageCount);
|
|
serialization::writePod(file, lutOffset);
|
|
serialization::writePod(file, anchorMapOffset);
|
|
serialization::writePod(file, paragraphLutOffset);
|
|
serialization::writePod(file, liLutFileOffset);
|
|
// Explicit close() required: member variable persists beyond function scope
|
|
file.close();
|
|
if (cssParser) {
|
|
cssParser->clear();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
|
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
|
return nullptr;
|
|
}
|
|
|
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
|
uint32_t lutOffset;
|
|
serialization::readPod(file, lutOffset);
|
|
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
|
uint32_t pagePos;
|
|
serialization::readPod(file, pagePos);
|
|
file.seek(pagePos);
|
|
|
|
auto page = Page::deserialize(file);
|
|
// Explicit close() required: member variable persists beyond function scope
|
|
file.close();
|
|
return page;
|
|
}
|
|
|
|
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
|
|
FsFile f;
|
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t fileSize = f.size();
|
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 3);
|
|
uint32_t anchorMapOffset;
|
|
serialization::readPod(f, anchorMapOffset);
|
|
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(anchorMapOffset);
|
|
uint16_t count;
|
|
serialization::readPod(f, count);
|
|
for (uint16_t i = 0; i < count; i++) {
|
|
std::string key;
|
|
uint16_t page;
|
|
serialization::readString(f, key);
|
|
serialization::readPod(f, page);
|
|
if (key == anchor) {
|
|
return page;
|
|
}
|
|
}
|
|
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex) const {
|
|
FsFile f;
|
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t fileSize = f.size();
|
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
|
uint32_t paragraphLutOffset;
|
|
serialization::readPod(f, paragraphLutOffset);
|
|
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(paragraphLutOffset);
|
|
uint16_t count;
|
|
serialization::readPod(f, count);
|
|
if (count == 0) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t);
|
|
if (lutEnd > fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
uint16_t resultPage = count - 1;
|
|
for (uint16_t i = 0; i < count; i++) {
|
|
uint16_t pagePIdx;
|
|
serialization::readPod(f, pagePIdx);
|
|
if (pagePIdx >= pIndex) {
|
|
resultPage = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return resultPage;
|
|
}
|
|
|
|
std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) const {
|
|
FsFile f;
|
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t fileSize = f.size();
|
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
|
uint32_t paragraphLutOffset;
|
|
serialization::readPod(f, paragraphLutOffset);
|
|
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(paragraphLutOffset);
|
|
uint16_t count;
|
|
serialization::readPod(f, count);
|
|
if (count == 0 || page >= count) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t);
|
|
if (entryEnd > fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t));
|
|
uint16_t pIdx;
|
|
serialization::readPod(f, pIdx);
|
|
return pIdx;
|
|
}
|
|
|
|
std::optional<uint16_t> Section::getPageForListItemIndex(const uint16_t liIndex) const {
|
|
FsFile f;
|
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t fileSize = f.size();
|
|
f.seek(HEADER_SIZE - sizeof(uint32_t));
|
|
uint32_t liLutOffset;
|
|
serialization::readPod(f, liLutOffset);
|
|
if (liLutOffset == 0 || liLutOffset >= fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
// The li LUT shares count with the paragraph LUT; read count from paragraphLutOffset
|
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
|
uint32_t paragraphLutOffset;
|
|
serialization::readPod(f, paragraphLutOffset);
|
|
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(paragraphLutOffset);
|
|
uint16_t count;
|
|
serialization::readPod(f, count);
|
|
if (count == 0) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
const uint32_t lutEnd = liLutOffset + count * sizeof(uint16_t);
|
|
if (lutEnd > fileSize) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
f.seek(liLutOffset);
|
|
uint16_t resultPage = count - 1;
|
|
for (uint16_t i = 0; i < count; i++) {
|
|
uint16_t pageLiIdx;
|
|
serialization::readPod(f, pageLiIdx);
|
|
if (pageLiIdx >= liIndex) {
|
|
resultPage = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return resultPage;
|
|
}
|