From 92ef1dcaf6e5f34a2b84a01ff8fb987564b98b3b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 09:03:41 +0200 Subject: [PATCH 1/6] Yet another bionic reading implementation Co-authored-by: Copilot --- lib/Epub/Epub/ParsedText.h | 9 ++++-- lib/I18n/translations/english.yaml | 2 ++ src/CrossPointSettings.h | 1 + src/JsonSettingsIO.cpp | 2 ++ src/RecentBooksStore.cpp | 14 +++++---- src/RecentBooksStore.h | 1 + src/SettingsList.h | 2 +- src/activities/ActivityResult.h | 1 + src/activities/reader/EpubReaderActivity.h | 1 + .../reader/EpubReaderMenuActivity.cpp | 29 ++++++++++++------- .../reader/EpubReaderMenuActivity.h | 5 ++-- 11 files changed, 47 insertions(+), 20 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 6aa7e3cc..2d94ed09 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -26,8 +26,10 @@ class ParsedText { BlockStyle blockStyle; bool extraParagraphSpacing; bool hyphenationEnabled; + bool bionicReadingEnabled; void applyParagraphIndent(); + void applyBionicReadingTransform(); std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, @@ -60,8 +62,11 @@ class ParsedText { public: explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false, - const BlockStyle& blockStyle = BlockStyle()) - : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} + const BlockStyle& blockStyle = BlockStyle(), const bool bionicReadingEnabled = false) + : blockStyle(blockStyle), + extraParagraphSpacing(extraParagraphSpacing), + hyphenationEnabled(hyphenationEnabled), + bionicReadingEnabled(bionicReadingEnabled) {} ~ParsedText() = default; void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index c667b1a7..c214eb27 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -598,3 +598,5 @@ STR_KB_HINT_SECONDARY_CHAR: "Hold SELECT for secondary char" STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char" STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char" STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets" +STR_BIONIC_READING: "Bionic Reading" +STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading" \ No newline at end of file diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index f124c030..4f1cd376 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -284,6 +284,7 @@ class CrossPointSettings { BTN_PREV_SECTION, BTN_EXIT_READER, BTN_READER_MENU, + BTN_TOGGLE_BIONIC_READING, BTN_KOREADER_SYNC, BUTTON_ACTION_COUNT }; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 2878ae99..d0f586b4 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -418,6 +418,7 @@ bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* obj["imageRenderingOverride"] = book.imageRenderingOverride; obj["fontFamilyOverride"] = book.fontFamilyOverride; obj["fontSizeOverride"] = book.fontSizeOverride; + obj["bionicReadingOverride"] = book.bionicReadingOverride; } String json; @@ -455,6 +456,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) book.fontFamilyOverride = clampInt8(obj["fontFamilyOverride"] | -1, -1, CrossPointSettings::FONT_FAMILY_COUNT - 1, -1); book.fontSizeOverride = clampInt8(obj["fontSizeOverride"] | -1, -1, CrossPointSettings::FONT_SIZE_COUNT - 1, -1); + book.bionicReadingOverride = obj["bionicReadingOverride"] | false; store.recentBooks.push_back(book); } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 8fbadd06..5891234c 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -26,6 +26,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title int8_t imageRenderingOverride = -1; int8_t fontFamilyOverride = -1; int8_t fontSizeOverride = -1; + bool bionicReadingOverride = false; // Remove existing entry if present auto it = @@ -35,12 +36,14 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title imageRenderingOverride = it->imageRenderingOverride; fontFamilyOverride = it->fontFamilyOverride; fontSizeOverride = it->fontSizeOverride; + bionicReadingOverride = it->bionicReadingOverride; recentBooks.erase(it); } // Add to front - recentBooks.insert(recentBooks.begin(), {path, title, author, series, coverBmpPath, embeddedStyleOverride, - imageRenderingOverride, fontFamilyOverride, fontSizeOverride}); + recentBooks.insert(recentBooks.begin(), + {path, title, author, series, coverBmpPath, embeddedStyleOverride, imageRenderingOverride, + fontFamilyOverride, fontSizeOverride, bionicReadingOverride}); // Trim to max size if (recentBooks.size() > MAX_RECENT_BOOKS) { @@ -83,19 +86,19 @@ RecentBook RecentBooksStore::getBookByPath(const std::string& path) const { } bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, - const int8_t imageRenderingOverride) { + const int8_t imageRenderingOverride, const bool bionicReadingOverride) { auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it == recentBooks.end()) { return false; } return setReaderOverrides(path, embeddedStyleOverride, imageRenderingOverride, it->fontFamilyOverride, - it->fontSizeOverride); + it->fontSizeOverride, bionicReadingOverride); } bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, const int8_t imageRenderingOverride, const int8_t fontFamilyOverride, - const int8_t fontSizeOverride) { + const int8_t fontSizeOverride, const bool bionicReadingOverride) { auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it == recentBooks.end()) { @@ -106,6 +109,7 @@ bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t it->imageRenderingOverride = imageRenderingOverride; it->fontFamilyOverride = fontFamilyOverride; it->fontSizeOverride = fontSizeOverride; + it->bionicReadingOverride = bionicReadingOverride; return saveToFile(); } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 6ca8f7a6..9815fa9c 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -17,6 +17,7 @@ struct RecentBook { int8_t fontFamilyOverride = -1; // -1 = use global setting, otherwise CrossPointSettings::FONT_SIZE value. int8_t fontSizeOverride = -1; + bool bionicReadingOverride = false; bool operator==(const RecentBook& other) const { return path == other.path; } }; diff --git a/src/SettingsList.h b/src/SettingsList.h index e53acd96..1f7149ec 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -145,7 +145,7 @@ inline const std::vector list = { StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, StrId::STR_BTN_ACT_OPEN_BOOKMARKS, \ StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, StrId::STR_BTN_ACT_NEXT_SECTION, \ StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, StrId::STR_BTN_ACT_READER_MENU, \ - StrId::STR_BTN_ACT_KOREADER_SYNC + StrId::STR_BTN_ACT_TOGGLE_BIONIC_READING, StrId::STR_BTN_ACT_KOREADER_SYNC // Back button: short=exit reader, double=ignore, long=go home SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortBack, {StrId::STR_BTN_DEF_EXIT_READER}, diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 151137b6..c535d671 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -28,6 +28,7 @@ struct MenuResult { int8_t fontFamilyOverride = -1; int8_t fontSizeOverride = -1; uint8_t textDarkness = 1; + uint8_t bionicReadingOverride = 0; }; struct ChapterResult { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 1fa1789b..49fe61c1 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -135,6 +135,7 @@ class EpubReaderActivity final : public Activity { int8_t bookImageRenderingOverride = -1; int8_t bookFontFamilyOverride = -1; int8_t bookFontSizeOverride = -1; + bool bookBionicReadingOverride = false; // Bookmarks (starred pages) BookmarkStore bookmarkStore; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8f42e97e..44524993 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -30,14 +30,12 @@ std::string defaultFontFamilyLabel(const SettingInfo& item) { } } // namespace -EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::string& title, const int currentPage, const int totalPages, - const int bookProgressPercent, const uint8_t currentOrientation, - const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride, - const int8_t initialImageRenderingOverride, - const int8_t initialFontFamilyOverride, - const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, - const bool hasStarredPages, const bool isCurrentPageStarred) +EpubReaderMenuActivity::EpubReaderMenuActivity( + GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, + const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const bool hasFootnotes, + const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, + const int8_t initialFontFamilyOverride, const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, + const bool initialBionicReadingOverride, const bool hasStarredPages, const bool isCurrentPageStarred) : MenuListActivity("EpubReaderMenu", renderer, mappedInput), currentPageStarred(isCurrentPageStarred), pendingOrientation(currentOrientation), @@ -46,6 +44,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu pendingFontFamilyOverride(initialFontFamilyOverride), pendingFontSizeOverride(initialFontSizeOverride), pendingTextDarkness(initialTextDarkness), + pendingBionicReading(initialBionicReadingOverride), title(title), currentPage(currentPage), totalPages(totalPages), @@ -161,6 +160,15 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa [](void* ctx, uint8_t v) { static_cast(ctx)->pendingTextDarkness = v; }) .withSubmenu(StrId::STR_READER_OVERRIDES)); + menuItems.push_back( + SettingInfo::DynamicEnumCtx( + StrId::STR_BIONIC_READING, {StrId::STR_STATE_OFF, StrId::STR_STATE_ON}, self, + [](const void* ctx) -> uint8_t { + return static_cast(ctx)->pendingBionicReading ? 1 : 0; + }, + [](void* ctx, uint8_t v) { static_cast(ctx)->pendingBionicReading = (v != 0); }) + .withSubmenu(StrId::STR_READER_OVERRIDES)); + // Helper functions, reading ruler, auto page turn, orientation menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS)); // Auto page turn: ACTION type with custom cycling in onActionSelected @@ -240,7 +248,7 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForSettingActio void EpubReaderMenuActivity::finishWithAction(MenuAction action) { setResult(MenuResult{static_cast(action), -1, pendingOrientation, selectedPageTurnOption, pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingFontFamilyOverride, - pendingFontSizeOverride, pendingTextDarkness}); + pendingFontSizeOverride, pendingTextDarkness, static_cast(pendingBionicReading)}); finish(); } @@ -273,7 +281,8 @@ void EpubReaderMenuActivity::onBackPressed() { pendingImageRenderingOverride, pendingFontFamilyOverride, pendingFontSizeOverride, - pendingTextDarkness}; + pendingTextDarkness, + static_cast(pendingBionicReading)}; setResult(std::move(result)); finish(); } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index df39b99c..f119b8d3 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -37,8 +37,8 @@ class EpubReaderMenuActivity final : public MenuListActivity { const uint8_t currentOrientation, const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, const int8_t initialFontFamilyOverride, const int8_t initialFontSizeOverride, - const uint8_t initialTextDarkness, const bool hasStarredPages, - const bool isCurrentPageStarred); + const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, + const bool hasStarredPages, const bool isCurrentPageStarred); void onEnter() override; void render(RenderLock&&) override; @@ -69,6 +69,7 @@ class EpubReaderMenuActivity final : public MenuListActivity { int8_t pendingFontFamilyOverride = -1; int8_t pendingFontSizeOverride = -1; uint8_t pendingTextDarkness = 1; + bool pendingBionicReading = false; static constexpr const char* pageTurnLabels[] = {"", "1", "3", "6", "12"}; From cefed0f6ce72000609f70e30427fd741f4fe3990 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 09:11:49 +0200 Subject: [PATCH 2/6] Yet another bionic reading implementation Co-authored-by: Copilot --- lib/Epub/Epub/ParsedText.h | 9 ++++-- lib/I18n/translations/english.yaml | 2 ++ src/CrossPointSettings.h | 1 + src/JsonSettingsIO.cpp | 2 ++ src/RecentBooksStore.cpp | 14 +++++---- src/RecentBooksStore.h | 5 ++++ src/SettingsList.h | 2 +- src/activities/ActivityResult.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 14 +++++---- src/activities/reader/EpubReaderActivity.h | 3 +- .../reader/EpubReaderMenuActivity.cpp | 29 ++++++++++++------- .../reader/EpubReaderMenuActivity.h | 5 ++-- 12 files changed, 61 insertions(+), 26 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 6aa7e3cc..2d94ed09 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -26,8 +26,10 @@ class ParsedText { BlockStyle blockStyle; bool extraParagraphSpacing; bool hyphenationEnabled; + bool bionicReadingEnabled; void applyParagraphIndent(); + void applyBionicReadingTransform(); std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, @@ -60,8 +62,11 @@ class ParsedText { public: explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false, - const BlockStyle& blockStyle = BlockStyle()) - : blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {} + const BlockStyle& blockStyle = BlockStyle(), const bool bionicReadingEnabled = false) + : blockStyle(blockStyle), + extraParagraphSpacing(extraParagraphSpacing), + hyphenationEnabled(hyphenationEnabled), + bionicReadingEnabled(bionicReadingEnabled) {} ~ParsedText() = default; void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index c667b1a7..c214eb27 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -598,3 +598,5 @@ STR_KB_HINT_SECONDARY_CHAR: "Hold SELECT for secondary char" STR_KB_HINT_UPPER_SECONDARY: "Hold SELECT for UPPERCASE or secondary char" STR_KB_HINT_LOWER_SECONDARY: "Hold SELECT for lowercase or secondary char" STR_KB_HINT_URL_SNIPPETS: "Press URL for snippets" +STR_BIONIC_READING: "Bionic Reading" +STR_BTN_ACT_TOGGLE_BIONIC_READING: "Toggle Bionic Reading" \ No newline at end of file diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index f124c030..4f1cd376 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -284,6 +284,7 @@ class CrossPointSettings { BTN_PREV_SECTION, BTN_EXIT_READER, BTN_READER_MENU, + BTN_TOGGLE_BIONIC_READING, BTN_KOREADER_SYNC, BUTTON_ACTION_COUNT }; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 2878ae99..d0f586b4 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -418,6 +418,7 @@ bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* obj["imageRenderingOverride"] = book.imageRenderingOverride; obj["fontFamilyOverride"] = book.fontFamilyOverride; obj["fontSizeOverride"] = book.fontSizeOverride; + obj["bionicReadingOverride"] = book.bionicReadingOverride; } String json; @@ -455,6 +456,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) book.fontFamilyOverride = clampInt8(obj["fontFamilyOverride"] | -1, -1, CrossPointSettings::FONT_FAMILY_COUNT - 1, -1); book.fontSizeOverride = clampInt8(obj["fontSizeOverride"] | -1, -1, CrossPointSettings::FONT_SIZE_COUNT - 1, -1); + book.bionicReadingOverride = obj["bionicReadingOverride"] | false; store.recentBooks.push_back(book); } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 8fbadd06..5891234c 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -26,6 +26,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title int8_t imageRenderingOverride = -1; int8_t fontFamilyOverride = -1; int8_t fontSizeOverride = -1; + bool bionicReadingOverride = false; // Remove existing entry if present auto it = @@ -35,12 +36,14 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title imageRenderingOverride = it->imageRenderingOverride; fontFamilyOverride = it->fontFamilyOverride; fontSizeOverride = it->fontSizeOverride; + bionicReadingOverride = it->bionicReadingOverride; recentBooks.erase(it); } // Add to front - recentBooks.insert(recentBooks.begin(), {path, title, author, series, coverBmpPath, embeddedStyleOverride, - imageRenderingOverride, fontFamilyOverride, fontSizeOverride}); + recentBooks.insert(recentBooks.begin(), + {path, title, author, series, coverBmpPath, embeddedStyleOverride, imageRenderingOverride, + fontFamilyOverride, fontSizeOverride, bionicReadingOverride}); // Trim to max size if (recentBooks.size() > MAX_RECENT_BOOKS) { @@ -83,19 +86,19 @@ RecentBook RecentBooksStore::getBookByPath(const std::string& path) const { } bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, - const int8_t imageRenderingOverride) { + const int8_t imageRenderingOverride, const bool bionicReadingOverride) { auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it == recentBooks.end()) { return false; } return setReaderOverrides(path, embeddedStyleOverride, imageRenderingOverride, it->fontFamilyOverride, - it->fontSizeOverride); + it->fontSizeOverride, bionicReadingOverride); } bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, const int8_t imageRenderingOverride, const int8_t fontFamilyOverride, - const int8_t fontSizeOverride) { + const int8_t fontSizeOverride, const bool bionicReadingOverride) { auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it == recentBooks.end()) { @@ -106,6 +109,7 @@ bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t it->imageRenderingOverride = imageRenderingOverride; it->fontFamilyOverride = fontFamilyOverride; it->fontSizeOverride = fontSizeOverride; + it->bionicReadingOverride = bionicReadingOverride; return saveToFile(); } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 6ca8f7a6..aa381153 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -17,6 +17,7 @@ struct RecentBook { int8_t fontFamilyOverride = -1; // -1 = use global setting, otherwise CrossPointSettings::FONT_SIZE value. int8_t fontSizeOverride = -1; + bool bionicReadingOverride = false; bool operator==(const RecentBook& other) const { return path == other.path; } }; @@ -64,6 +65,10 @@ class RecentBooksStore { bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride); bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride, int8_t fontFamilyOverride, int8_t fontSizeOverride); + bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride, + bool bionicReadingOverride); + bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride, + int8_t fontFamilyOverride, int8_t fontSizeOverride, bool bionicReadingOverride); private: bool loadFromBinaryFile(); diff --git a/src/SettingsList.h b/src/SettingsList.h index e53acd96..1f7149ec 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -145,7 +145,7 @@ inline const std::vector list = { StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, StrId::STR_BTN_ACT_OPEN_BOOKMARKS, \ StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, StrId::STR_BTN_ACT_NEXT_SECTION, \ StrId::STR_BTN_ACT_PREV_SECTION, StrId::STR_BTN_ACT_EXIT_READER, StrId::STR_BTN_ACT_READER_MENU, \ - StrId::STR_BTN_ACT_KOREADER_SYNC + StrId::STR_BTN_ACT_TOGGLE_BIONIC_READING, StrId::STR_BTN_ACT_KOREADER_SYNC // Back button: short=exit reader, double=ignore, long=go home SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortBack, {StrId::STR_BTN_DEF_EXIT_READER}, diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 151137b6..c535d671 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -28,6 +28,7 @@ struct MenuResult { int8_t fontFamilyOverride = -1; int8_t fontSizeOverride = -1; uint8_t textDarkness = 1; + uint8_t bionicReadingOverride = 0; }; struct ChapterResult { diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c678bc14..490dab4f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -191,6 +191,7 @@ void EpubReaderActivity::onEnter() { bookImageRenderingOverride = currentBook.imageRenderingOverride; bookFontFamilyOverride = currentBook.fontFamilyOverride; bookFontSizeOverride = currentBook.fontSizeOverride; + bookBionicReadingOverride = currentBook.bionicReadingOverride; logReaderMemSnapshot("onEnter_after_recent_books"); // Trigger first update @@ -1033,13 +1034,14 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption void EpubReaderActivity::applyBookReaderOverrides(const int8_t embeddedStyleOverride, const int8_t imageRenderingOverride, const int8_t fontFamilyOverride, - const int8_t fontSizeOverride) { + const int8_t fontSizeOverride, const bool bionicReadingOverride) { if (!epub) { return; } if (bookEmbeddedStyleOverride == embeddedStyleOverride && bookImageRenderingOverride == imageRenderingOverride && - bookFontFamilyOverride == fontFamilyOverride && bookFontSizeOverride == fontSizeOverride) { + bookFontFamilyOverride == fontFamilyOverride && bookFontSizeOverride == fontSizeOverride && + bookBionicReadingOverride == bionicReadingOverride) { return; } @@ -1047,8 +1049,9 @@ void EpubReaderActivity::applyBookReaderOverrides(const int8_t embeddedStyleOver bookImageRenderingOverride = imageRenderingOverride; bookFontFamilyOverride = fontFamilyOverride; bookFontSizeOverride = fontSizeOverride; + bookBionicReadingOverride = bionicReadingOverride; RECENT_BOOKS.setReaderOverrides(epub->getPath(), bookEmbeddedStyleOverride, bookImageRenderingOverride, - bookFontFamilyOverride, bookFontSizeOverride); + bookFontFamilyOverride, bookFontSizeOverride, bookBionicReadingOverride); RenderLock lock(*this); if (section) { @@ -1861,14 +1864,15 @@ void EpubReaderActivity::openReaderMenu() { std::make_unique( renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, !currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride, - bookFontSizeOverride, SETTINGS.textDarkness, !bookmarkStore.isEmpty(), isCurrentPageStarred), + bookFontSizeOverride, SETTINGS.textDarkness, bookBionicReadingOverride, !bookmarkStore.isEmpty(), + isCurrentPageStarred), [this](const ActivityResult& result) { const auto& menu = std::get(result.data); applyOrientation(menu.orientation); applyTextDarkness(menu.textDarkness); toggleAutoPageTurn(menu.pageTurnOption); applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride, menu.fontFamilyOverride, - menu.fontSizeOverride); + menu.fontSizeOverride, static_cast(menu.bionicReadingOverride)); if (!result.isCancelled) { onReaderMenuConfirm(static_cast(menu.action)); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 1fa1789b..37429356 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -135,6 +135,7 @@ class EpubReaderActivity final : public Activity { int8_t bookImageRenderingOverride = -1; int8_t bookFontFamilyOverride = -1; int8_t bookFontSizeOverride = -1; + bool bookBionicReadingOverride = false; // Bookmarks (starred pages) BookmarkStore bookmarkStore; @@ -176,7 +177,7 @@ class EpubReaderActivity final : public Activity { void applyTextDarkness(uint8_t textDarkness); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); void applyBookReaderOverrides(int8_t embeddedStyleOverride, int8_t imageRenderingOverride, int8_t fontFamilyOverride, - int8_t fontSizeOverride); + int8_t fontSizeOverride, bool bionicReadingOverride); void openReaderMenu(); bool getEffectiveEmbeddedStyle() const; uint8_t getEffectiveImageRendering() const; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8f42e97e..44524993 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -30,14 +30,12 @@ std::string defaultFontFamilyLabel(const SettingInfo& item) { } } // namespace -EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::string& title, const int currentPage, const int totalPages, - const int bookProgressPercent, const uint8_t currentOrientation, - const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride, - const int8_t initialImageRenderingOverride, - const int8_t initialFontFamilyOverride, - const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, - const bool hasStarredPages, const bool isCurrentPageStarred) +EpubReaderMenuActivity::EpubReaderMenuActivity( + GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, + const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const bool hasFootnotes, + const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, + const int8_t initialFontFamilyOverride, const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, + const bool initialBionicReadingOverride, const bool hasStarredPages, const bool isCurrentPageStarred) : MenuListActivity("EpubReaderMenu", renderer, mappedInput), currentPageStarred(isCurrentPageStarred), pendingOrientation(currentOrientation), @@ -46,6 +44,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu pendingFontFamilyOverride(initialFontFamilyOverride), pendingFontSizeOverride(initialFontSizeOverride), pendingTextDarkness(initialTextDarkness), + pendingBionicReading(initialBionicReadingOverride), title(title), currentPage(currentPage), totalPages(totalPages), @@ -161,6 +160,15 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa [](void* ctx, uint8_t v) { static_cast(ctx)->pendingTextDarkness = v; }) .withSubmenu(StrId::STR_READER_OVERRIDES)); + menuItems.push_back( + SettingInfo::DynamicEnumCtx( + StrId::STR_BIONIC_READING, {StrId::STR_STATE_OFF, StrId::STR_STATE_ON}, self, + [](const void* ctx) -> uint8_t { + return static_cast(ctx)->pendingBionicReading ? 1 : 0; + }, + [](void* ctx, uint8_t v) { static_cast(ctx)->pendingBionicReading = (v != 0); }) + .withSubmenu(StrId::STR_READER_OVERRIDES)); + // Helper functions, reading ruler, auto page turn, orientation menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS)); // Auto page turn: ACTION type with custom cycling in onActionSelected @@ -240,7 +248,7 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForSettingActio void EpubReaderMenuActivity::finishWithAction(MenuAction action) { setResult(MenuResult{static_cast(action), -1, pendingOrientation, selectedPageTurnOption, pendingEmbeddedStyleOverride, pendingImageRenderingOverride, pendingFontFamilyOverride, - pendingFontSizeOverride, pendingTextDarkness}); + pendingFontSizeOverride, pendingTextDarkness, static_cast(pendingBionicReading)}); finish(); } @@ -273,7 +281,8 @@ void EpubReaderMenuActivity::onBackPressed() { pendingImageRenderingOverride, pendingFontFamilyOverride, pendingFontSizeOverride, - pendingTextDarkness}; + pendingTextDarkness, + static_cast(pendingBionicReading)}; setResult(std::move(result)); finish(); } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index df39b99c..f119b8d3 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -37,8 +37,8 @@ class EpubReaderMenuActivity final : public MenuListActivity { const uint8_t currentOrientation, const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, const int8_t initialFontFamilyOverride, const int8_t initialFontSizeOverride, - const uint8_t initialTextDarkness, const bool hasStarredPages, - const bool isCurrentPageStarred); + const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, + const bool hasStarredPages, const bool isCurrentPageStarred); void onEnter() override; void render(RenderLock&&) override; @@ -69,6 +69,7 @@ class EpubReaderMenuActivity final : public MenuListActivity { int8_t pendingFontFamilyOverride = -1; int8_t pendingFontSizeOverride = -1; uint8_t pendingTextDarkness = 1; + bool pendingBionicReading = false; static constexpr const char* pageTurnLabels[] = {"", "1", "3", "6", "12"}; From 867bd34798f4e9844b57162d983152696274e1bc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 12:08:30 +0200 Subject: [PATCH 3/6] A couple of fixes --- lib/Epub/Epub/ParsedText.cpp | 66 +++++++++++++++++++ lib/Epub/Epub/Section.cpp | 24 ++++--- lib/Epub/Epub/Section.h | 7 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 2 +- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 20 +++--- src/CrossPointSettings.h | 2 + src/JsonSettingsIO.cpp | 2 +- src/RecentBooksStore.cpp | 2 +- src/RecentBooksStore.h | 3 +- src/SettingsList.h | 2 + src/activities/reader/EpubReaderActivity.cpp | 22 ++++--- 11 files changed, 118 insertions(+), 34 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index c13a8e99..283ddafc 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -149,6 +149,9 @@ void ParsedText::layoutAndExtractLines( // Apply fixed transforms before any per-line layout work. applyParagraphIndent(); + if (bionicReadingEnabled) { + applyBionicReadingTransform(); + } // Ensure SD card font glyph metrics are loaded before measuring word widths. // For flash-based fonts isSdCardFont() returns false and this block is skipped @@ -506,6 +509,69 @@ void ParsedText::applyParagraphIndent() { } } +void ParsedText::applyBionicReadingTransform() { + if (words.empty()) { + return; + } + + std::vector transformedWords; + std::vector transformedStyles; + std::vector transformedContinues; + transformedWords.reserve(words.size() * 2); + transformedStyles.reserve(wordStyles.size() * 2); + transformedContinues.reserve(wordContinues.size() * 2); + + for (size_t i = 0; i < words.size(); ++i) { + const std::string& word = words[i]; + const auto originalStyle = wordStyles[i]; + const bool attachToPrevious = wordContinues[i]; + + const unsigned char* ptr = reinterpret_cast(word.c_str()); + int codepointCount = 0; + while (utf8NextCodepoint(&ptr)) { + codepointCount++; + } + + if (codepointCount <= 3) { + transformedWords.push_back(word); + transformedStyles.push_back(originalStyle); + transformedContinues.push_back(attachToPrevious); + continue; + } + + const int boldPrefixCount = std::max(1, (codepointCount + 1) / 2); + ptr = reinterpret_cast(word.c_str()); + const unsigned char* prefixEnd = ptr; + for (int j = 0; j < boldPrefixCount && *prefixEnd; ++j) { + utf8NextCodepoint(&prefixEnd); + } + const size_t prefixByteCount = + static_cast(prefixEnd - reinterpret_cast(word.c_str())); + if (prefixByteCount >= word.size()) { + transformedWords.push_back(word); + transformedStyles.push_back(originalStyle); + transformedContinues.push_back(attachToPrevious); + continue; + } + + const std::string prefix(word.data(), prefixByteCount); + const std::string suffix(word.data() + prefixByteCount, word.size() - prefixByteCount); + const auto boldStyle = static_cast(originalStyle | EpdFontFamily::BOLD); + + transformedWords.push_back(prefix); + transformedStyles.push_back(boldStyle); + transformedContinues.push_back(attachToPrevious); + + transformedWords.push_back(suffix); + transformedStyles.push_back(originalStyle); + transformedContinues.push_back(true); + } + + words = std::move(transformedWords); + wordStyles = std::move(transformedStyles); + wordContinues = std::move(transformedContinues); +} + // Builds break indices while opportunistically splitting the word that would overflow the current line. std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index d6cdc424..3edb1e70 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -12,7 +12,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 23; +constexpr uint8_t SECTION_FILE_VERSION = 24; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION sizeof(int) + // fontId sizeof(float) + // lineCompression @@ -23,6 +23,7 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION sizeof(uint16_t) + // pageCount (stored as 16-bit in header) sizeof(bool) + // hyphenationEnabled sizeof(bool) + // embeddedStyle + sizeof(bool) + // bionicReadingEnabled sizeof(uint8_t) + // imageRendering sizeof(uint32_t) + // page LUT offset sizeof(uint32_t) + // anchor map offset @@ -55,7 +56,8 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, - const bool embeddedStyle, const uint8_t imageRendering) { + const bool embeddedStyle, const bool bionicReadingEnabled, + const uint8_t imageRendering) { if (!file) { LOG_DBG("SCT", "File not open for writing header"); return; @@ -63,8 +65,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) + - sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + - sizeof(uint32_t) + sizeof(uint32_t), + sizeof(embeddedStyle) + sizeof(bionicReadingEnabled) + sizeof(imageRendering) + + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t), "Header size mismatch"); serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, fontId); @@ -75,6 +77,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi serialization::writePod(file, viewportHeight); serialization::writePod(file, hyphenationEnabled); serialization::writePod(file, embeddedStyle); + serialization::writePod(file, bionicReadingEnabled); serialization::writePod(file, imageRendering); serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later) serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset (patched later) @@ -85,7 +88,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, - const uint8_t imageRendering) { + const bool bionicReadingEnabled, const uint8_t imageRendering) { if (!Storage.openFileForRead("SCT", filePath, file)) { return false; } @@ -107,6 +110,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con uint8_t fileParagraphAlignment; bool fileHyphenationEnabled; bool fileEmbeddedStyle; + bool fileBionicReadingEnabled; uint8_t fileImageRendering; serialization::readPod(file, fileFontId); serialization::readPod(file, fileLineCompression); @@ -116,13 +120,14 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con serialization::readPod(file, fileViewportHeight); serialization::readPod(file, fileHyphenationEnabled); serialization::readPod(file, fileEmbeddedStyle); + serialization::readPod(file, fileBionicReadingEnabled); serialization::readPod(file, fileImageRendering); if (fontId != fileFontId || lineCompression != fileLineCompression || extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment || viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle || - imageRendering != fileImageRendering) { + bionicReadingEnabled != fileBionicReadingEnabled || imageRendering != fileImageRendering) { LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); clearCache(); // closes file before removal return false; @@ -188,7 +193,8 @@ bool Section::clearCache() { bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, - const uint8_t imageRendering, const std::function& progressFn) { + const bool bionicReadingEnabled, const uint8_t imageRendering, + const std::function& progressFn) { const uint32_t phaseTotalStart = millis(); const auto localPath = epub->getSpineItem(spineIndex).href; @@ -210,7 +216,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return false; } writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, - viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering); + viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering); std::vector lut = {}; // Derive the content base directory and image cache path prefix for the parser @@ -243,7 +249,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c ChapterHtmlSlimParser visitor( epub, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, - hyphenationEnabled, + hyphenationEnabled, bionicReadingEnabled, [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), progressFn, cssParser); Hyphenator::setPreferredLanguage(epub->getLanguage()); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index d2c85b4c..e4bdab5f 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -20,7 +20,7 @@ class Section { void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, - bool embeddedStyle, uint8_t imageRendering); + bool embeddedStyle, bool bionicReadingEnabled, uint8_t imageRendering); uint32_t onPageComplete(std::unique_ptr page); struct TocBoundary { @@ -50,11 +50,12 @@ class Section { ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, - uint8_t imageRendering); + bool bionicReadingEnabled, uint8_t imageRendering); bool clearCache(); bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, - uint8_t imageRendering, const std::function& progressFn = nullptr); + bool bionicReadingEnabled, uint8_t imageRendering, + const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSectionFile(); // Given a page in this section, return the TOC index for that page. diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 6925d0aa..bfa5ad9a 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -255,7 +255,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, blockStyle, bionicReadingEnabled)); wordsExtractedInBlock = 0; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 875ecfcc..cb2df47a 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -128,6 +128,7 @@ class ChapterHtmlSlimParser final : public Print { int currentFootnoteLinkTextLen = 0; std::vector> pendingFootnotes; // int wordsExtractedInBlock = 0; + bool bionicReadingEnabled = false; // Per-chapter caches: resolveStyle and parseInlineStyle are called for every HTML element; // caching by (tag|classAttr) and styleAttr avoids repeated string operations and hash lookups. @@ -149,16 +150,14 @@ class ChapterHtmlSlimParser final : public Print { static void XMLCALL endElement(void* userData, const XML_Char* name); public: - explicit ChapterHtmlSlimParser(std::shared_ptr epub, GfxRenderer& renderer, const int fontId, - const float lineCompression, const bool extraParagraphSpacing, - const uint8_t paragraphAlignment, const uint16_t viewportWidth, - const uint16_t viewportHeight, const bool hyphenationEnabled, - const std::function)>& completePageFn, - const bool embeddedStyle, const std::string& contentBase, - const std::string& imageBasePath, const uint8_t imageRendering = 0, - std::vector tocAnchors = {}, - const std::function& progressFn = nullptr, - const CssParser* cssParser = nullptr) + explicit ChapterHtmlSlimParser( + std::shared_ptr epub, GfxRenderer& renderer, const int fontId, const float lineCompression, + const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, + const uint16_t viewportHeight, const bool hyphenationEnabled, const bool bionicReadingEnabled, + const std::function)>& completePageFn, const bool embeddedStyle, + const std::string& contentBase, const std::string& imageBasePath, const uint8_t imageRendering = 0, + std::vector tocAnchors = {}, const std::function& progressFn = nullptr, + const CssParser* cssParser = nullptr) : epub(epub), renderer(renderer), @@ -169,6 +168,7 @@ class ChapterHtmlSlimParser final : public Print { viewportWidth(viewportWidth), viewportHeight(viewportHeight), hyphenationEnabled(hyphenationEnabled), + bionicReadingEnabled(bionicReadingEnabled), completePageFn(completePageFn), progressFn(progressFn), cssParser(cssParser), diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 4f1cd376..4daddaf5 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -249,6 +249,8 @@ class CrossPointSettings { uint8_t imageDithering = IMAGE_DITHER_BAYER; // Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled) uint8_t syntheticTocFallback = 1; + // Default bionic reading in EPUB pages when no per-book override is set (1 = enabled, 0 = disabled) + uint8_t bionicReading = 0; // Automatically push reading progress to the KOReader sync server when leaving the reader // (1 = enabled, 0 = disabled). The push only fires when credentials are configured and the // reader session advanced at least 3 pages, and is skipped when remote progress is already ahead. diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d0f586b4..d743c016 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -456,7 +456,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) book.fontFamilyOverride = clampInt8(obj["fontFamilyOverride"] | -1, -1, CrossPointSettings::FONT_FAMILY_COUNT - 1, -1); book.fontSizeOverride = clampInt8(obj["fontSizeOverride"] | -1, -1, CrossPointSettings::FONT_SIZE_COUNT - 1, -1); - book.bionicReadingOverride = obj["bionicReadingOverride"] | false; + book.bionicReadingOverride = clampInt8(obj["bionicReadingOverride"] | -1, -1, 1, -1); store.recentBooks.push_back(book); } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 5891234c..ab21fba0 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -26,7 +26,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title int8_t imageRenderingOverride = -1; int8_t fontFamilyOverride = -1; int8_t fontSizeOverride = -1; - bool bionicReadingOverride = false; + int8_t bionicReadingOverride = -1; // Remove existing entry if present auto it = diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index aa381153..53652c42 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -17,7 +17,8 @@ struct RecentBook { int8_t fontFamilyOverride = -1; // -1 = use global setting, otherwise CrossPointSettings::FONT_SIZE value. int8_t fontSizeOverride = -1; - bool bionicReadingOverride = false; + // -1 = use global default, otherwise explicit per-book override (0 = off, 1 = on). + int8_t bionicReadingOverride = -1; bool operator==(const RecentBook& other) const { return path == other.path; } }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 1f7149ec..f57fe0f6 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -117,6 +117,8 @@ inline const std::vector list = { StrId::STR_CAT_READER), SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", StrId::STR_CAT_READER), + SettingInfo::Toggle(StrId::STR_BIONIC_READING, &CrossPointSettings::bionicReading, "bionicReading", + StrId::STR_CAT_READER), SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER), diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 490dab4f..9e4e1744 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -191,7 +191,9 @@ void EpubReaderActivity::onEnter() { bookImageRenderingOverride = currentBook.imageRenderingOverride; bookFontFamilyOverride = currentBook.fontFamilyOverride; bookFontSizeOverride = currentBook.fontSizeOverride; - bookBionicReadingOverride = currentBook.bionicReadingOverride; + bookBionicReadingOverride = (currentBook.bionicReadingOverride >= 0) + ? static_cast(currentBook.bionicReadingOverride) + : static_cast(SETTINGS.bionicReading); logReaderMemSnapshot("onEnter_after_recent_books"); // Trigger first update @@ -1254,7 +1256,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (!section->loadSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { + viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, bookBionicReadingOverride, + imageRendering)) { LOG_DBG("ERS", "Cache not found, building..."); lastRenderStats.cacheRebuilt = true; @@ -1275,8 +1278,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderer.clearSdCardFontAccumulation(); if (!section->createSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering, - progressFn)) { + viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, + bookBionicReadingOverride, imageRendering, progressFn)) { LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); return; @@ -1430,7 +1433,8 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW Section nextSection(epub, nextSpineIndex, renderer); if (nextSection.loadSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { + viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, bookBionicReadingOverride, + imageRendering)) { return; } @@ -1439,7 +1443,8 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW renderer.clearSdCardFontAccumulation(); if (!nextSection.createSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { + viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, + bookBionicReadingOverride, imageRendering)) { LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); } } @@ -1822,12 +1827,13 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf if (!section->loadSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), effectiveLineCompression, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering)) { + static_cast(SETTINGS.bionicReading), SETTINGS.imageRendering)) { LOG_DBG("SLP", "EPUB: section cache not found for spine %d, rebuilding", spineIndex); if (!section->createSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), effectiveLineCompression, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, - SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { + SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + static_cast(SETTINGS.bionicReading), SETTINGS.imageRendering)) { LOG_ERR("SLP", "EPUB: failed to rebuild section cache for spine %d", spineIndex); return false; } From b6c90fdac63843b77388ec2df891862d43e60179 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 12:12:28 +0200 Subject: [PATCH 4/6] Add constants Co-authored-by: Copilot --- lib/Epub/Epub/ParsedText.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 283ddafc..f9de5e18 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -123,6 +123,17 @@ std::string buildLinePreview(const std::vector& words, const std::v return preview; } +constexpr int kBionicReadingMinCodepoints = 4; +constexpr int kBionicReadingMinBoldPrefix = 1; +constexpr int kBionicReadingBoldPrefixNumerator = 1; +constexpr int kBionicReadingBoldPrefixDenominator = 2; + +static int computeBionicBoldPrefixCount(const int codepointCount) { + return std::max(kBionicReadingMinBoldPrefix, + (codepointCount * kBionicReadingBoldPrefixNumerator + kBionicReadingBoldPrefixDenominator - 1) / + kBionicReadingBoldPrefixDenominator); +} + } // namespace void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, @@ -532,14 +543,14 @@ void ParsedText::applyBionicReadingTransform() { codepointCount++; } - if (codepointCount <= 3) { + if (codepointCount < kBionicReadingMinCodepoints) { transformedWords.push_back(word); transformedStyles.push_back(originalStyle); transformedContinues.push_back(attachToPrevious); continue; } - const int boldPrefixCount = std::max(1, (codepointCount + 1) / 2); + const int boldPrefixCount = computeBionicBoldPrefixCount(codepointCount); ptr = reinterpret_cast(word.c_str()); const unsigned char* prefixEnd = ptr; for (int j = 0; j < boldPrefixCount && *prefixEnd; ++j) { From f0218c194dd1684fa78839d7284832e668103d8d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 12:19:27 +0200 Subject: [PATCH 5/6] Review changes --- src/CrossPointSettings.h | 2 +- src/RecentBooksStore.cpp | 23 ++++++++++++++++++++ src/activities/reader/EpubReaderActivity.cpp | 7 ++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 4daddaf5..d970db53 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -286,8 +286,8 @@ class CrossPointSettings { BTN_PREV_SECTION, BTN_EXIT_READER, BTN_READER_MENU, - BTN_TOGGLE_BIONIC_READING, BTN_KOREADER_SYNC, + BTN_TOGGLE_BIONIC_READING, BUTTON_ACTION_COUNT }; diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index ab21fba0..7dc1d2e0 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -85,6 +85,29 @@ RecentBook RecentBooksStore::getBookByPath(const std::string& path) const { return RecentBook{}; } +bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, + const int8_t imageRenderingOverride) { + auto it = + std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); + if (it == recentBooks.end()) { + return false; + } + return setReaderOverrides(path, embeddedStyleOverride, imageRenderingOverride, it->fontFamilyOverride, + it->fontSizeOverride, it->bionicReadingOverride); +} + +bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, + const int8_t imageRenderingOverride, const int8_t fontFamilyOverride, + const int8_t fontSizeOverride) { + auto it = + std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); + if (it == recentBooks.end()) { + return false; + } + return setReaderOverrides(path, embeddedStyleOverride, imageRenderingOverride, fontFamilyOverride, fontSizeOverride, + it->bionicReadingOverride); +} + bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride, const int8_t imageRenderingOverride, const bool bionicReadingOverride) { auto it = diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 9e4e1744..bc08abbf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2007,6 +2007,13 @@ void EpubReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION openReaderMenu(); } break; + case BA::BTN_TOGGLE_BIONIC_READING: + if (epub) { + applyBookReaderOverrides(bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride, + bookFontSizeOverride, !bookBionicReadingOverride); + requestUpdate(); + } + break; case BA::BTN_KOREADER_SYNC: launchKOReaderSync(SyncLaunchMode::COMPARE); break; From 0dda02cafc2f1500210bffa0db8c9e273931c3d2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 30 Apr 2026 12:53:03 +0200 Subject: [PATCH 6/6] Proper word recognition Co-authored-by: Copilot --- lib/Epub/Epub/ParsedText.cpp | 141 ++++++++++++++++++++++++++--------- 1 file changed, 106 insertions(+), 35 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f9de5e18..aa033586 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -11,6 +11,7 @@ #include #include +#include "hyphenation/HyphenationCommon.h" #include "hyphenation/Hyphenator.h" constexpr int MAX_COST = std::numeric_limits::max(); @@ -128,12 +129,66 @@ constexpr int kBionicReadingMinBoldPrefix = 1; constexpr int kBionicReadingBoldPrefixNumerator = 1; constexpr int kBionicReadingBoldPrefixDenominator = 2; +struct TokenSpan { + size_t start; + size_t end; + bool isWord; +}; + static int computeBionicBoldPrefixCount(const int codepointCount) { return std::max(kBionicReadingMinBoldPrefix, (codepointCount * kBionicReadingBoldPrefixNumerator + kBionicReadingBoldPrefixDenominator - 1) / kBionicReadingBoldPrefixDenominator); } +static bool isBionicWordCodepoint(const uint32_t cp) { + if (cp == 0) { + return false; + } + if (utf8IsCombiningMark(cp)) { + return true; + } + return isAlphabetic(cp) || isAsciiDigit(cp) || isApostrophe(cp); +} + +// Split a word token into contiguous spans of "word-like" characters and non-word characters. +// This avoids applying bionic bolding to punctuation, digits-only runs, or other separators. +// Only spans marked as word-like are eligible for the bionic prefix transform. +static std::vector tokenizeBionicWord(const std::string& word) { + std::vector spans; + spans.reserve(2); + + const unsigned char* base = reinterpret_cast(word.c_str()); + const unsigned char* ptr = base; + const unsigned char* segmentStart = ptr; + bool currentIsWord = false; + bool haveCurrent = false; + + while (true) { + const unsigned char* cpStart = ptr; + uint32_t cp = utf8NextCodepoint(&ptr); + if (cp == 0) { + break; + } + + bool cpIsWord = isBionicWordCodepoint(cp); + if (!haveCurrent) { + currentIsWord = cpIsWord; + haveCurrent = true; + } else if (!utf8IsCombiningMark(cp) && cpIsWord != currentIsWord) { + spans.push_back({static_cast(segmentStart - base), static_cast(cpStart - base), currentIsWord}); + segmentStart = cpStart; + currentIsWord = cpIsWord; + } + } + + if (haveCurrent) { + spans.push_back({static_cast(segmentStart - base), word.size(), currentIsWord}); + } + + return spans; +} + } // namespace void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, @@ -533,49 +588,65 @@ void ParsedText::applyBionicReadingTransform() { transformedContinues.reserve(wordContinues.size() * 2); for (size_t i = 0; i < words.size(); ++i) { - const std::string& word = words[i]; + std::string source = std::move(words[i]); const auto originalStyle = wordStyles[i]; - const bool attachToPrevious = wordContinues[i]; + const bool originalAttachToPrevious = wordContinues[i]; + const char* raw = source.c_str(); - const unsigned char* ptr = reinterpret_cast(word.c_str()); - int codepointCount = 0; - while (utf8NextCodepoint(&ptr)) { - codepointCount++; - } - - if (codepointCount < kBionicReadingMinCodepoints) { - transformedWords.push_back(word); - transformedStyles.push_back(originalStyle); - transformedContinues.push_back(attachToPrevious); + const auto spans = tokenizeBionicWord(source); + if (spans.empty()) { continue; } - const int boldPrefixCount = computeBionicBoldPrefixCount(codepointCount); - ptr = reinterpret_cast(word.c_str()); - const unsigned char* prefixEnd = ptr; - for (int j = 0; j < boldPrefixCount && *prefixEnd; ++j) { - utf8NextCodepoint(&prefixEnd); - } - const size_t prefixByteCount = - static_cast(prefixEnd - reinterpret_cast(word.c_str())); - if (prefixByteCount >= word.size()) { - transformedWords.push_back(word); + bool attachToPrevious = originalAttachToPrevious; + for (size_t spanIndex = 0; spanIndex < spans.size(); ++spanIndex) { + const TokenSpan span = spans[spanIndex]; + const size_t spanLength = span.end - span.start; + std::string token; + if (spans.size() == 1 && spanIndex == 0) { + token = std::move(source); + } else { + token.assign(raw + span.start, spanLength); + } + + if (span.isWord) { + const unsigned char* ptr = reinterpret_cast(token.c_str()); + int codepointCount = 0; + while (utf8NextCodepoint(&ptr)) { + codepointCount++; + } + + if (codepointCount >= kBionicReadingMinCodepoints) { + const int boldPrefixCount = computeBionicBoldPrefixCount(codepointCount); + ptr = reinterpret_cast(token.c_str()); + const unsigned char* prefixEnd = ptr; + for (int j = 0; j < boldPrefixCount && *prefixEnd; ++j) { + utf8NextCodepoint(&prefixEnd); + } + const size_t prefixByteCount = + static_cast(prefixEnd - reinterpret_cast(token.c_str())); + if (prefixByteCount < token.size()) { + std::string suffix(reinterpret_cast(prefixEnd), token.size() - prefixByteCount); + token.resize(prefixByteCount); + const auto boldStyle = static_cast(originalStyle | EpdFontFamily::BOLD); + transformedWords.push_back(std::move(token)); + transformedStyles.push_back(boldStyle); + transformedContinues.push_back(attachToPrevious); + + transformedWords.push_back(std::move(suffix)); + transformedStyles.push_back(originalStyle); + transformedContinues.push_back(true); + attachToPrevious = true; + continue; + } + } + } + + transformedWords.push_back(std::move(token)); transformedStyles.push_back(originalStyle); transformedContinues.push_back(attachToPrevious); - continue; + attachToPrevious = true; } - - const std::string prefix(word.data(), prefixByteCount); - const std::string suffix(word.data() + prefixByteCount, word.size() - prefixByteCount); - const auto boldStyle = static_cast(originalStyle | EpdFontFamily::BOLD); - - transformedWords.push_back(prefix); - transformedStyles.push_back(boldStyle); - transformedContinues.push_back(attachToPrevious); - - transformedWords.push_back(suffix); - transformedStyles.push_back(originalStyle); - transformedContinues.push_back(true); } words = std::move(transformedWords);