From 6c048518f21b4d0da530cce7af60a762c236e1f4 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Fri, 26 Jun 2026 18:09:06 -0700 Subject: [PATCH 01/91] feat: add in-book search for epubs --- docs/file-formats.md | 27 +- docs/search-architecture.md | 397 ++++++++++++++++++ lib/Epub/Epub/AsciiCase.h | 15 + lib/Epub/Epub/Page.cpp | 62 +++ lib/Epub/Epub/Page.h | 4 + lib/Epub/Epub/Section.cpp | 334 ++++++++++++++- lib/Epub/Epub/Section.h | 91 +++- lib/Epub/Epub/css/CssParser.cpp | 6 +- lib/I18n/translations/belarusian.yaml | 5 + lib/I18n/translations/catalan.yaml | 4 + lib/I18n/translations/czech.yaml | 5 + lib/I18n/translations/danish.yaml | 5 + lib/I18n/translations/dutch.yaml | 5 + lib/I18n/translations/english.yaml | 4 + lib/I18n/translations/finnish.yaml | 5 + lib/I18n/translations/french.yaml | 5 + lib/I18n/translations/german.yaml | 4 + lib/I18n/translations/hebrew.yaml | 4 + lib/I18n/translations/hungarian.yaml | 5 + lib/I18n/translations/italian.yaml | 4 + lib/I18n/translations/kazakh.yaml | 5 + lib/I18n/translations/lithuanian.yaml | 5 + lib/I18n/translations/polish.yaml | 5 + lib/I18n/translations/portuguese.yaml | 5 + lib/I18n/translations/romanian.yaml | 5 + lib/I18n/translations/russian.yaml | 4 + lib/I18n/translations/slovak.yaml | 4 + lib/I18n/translations/slovenian.yaml | 5 + lib/I18n/translations/spanish.yaml | 4 + lib/I18n/translations/swedish.yaml | 4 + lib/I18n/translations/turkish.yaml | 5 + lib/I18n/translations/ukrainian.yaml | 4 + lib/I18n/translations/valencian.yaml | 4 + lib/I18n/translations/vietnamese.yaml | 5 + src/activities/reader/EpubReaderActivity.cpp | 215 ++++++++-- src/activities/reader/EpubReaderActivity.h | 13 + .../reader/EpubReaderMenuActivity.cpp | 4 +- .../reader/EpubReaderMenuActivity.h | 1 + .../reader/EpubReaderSearchActivity.cpp | 348 +++++++++++++++ .../reader/EpubReaderSearchActivity.h | 96 +++++ src/activities/reader/ReaderUtils.h | 22 + src/activities/util/KeyboardEntryActivity.cpp | 10 +- 42 files changed, 1706 insertions(+), 58 deletions(-) create mode 100644 docs/search-architecture.md create mode 100644 lib/Epub/Epub/AsciiCase.h create mode 100644 src/activities/reader/EpubReaderSearchActivity.cpp create mode 100644 src/activities/reader/EpubReaderSearchActivity.h diff --git a/docs/file-formats.md b/docs/file-formats.md index 2661cd1d6e..d638017a1f 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -229,19 +229,20 @@ Binary layout: ## `section.bin` -### Version 41 +### Version 42 Each file in `sections/*.bin` stores one laid-out spine section. The header is also the cache-busting key: if any layout-affecting setting differs from the current reader settings, the section is discarded and rebuilt. -Version 41 includes: +Version 42 includes: - cache-busting fields for font, line compression, extra paragraph spacing, forced paragraph indents, paragraph alignment, viewport size, hyphenation, embedded CSS, image rendering mode, Bionic Reading, Guide Dots, and EPUB render mode -- page offset LUT +- paired page/search-text offset LUT +- compact per-page text records used by bounded-memory in-book search - anchor-to-page map for fragment and footnote navigation - paragraph and list-item LUTs used by KOReader sync page refinement - optional per-word Bionic Reading split metadata @@ -252,6 +253,9 @@ Version 41 includes: - per-page footnote entries - per-page publisher page markers +See [In-Book Search Architecture](./search-architecture.md) for the +memory and SD-space trade-offs behind the search records. + ImHex pattern: ```c++ @@ -259,7 +263,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 41 +#define EXPECTED_VERSION 42 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 @@ -426,6 +430,17 @@ struct Page { PublisherPageMarker publisherPageMarkers[publisherPageMarkerCount]; }; +struct PageRecord { + Page page [[inline]]; + u32 searchTextLength; + char searchText[searchTextLength] [[comment("Rendered words joined by ASCII spaces")]]; +}; + +struct PageLutEntry { + u32 pageOffset [[comment("Serialized Page offset")]]; + u32 searchTextOffset [[comment("searchTextLength field offset")]]; +}; + struct AnchorEntry { String anchor; u16 page; @@ -467,14 +482,14 @@ struct SectionBin { u32 paragraphLutOffset; u32 listItemLutOffset; - Page pages[pageCount]; + PageRecord pages[pageCount]; u32 currentOffset = $; if (currentOffset != pageLutOffset) { std::warning(std::format("Page LUT offset mismatch: expected 0x{:X}, got 0x{:X}", pageLutOffset, currentOffset)); } - u32 pageLut[pageCount] [[comment("Page data offsets")]]; + PageLutEntry pageLut[pageCount] [[comment("Page and search-text offsets")]]; if (anchorMapOffset != 0) { AnchorMap anchorMap @ anchorMapOffset; diff --git a/docs/search-architecture.md b/docs/search-architecture.md new file mode 100644 index 0000000000..865a84efbc --- /dev/null +++ b/docs/search-architecture.md @@ -0,0 +1,397 @@ +# In-Book Search Architecture + +In-book search is implemented for EPUBs as a forward scan over compact text +records stored beside the rendered pages in each section cache. It deliberately +does not build a whole-book index in RAM. This design keeps the steady search +path bounded on the ESP32-C3 while preserving an exact `(spine, page)` target +for reader navigation. + +## Goals and constraints + +The implementation is optimized for the constraints shared by the X3 and X4: + +- about 380 KB of usable RAM and no PSRAM +- one statically allocated monochrome framebuffer, sized for the larger X3 + buffer: 52,272 bytes for 792 × 528, compared with 48,000 bytes for the X4's + 800 × 480 panel +- a single-core ESP32-C3 +- SD storage that is much larger than RAM, but slower and subject to write wear +- EPUB content that is laid out lazily, one spine section at a time + +X3 support uses the same firmware image rather than a separate search build. +`HalGPIO` detects the device before `HalDisplay` initializes the panel, and +`GfxRenderer` then imports the runtime width, height, row width, and buffer size. +Search UI geometry comes from `UITheme` and those runtime renderer dimensions; +the keyboard and button hints already contain X3-specific spacing. Search input +uses `MappedInputManager`, so it follows the configured logical front-button +mapping on both devices. + +The rendered viewport width and height are part of the section-cache validation +key. Consequently, a cache laid out for the X4 is rejected and rebuilt when the +same SD card and book are opened on an X3, and vice versa. This prevents the +search result's page number from being calculated against the other panel's +pagination. + +The user-visible behavior is intentionally narrow: + +- queries are limited to 64 UTF-8 bytes +- search starts at the current rendered page and moves forward +- after reaching the end of the spine, it continues through later spines +- after reaching the end of the book, it wraps once and stops at the page the + search was initiated from +- the first matching page is returned immediately +- repeating the same query from the page returned by search starts at the next + page, and the wrap stops before that originating page, so "find next" advances + to a different match or reports no matches rather than re-returning it +- while searching, the status screen shows an approximate percentage of how much + of the scan has completed, measured from where the search began (so it rises + from 0% to 100% over the whole scan even when the search starts mid-book) + +The result is page-granular. The reader opens the matching page and shows a +short confirmation popup; individual glyphs are not highlighted. + +## Component flow + +```mermaid +flowchart TD + A["Reader menu: Search"] --> B["KeyboardEntryActivity"] + B --> C["EpubReaderActivity validates a <=64-byte query"] + C --> D["Save position and release current Section/page graph"] + D --> E["EpubReaderSearchActivity"] + E --> F{"Section cache valid?"} + F -->|"No"| G["Lay out the section and write version 42 cache"] + F -->|"Yes"| H["Read page search record"] + G --> H + H --> I{"KMP match?"} + I -->|"No"| J["Advance one page; then spine; wrap once"] + J --> F + I -->|"Yes"| K["Return ProgressChangeResult (spine, page)"] + K --> L["Reader reloads that page from SD cache"] +``` + +The responsibilities are split as follows: + +- `EpubReaderMenuActivity` exposes the existing translated `Search` command. +- `EpubReaderActivity` owns query history and coordinates the keyboard, search + activity, reader position, and result popup. +- `EpubReaderSearchActivity` is a small state machine that scans one page per + main-loop iteration and distinguishes `Searching`, `NotFound`, and `Error`. +- `Page::serializeSearchText()` writes compact searchable text while the page + already exists during layout. +- `Section::pageContainsText()` searches one record without deserializing a + `Page` or allocating word vectors. + +All SD access continues through `HalStorage` and `HalFile`; search does not +reach into SdFat directly. + +Implementation entry points: + +- reader orchestration: [`EpubReaderActivity.cpp`](../../src/activities/reader/EpubReaderActivity.cpp) +- cooperative scan activity: [`EpubReaderSearchActivity.cpp`](../../src/activities/reader/EpubReaderSearchActivity.cpp) +- cache creation and streaming matcher: [`Section.cpp`](../../lib/Epub/Epub/Section.cpp) +- per-page text serialization: [`Page.cpp`](../../lib/Epub/Epub/Page.cpp) + +## Section cache format + +The search capability is fully integrated into the version 42 section cache format. Each serialized page is immediately followed by one search record: + +```text +Page +u32 searchTextLength +u8 searchText[searchTextLength] +``` + +The page LUT stores four offsets/indices per page: + +```text +u32 pageOffset +u32 searchTextOffset +u16 paragraphIndex +u16 listItemIndex +``` + +`pageOffset` preserves normal rendering behavior. `searchTextOffset` lets the +matcher seek directly to the bounded text record without decoding page +elements, images, footnotes, styles, or word-position vectors. + +The text record contains the rendered page's words in page-element order, +joined by single ASCII spaces. Images and styling metadata are excluded. The +SD cost is therefore approximately one additional copy of rendered UTF-8 text, +plus 8 bytes per page for the search text length and its LUT offset. + +Additionally, version 42 stores all layout settings (including fonts, line compression, extra paragraph spacing, forced indents, paragraph alignment, bionic reading, guide reading, and the selected `EpubRenderMode`) in the section header. This ensures that the search-time layout accurately matches the reader's layout settings, preventing pagination misalignment. Version 42 invalidates older section caches automatically; they are rebuilt on demand using the normal cache-busting path. The book's EPUB source is never modified. + +## Memory budget + +The steady page-scan path has fixed memory use: + +| Item | Storage | Size | Lifetime | +| --- | --- | ---: | --- | +| Saved query | Inline reader/activity arrays | 65 bytes each | Reader/search activity | +| Compiled query (normalized pattern + KMP table) | Inline search activity arrays | 132 bytes | Search activity (built once) | +| SD read buffer | Stack | 64 bytes | One page scan | +| Search activity object | Heap, nothrow | 444 bytes in the target build | Search activity | +| Page LUT reservation | Heap | 12,288 bytes | Uncached section layout only | + +The display's 52,272-byte framebuffer is not a search allocation. The shared +firmware reserves that X3-sized array statically even while running on an X4, +so entering search does not create a panel-sized heap allocation or change the +framebuffer footprint. Search uses the existing black-and-white framebuffer and +does not request a grayscale scratch buffer. + +The search activity owns one reusable `Section`. `Section::resetForSpine()` +changes its spine and cache path in place, avoiding a new/delete cycle for each +chapter. Before the activity is allocated, the reader releases its current +`Section` and deserialized page graph. This avoids keeping the normal reader +working set and the search working set live together. + +The 12,288-byte LUT reservation is not a new steady-state index. Section layout +already needs a data-dependent page LUT; reserving 1,024 entries once avoids +repeated allocate-copy-free growth. Chapters larger than that remain supported +and may grow the vector. + +At implementation time, the measured `default` build had unchanged static RAM +usage at 101,220 bytes. Flash usage increased by 8,174 bytes, from 5,225,869 to +5,234,043 bytes, for the search behavior, cache handling, UI, and translated +fallback strings. These are build snapshots rather than permanent budgets; +remeasure them when the implementation or toolchain changes. + +## Matching algorithm + +`Section::pageContainsText()` uses Knuth-Morris-Pratt matching because it: + +- scans the SD record once +- handles matches that cross 64-byte read-buffer boundaries +- handles overlapping prefixes without rewinding the file +- needs only a prefix table bounded by the 64-byte query limit + +ASCII `A-Z` bytes are folded to lowercase during comparison without copying the +query or page. Other UTF-8 bytes are compared exactly. Rendered EPUB words are +already NFC-composed by the layout pipeline, but the search path does not +perform general Unicode normalization or case folding. + +ASCII spaces and hyphens are treated as insignificant on both sides: +`normalizeSearchQuery()` drops them from the query (and the KMP prefix table is +built over that normalized form), and the page scan skips the same bytes in the +record. This lets a query match across the artifacts the rendered text +introduces — most importantly a word the layout split across a line break, +which is stored as `"-"` plus a space plus `""` — and makes spacing +differences between the query and the rendered tokens irrelevant. The +consequence is that matching is space- and hyphen-agnostic: `"the cat"`, +`"thecat"`, and `"the-cat"` are equivalent, which can occasionally match across +an unrelated word boundary. This is a deliberate extension of the matcher's +existing substring behavior (a query already matches inside a longer word) and +favors finding a half-remembered passage — e.g. a location last read on another +device or in print — over exact-span precision. + +The matcher's KMP partial-match length is carried across consecutive pages of +the same spine, so a query split across a *page* boundary still matches — for +example a word the layout hyphenated at the foot of one page (`"…inter-"`) and +continued at the top of the next (`"national…"`), or any phrase that straddles +the break. The carried state is reset at every reading-order discontinuity (the +scan's first page, a spine/chapter change, the single wrap, and any image-only +page with an empty text record), so it never bridges non-contiguous text. A +cross-page match is reported on the page where it *completes* (the second page), +which is where the reader opens. + +The return type is `std::optional`: + +- `true`: the page contains the query +- `false`: the cache record is valid and does not contain the query +- `std::nullopt`: invalid input, I/O failure, or corrupt/truncated cache data + +This distinction lets an ordinary miss advance to the next page while a cache +failure moves the activity to its translated error state. +On the first cache failure in a spine, the activity closes and removes that +section cache, rebuilds it, restores the matcher state from the start of the +failed page, and retries once. A second failure removes the cache again and +surfaces the error rather than entering an unbounded rebuild loop. + +### Alternatives considered + +The decisive constraints are the streaming read model and the RAM budget, not +raw matching speed. The page text is read from SD byte by byte regardless, so +the scan is I/O-bound: an algorithm that skips *comparisons* does not skip +*reads*, which is where the time goes. Each alternative was rejected against +those constraints rather than against asymptotic complexity. + +- **Naive / sliding window.** Correct in practice for short page text, but it + needs an overlap buffer to span 64-byte read boundaries and can rewind on a + mismatch (worst case O(n·m)). KMP gives the same streaming behavior with a + guaranteed linear bound and no rewind for no extra cost. +- **Boyer–Moore / Horspool.** Sublinear by skipping ahead on mismatches, but it + skips comparisons, not SD reads, so the I/O cost — the actual bottleneck — is + unchanged. Its bad-character table is 256 bytes, which is the entire + documented local-data budget on its own, and right-to-left window scanning + with variable forward jumps fits poorly with a 64-byte streaming chunk reader. + More RAM and complexity for no I/O win. +- **Rabin–Karp.** A rolling hash is also single-pass, tiny-memory, and handles + chunk boundaries, but it adds a collision-verification fallback for no + advantage over KMP's deterministic O(n). +- **`std::search` / `std::boyer_moore_searcher`.** Both want random-access + iterators over the full text, so the whole record would have to be buffered in + RAM, defeating the streaming design; the searcher templates also add binary + size. + +KMP wins because it is the cleanest single-pass, no-rewind matcher whose only +state is a prefix table bounded by the 64-byte query limit. + +## Why this design + +### Whole-book in-memory index + +Rejected because its size scales with book length. Even a compact term table +would need dynamic storage for tokens and page postings, increasing both peak +RAM and largest-free-block pressure. It would also compete with the framebuffer, +EPUB parser, fonts, and current page graph. + +### Deserializing every cached page + +Rejected because `Page::deserialize()` reconstructs page elements, `TextBlock` +objects, word strings, and multiple vectors. Repeating those allocations for +every page would be slow and would fragment the heap even if only one page were +live at a time. + +### Searching raw XHTML in the EPUB + +Rejected because a raw byte match does not correspond reliably to visible +reader text. Markup, entities, CSS-hidden content, token boundaries, and Unicode +composition can all differ from the rendered page. A raw XHTML offset also +does not provide the rendered page number needed for navigation. + +### Building a full sidecar index when a book opens + +Rejected because it would make every first open pay the CPU, battery, SD-write, +and latency cost even if search is never used. The chosen design adds search +records only as sections are laid out. A search that reaches an uncached section +uses the existing layout path, then reuses that cache for later reading and +searching. + +### Keeping a vector of every result + +Rejected because result count is unbounded. Returning the first matching page +keeps memory constant. The saved query provides a simple "find next page" +interaction through the same menu command. + +### Background search task + +Rejected for the initial implementation. The device is single-core, SdFat +access must remain serialized, and a task would add stack and activity-lifetime +coordination. The cooperative activity scans one cached page per loop iteration, +keeps cancellation responsive between pages, and prevents automatic sleep while +searching. + +## Accepted trade-offs and limitations + +- A cold search can be slow. Reaching an uncached spine requires normal EPUB + layout and may write images and section data before scanning can continue. +- Cancellation is handled between page scans. An individual uncached-section + layout remains a blocking unit of work. +- The cache uses more SD space: roughly the rendered text size plus 8 bytes per + page. +- Matches are page-level. Repeating a query skips the rest of the current page, + so multiple occurrences on one page are not individually navigable. +- There is no match highlighting or result list. +- Case-insensitive matching is ASCII-only. Non-ASCII case variants must match + exactly. +- Search text is reconstructed from rendered word tokens with single spaces, so + it can differ from the EPUB source in spacing and in words split by layout-time + hyphenation. Matching ignores ASCII spaces and hyphens and carries match state + across adjacent same-spine pages to absorb these — including hyphenation and + phrases split across a page boundary (see Matching algorithm). Other + punctuation-glyph differences (curly vs straight quotes, em dash, the ellipsis + character vs three dots) are not normalized and can still cause a miss, and a + match split across a chapter (spine) boundary is not joined. +- Search results depend on the current layout settings. Font, viewport, + orientation, margins, paragraph settings, hyphenation, embedded CSS, image + mode, or Focus Reading changes can invalidate and rebuild section caches. +- The X3 display path runs at 16 MHz rather than the X4's 40 MHz SPI rate. + Search paints the status screen when entering, when changing state, and when + the progress percentage advances a whole repaint step, but it does not refresh + the e-ink panel for every page scanned; page matching remains an SD/CPU + operation. Progress is interpolated within the current spine so it advances + per page (not only at chapter boundaries), and the repaint step bounds total + e-ink refreshes regardless of how many spines the book has. + +These trade-offs favor stability and predictable RAM use over desktop-style +search features. + +## Verification + +Automated checks: + +```bash +pio run +pio check +``` + +Device testing should cover the core matrix on both X3 and X4: + +1. A match on the current page. +2. A match in a later cached and uncached spine. +3. Wraparound to an earlier spine. +4. A missing query and the `No matches found` state. +5. Repeating the same query to advance to the next matching page. +6. Cancellation during a warm-cache scan and after an uncached section build. +7. Portrait, inverted, and both landscape orientations. +8. Searches after changing a layout-affecting reader setting. +9. ASCII case differences and representative non-ASCII text. +10. On X3, confirm startup logs report `Hardware detect: X3` and a 52,272-byte + static framebuffer before testing search. +11. Move an SD card with an existing section cache between X3 and X4 and verify + that the first section load rebuilds the viewport-mismatched cache and later + searches reuse it. +12. A word hyphenated across a page break (and a phrase straddling a page break) + matches and opens the page where it completes; confirm a match is not joined + across a chapter (spine) boundary or across an image-only page. + +Use `python3 scripts/debugging_monitor.py` and watch `EPS`/`SCT` logs for cache +builds, I/O errors, or OOM reports. For heap validation, instrument device runs +with both free heap and largest-free-block readings before search, during an +uncached section build, after a match, and after returning to the reader. Free +heap alone is insufficient to detect fragmentation. + +## Possible future extensions + +- Store token/word offsets if exact same-page occurrences and highlighting are + worth the extra cache space and rendering complexity. +- Add compact Unicode case-fold support for languages available on the input + method, with an explicit flash budget. +- Make section layout cooperatively cancellable if cold-search latency becomes + a usability problem. +- Reduce per-page seeks during a warm scan. The invariant header state (file + size and page-LUT offset) is already cached once per section, so the per-page + cost is now two seek+read pairs (the page's LUT entry and its text record). + Because the text records and the LUT are written physically contiguously at + layout time, a forward whole-section scan could instead read the LUT once into + a small buffer and stream the text records sequentially, running the matcher + across page boundaries with page attribution. This targets SD seek latency, + the likely dominant cost, more directly than any change to the matching + algorithm. +- Store a source-faithful (de-hyphenated) search text. Matching already ignores + ASCII spaces and hyphens and carries state across adjacent same-spine pages + (see Matching algorithm), which absorbs layout-time hyphenation and spacing + differences — including across page boundaries — at no extra storage cost. The + remaining gap is *exact-spacing* search: because spaces and hyphens are + insignificant, the matcher cannot distinguish `"the cat"` from `"thecat"`, and + a query can occasionally match across an unrelated word boundary. Closing that + would require the record to store the actual source token stream with correct + join/no-join boundaries instead of the rendered tokens. The cost is the reason + this is deferred: + - The metadata needed (`ParsedText::wordContinues` / `wordNoSpaceBefore`, and + where `hyphenateWordAtIndex()` split a word) exists during layout but is + discarded at the `TextBlock` boundary — `Page::serializeSearchText()` only + sees the rendered tokens, with a visible `-` already pushed onto a + line-broken fragment, so it cannot tell `"well-"`+`"known"` (rejoin as + `well-known`) from `"inter-"`+`"national"` (rejoin as `international`). + - Fixing it means threading per-token join information from the line breaker + to the search-text writer — either by adding a per-word "joins previous + without space" flag to `TextBlock` (which **bumps the section cache version + and rebuilds all caches**) or by surfacing per-page continuation flags + through the page-emit callback. Either touches the layout pipeline, the most + performance- and stability-sensitive code in the project. + Defer until exact-spacing search is actually wanted; the current normalized + matching is the better trade for finding a half-remembered passage. +- Normalize punctuation and Unicode for cross-medium search. Even with + space/hyphen folding, curly vs straight quotes, em dash vs hyphen, and NFD vs + NFC input can still cause a miss, and case folding is ASCII-only. diff --git a/lib/Epub/Epub/AsciiCase.h b/lib/Epub/Epub/AsciiCase.h new file mode 100644 index 0000000000..e0c9ab0a85 --- /dev/null +++ b/lib/Epub/Epub/AsciiCase.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace epub { + +// ASCII-only lowercase fold: 'A'-'Z' -> 'a'-'z'; every other byte (including +// multi-byte UTF-8 sequences) is returned unchanged. constexpr so it stays in +// flash and folds at compile time. Shared by the CSS keyword matcher and the +// in-book search matcher so the logic has a single definition. +constexpr uint8_t asciiToLower(const uint8_t value) { + return (value >= 'A' && value <= 'Z') ? static_cast(value + ('a' - 'A')) : value; +} + +} // namespace epub diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 365d4bda93..cb2f01ae08 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -419,6 +419,68 @@ bool Page::serialize(FsFile& file) const { return true; } +bool Page::serializeSearchText(FsFile& file) const { + // Single pass: write a placeholder length, stream the words while counting + // the bytes emitted, then seek back and back-patch the real length. Keeping + // one walk of the elements/words means the recorded length can never diverge + // from the bytes actually written (the file is O_RDWR and seekable). A page + // cannot hold anywhere near 4 GB of text, so a uint32 byte count cannot wrap. + const uint32_t lengthPos = file.position(); + uint32_t textLength = 0; + if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { + LOG_ERR("PGE", "Failed to write search text length"); + return false; + } + + bool hasWord = false; + static constexpr uint8_t WORD_SEPARATOR = ' '; + for (const auto& element : elements) { + if (element->getTag() != TAG_PageLine) { + continue; + } + + const auto& line = static_cast(*element); + if (!line.getBlock()) { + continue; + } + + for (const auto& word : line.getBlock()->getWords()) { + if (hasWord) { + if (file.write(&WORD_SEPARATOR, sizeof(WORD_SEPARATOR)) != sizeof(WORD_SEPARATOR)) { + LOG_ERR("PGE", "Failed to write search text separator"); + return false; + } + ++textLength; + } + if (!word.empty()) { + if (file.write(reinterpret_cast(word.data()), word.size()) != word.size()) { + LOG_ERR("PGE", "Failed to write search text word"); + return false; + } + textLength += static_cast(word.size()); + } + hasWord = true; + } + } + + const uint32_t endPos = file.position(); + if (!file.seek(lengthPos)) { + LOG_ERR("PGE", "Failed to seek for search text length back-patch"); + return false; + } + if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { + LOG_ERR("PGE", "Failed to back-patch search text length"); + return false; + } + // Restore the write position to the record end so the next page appends + // correctly; failing this would corrupt the following page's data. + if (!file.seek(endPos)) { + LOG_ERR("PGE", "Failed to restore position after search text back-patch"); + return false; + } + return true; +} + std::unique_ptr Page::deserialize(FsFile& file) { auto* rawPage = new (std::nothrow) Page(); if (!rawPage) { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index ee6b53a4b0..65b77d2083 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -164,6 +164,10 @@ class Page { void renderText(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) const; void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; bool serialize(FsFile& file) const; + // Persist a compact, display-order text record for bounded-memory page search. + // The record is kept separate from Page::serialize() so searching never has + // to reconstruct TextBlock vectors on the heap. + bool serializeSearchText(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); // Check if page contains any images (used to force full refresh) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index e2a522fa99..ed51c01b7a 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -7,6 +7,12 @@ #include #include +#include +#include +#include +#include + +#include "AsciiCase.h" #include "Epub/css/CssParser.h" #include "Page.h" #include "hyphenation/Hyphenator.h" @@ -14,8 +20,8 @@ namespace { constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" -// v41: busts public v40 caches for updated text/layout metadata in this release. -constexpr uint8_t SECTION_FILE_VERSION = 41; +// v42: page LUT entries include offsets to compact text records used by search. +constexpr uint8_t SECTION_FILE_VERSION = 42; constexpr uint16_t INITIAL_SECTION_PAGE_LUT_ENTRIES = 1024; constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + @@ -25,10 +31,10 @@ constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + struct PageLutEntry { uint32_t fileOffset; + uint32_t searchTextOffset; uint16_t paragraphIndex; uint16_t listItemIndex; }; - bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCapacity, const uint16_t lutCount) { if (lutCount < lutCapacity) return true; if (lutCapacity == UINT16_MAX) return false; @@ -48,9 +54,25 @@ bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCa lutCapacity = static_cast(nextCapacity); return true; } + +static_assert(sizeof(PageLutEntry) == 12, "Unexpected PageLutEntry padding changes the transient RAM budget"); + +// On-disk page LUT stride: only pageOffset and searchTextOffset are stored +// inline; paragraphIndex and listItemIndex are written to separate LUTs. +constexpr size_t PAGE_LUT_ENTRY_SIZE = sizeof(uint32_t) * 2; +// Bind the stride to the two inline offset fields so adding or resizing an +// inline LUT field can't silently desync it from the write/read sites. +static_assert(PAGE_LUT_ENTRY_SIZE == sizeof(PageLutEntry::fileOffset) + sizeof(PageLutEntry::searchTextOffset), + "On-disk page-LUT stride must match the inline offset fields"); + +// ASCII bytes treated as insignificant during search, dropped from both the +// query and the scanned record so layout-time hyphenation (a word split across +// a line break stores "-" + space + "") and spacing differences +// between the query and the rendered text do not block a match. +constexpr bool isSearchSeparator(const uint8_t b) { return b == ' ' || b == '-'; } } // namespace -uint32_t Section::onPageComplete(std::unique_ptr page) { +uint32_t Section::onPageComplete(std::unique_ptr page, uint32_t& searchTextOffset) { if (!file) { LOG_ERR("SCT", "File not open for writing page %d", pageCount); return 0; @@ -65,6 +87,11 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { LOG_ERR("SCT", "Failed to serialize page %d", pageCount); return 0; } + searchTextOffset = file.position(); + if (!page->serializeSearchText(file)) { + LOG_ERR("SCT", "Failed to serialize search text for page %d", pageCount); + return 0; + } LOG_DBG("SCT", "Page %d processed (pos=%lu, free=%u, maxAlloc=%u)", pageCount, static_cast(position), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); @@ -302,7 +329,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c Storage.remove(tmpSectionPath.c_str()); return false; } - // 1024 entries is 8 KB. Stack is too small, and std::vector growth in the page callback can abort on OOM. + // 1024 entries is 12 KB. Stack is too small, and std::vector growth in the page callback can abort on OOM. uint16_t lutCapacity = INITIAL_SECTION_PAGE_LUT_ENTRIES; auto lut = makeUniqueNoThrow(lutCapacity); if (!lut) { @@ -372,12 +399,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c pageCompletionFailed = true; return; } - const uint32_t fileOffset = this->onPageComplete(std::move(page)); + uint32_t searchTextOffset = 0; + const uint32_t fileOffset = this->onPageComplete(std::move(page), searchTextOffset); if (fileOffset == 0) { pageCompletionFailed = true; return; } - lut[lutCount++] = {fileOffset, paragraphIndex, listItemIndex}; + lut[lutCount++] = {fileOffset, searchTextOffset, paragraphIndex, listItemIndex}; }, embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser, renderMode, buildOptions.isPreview() ? std::string(buildOptions.previewAnchor) : std::string{}, buildOptions.previewMaxPages); @@ -412,7 +440,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c hasFailedLutRecords = true; break; } - if (!serialization::tryWritePod(file, lut[i].fileOffset)) { + if (!serialization::tryWritePod(file, lut[i].fileOffset) || + !serialization::tryWritePod(file, lut[i].searchTextOffset)) { hasFailedLutRecords = true; break; } @@ -499,22 +528,60 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return true; } +bool Section::readPageLutOffset(uint32_t& lutOffset) { + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { + return false; + } + return file.read(reinterpret_cast(&lutOffset), sizeof(lutOffset)) == sizeof(lutOffset); +} + std::unique_ptr Section::loadPageFromSectionFile() { if (!Storage.openFileForRead("SCT", filePath, file)) { return nullptr; } - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { + const uint32_t fileSize = file.size(); + if (fileSize < HEADER_SIZE) { + LOG_ERR("SCT", "Section cache header is truncated"); + file.close(); + return nullptr; + } + + uint32_t lutOffset = 0; + if (!readPageLutOffset(lutOffset)) { + LOG_ERR("SCT", "Failed to read page LUT offset"); file.close(); return nullptr; } - uint32_t lutOffset; - if (!serialization::tryReadPod(file, lutOffset) || !file.seek(lutOffset + sizeof(uint32_t) * currentPage)) { + + // Validate LUT-derived offsets against the file before trusting them (mirrors + // pageContainsText). Compute in 64-bit so a corrupt (huge) lutOffset cannot + // wrap the uint32 sum into a small in-bounds value. + if (lutOffset == 0 || currentPage < 0) { + LOG_ERR("SCT", "Invalid page LUT request"); file.close(); return nullptr; } - uint32_t pagePos; - if (!serialization::tryReadPod(file, pagePos) || !file.seek(pagePos)) { + const uint64_t entryOffset = static_cast(lutOffset) + + static_cast(PAGE_LUT_ENTRY_SIZE) * static_cast(currentPage); + if (entryOffset > fileSize || fileSize - entryOffset < PAGE_LUT_ENTRY_SIZE) { + LOG_ERR("SCT", "Invalid page LUT entry"); + file.close(); + return nullptr; + } + if (!file.seek(static_cast(entryOffset))) { + LOG_ERR("SCT", "Failed to seek to page LUT entry"); + file.close(); + return nullptr; + } + uint32_t pagePos = 0; + if (file.read(reinterpret_cast(&pagePos), sizeof(pagePos)) != sizeof(pagePos) || pagePos >= fileSize) { + LOG_ERR("SCT", "Failed to read page offset"); + file.close(); + return nullptr; + } + if (!file.seek(pagePos)) { + LOG_ERR("SCT", "Failed to seek to page record"); file.close(); return nullptr; } @@ -525,6 +592,247 @@ std::unique_ptr Section::loadPageFromSectionFile() { return page; } +void Section::rebuildFilePathForSpine() { + // Re-append the ".bin" suffix onto the cached prefix in place. + // snprintf into a stack buffer avoids std::to_string's heap temporary, and + // resize()+append() reuse filePath's reserved capacity (no reallocation). + char numBuf[12]; + const int len = snprintf(numBuf, sizeof(numBuf), "%d", spineIndex); + filePath.resize(sectionPathPrefixLen); + if (len > 0) { + filePath.append(numBuf, static_cast(len)); + } + if (!cacheSuffix.empty()) { + filePath.append(cacheSuffix); + } + filePath.append(".bin"); +} + +void Section::resetForSpine(const int newSpineIndex) { + if (file) { + // Member handle persists across calls, so close before switching paths. + file.close(); + } + spineIndex = newSpineIndex; + rebuildFilePathForSpine(); + pageCount = 0; + currentPage = 0; + searchHeaderReady = false; +} +bool Section::ensureSearchHeader() { + if (searchHeaderReady) { + return true; + } + + // Open the member file handle lazily on the first call. It stays open for + // all pages in this section; resetForSpine() closes it when advancing. + if (!file) { + if (!Storage.openFileForRead("SCT", filePath, file)) { + return false; + } + } + + const uint32_t fileSize = file.size(); + if (fileSize < HEADER_SIZE) { + LOG_ERR("SCT", "Search failed: section cache header is truncated"); + // Release the handle so the corrupt cache can be invalidated/rebuilt; the + // next call reopens lazily (searchHeaderReady stays false). + file.close(); + return false; + } + + uint32_t lutOffset = 0; + if (!readPageLutOffset(lutOffset)) { + LOG_ERR("SCT", "Search failed: could not read page LUT offset"); + file.close(); + return false; + } + + searchFileSize = fileSize; + searchLutOffset = lutOffset; + searchHeaderReady = true; + return true; +} + +bool Section::isValidSearchQuery(const std::string_view query) { + if (query.empty() || query.size() > MAX_SEARCH_QUERY_BYTES) { + return false; + } + // Require at least one byte that survives normalization. The matcher ignores + // ASCII spaces and hyphens (see normalizeSearchQuery), so a query of only + // whitespace and/or hyphens normalizes to nothing and can never match; the UI + // gate must agree with the matcher on what is searchable. + return std::any_of(query.begin(), query.end(), + [](const unsigned char value) { return std::isspace(value) == 0 && !isSearchSeparator(value); }); +} + +size_t Section::normalizeSearchQuery(const std::string_view query, std::array& out) { + size_t len = 0; + for (const char c : query) { + const uint8_t b = static_cast(c); + if (isSearchSeparator(b)) { + continue; + } + if (len >= out.size()) { + break; // defensive; callers reject query.size() > MAX_SEARCH_QUERY_BYTES + } + out[len++] = epub::asciiToLower(b); + } + return len; +} + +bool Section::compileSearchQuery(const std::string_view query, CompiledSearchQuery& out) { + // Leave the result in a defined (zeroed, length 0) state even on rejection, so + // a caller that ignores the return value never matches against stale bytes. + out = CompiledSearchQuery{}; + // One validity definition (empty / oversized / no searchable byte) shared with + // the UI gate; a query that passes always normalizes to a non-empty pattern. + if (!isValidSearchQuery(query)) { + return false; + } + + // Normalize once (lowercase, spaces/hyphens dropped); the KMP table is built + // over that same pattern, so pattern and prefix can never disagree. + out.length = normalizeSearchQuery(query, out.pattern); + + for (size_t i = 1, matched = 0; i < out.length; ++i) { + const uint8_t value = out.pattern[i]; + while (matched > 0 && value != out.pattern[matched]) { + matched = out.prefix[matched - 1]; + } + if (value == out.pattern[matched]) { + ++matched; + } + out.prefix[i] = static_cast(matched); + } + return true; +} + +std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched) { + if (query.length == 0 || page >= pageCount) { + LOG_ERR("SCT", "Invalid page search request (page=%u count=%u patternLen=%u)", page, pageCount, + static_cast(query.length)); + return std::nullopt; + } + + // File size and page-LUT offset are invariant per section; read them once. + if (!ensureSearchHeader()) { + return std::nullopt; + } + const uint32_t fileSize = searchFileSize; + const uint32_t lutOffset = searchLutOffset; + + // Compute in 64-bit so a corrupt (huge) lutOffset cannot wrap the uint32 sum + // into a small in-bounds value that slips past the fileSize bounds check. + const uint64_t entryOffset = static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * page; + if (lutOffset == 0 || entryOffset > fileSize || fileSize - entryOffset < PAGE_LUT_ENTRY_SIZE) { + LOG_ERR("SCT", "Search failed: invalid page LUT entry"); + return std::nullopt; + } + + if (!file.seek(static_cast(entryOffset) + sizeof(uint32_t))) { + LOG_ERR("SCT", "Search failed: could not seek to page LUT entry"); + return std::nullopt; + } + uint32_t searchTextOffset = 0; + if (file.read(reinterpret_cast(&searchTextOffset), sizeof(searchTextOffset)) != sizeof(searchTextOffset) || + searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record offset"); + return std::nullopt; + } + + if (!file.seek(searchTextOffset)) { + LOG_ERR("SCT", "Search failed: could not seek to text record"); + return std::nullopt; + } + uint32_t remaining = 0; + if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || + remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record length"); + return std::nullopt; + } + + // A page with no searchable text (e.g. image-only) is a content discontinuity, + // so drop any carried partial match rather than bridging across it. + if (remaining == 0) { + matched = 0; + return false; + } + + // KMP keeps overlap handling correct while streaming through a 64-byte SD + // read buffer. The query's normalized pattern and failure table were compiled + // once (compileSearchQuery); the scan skips spaces and hyphens in the record + // so layout hyphenation and spacing differences do not block a match. `matched` + // is carried in from the previous adjacent page so a query split across a page + // boundary still matches. + // Left uninitialized: file.read() fills chunkSize bytes and only [0,chunkSize) + // is ever read, so the per-page zero-fill would be dead work. + std::array buffer; + while (remaining > 0) { + const size_t chunkSize = std::min(buffer.size(), remaining); + if (file.read(buffer.data(), chunkSize) != chunkSize) { + LOG_ERR("SCT", "Search failed: truncated text record"); + return std::nullopt; + } + remaining -= chunkSize; + + for (size_t i = 0; i < chunkSize; ++i) { + if (isSearchSeparator(buffer[i])) { + continue; // spaces/hyphens are insignificant on both sides + } + const uint8_t value = epub::asciiToLower(buffer[i]); + while (matched > 0 && value != query.pattern[matched]) { + matched = query.prefix[matched - 1]; + } + if (value == query.pattern[matched]) { + ++matched; + if (matched == query.length) { + return true; + } + } + } + } + + return false; +} + +std::string Section::getTextFromSectionFile() { + std::string fullText; + auto p = this->loadPageFromSectionFile(); + if (p) { + for (const auto& el : p->elements) { + if (el->getTag() == TAG_PageLine) { + const auto& line = static_cast(*el); + if (line.getBlock()) { + const auto& words = line.getBlock()->getWords(); + for (const auto& w : words) { + if (!fullText.empty()) fullText += " "; + fullText += w; + } + } + } + } + } + return fullText; +} + +std::optional Section::getCachedPageCount() const { + HalFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + if (fileSize < HEADER_SIZE) { + return std::nullopt; + } + + f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t)); + uint16_t count; + serialization::readPod(f, count); + return count; +} + std::optional Section::getPageForAnchor(const std::string& anchor) const { FsFile f; if (!Storage.openFileForRead("SCT", filePath, f)) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 5370926c2b..d80e11bd63 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -1,8 +1,11 @@ #pragma once +#include +#include #include #include #include #include +#include #include "Epub.h" #include "EpubRenderMode.h" @@ -19,28 +22,58 @@ struct SectionBuildOptions { class Section { std::shared_ptr epub; - const int spineIndex; + int spineIndex; GfxRenderer& renderer; std::string filePath; + // Byte length of the constant "/sections/" prefix within filePath. + // resetForSpine() truncates filePath to this length and re-appends only the + // numeric suffix, reusing the buffer instead of allocating a new string per + // spine transition. + size_t sectionPathPrefixLen = 0; HalFile file; + std::string cacheSuffix; + + // Cached section-header state for the search scan: the file size and page-LUT + // offset are invariant per section, so they are read once when the scan file + // is lazily opened and reused for every pageContainsText() call. Invalidated + // by resetForSpine() (which also closes the file). + bool searchHeaderReady = false; + uint32_t searchFileSize = 0; + uint32_t searchLutOffset = 0; + bool writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool bionicReadingEnabled, bool guideReadingEnabled, EpubRenderMode renderMode); - uint32_t onPageComplete(std::unique_ptr page); + uint32_t onPageComplete(std::unique_ptr page, uint32_t& searchTextOffset); + // Seek to and read the page-LUT offset from the section header (the single + // place that knows where that field lives). Requires the member file to be + // open; returns false on seek/read failure. + bool readPageLutOffset(uint32_t& lutOffset); + // Lazily open the scan file and cache its size and page-LUT offset. Returns + // false on open failure or a truncated/corrupt header. + bool ensureSearchHeader(); + // Rewrite filePath's numeric suffix in place for the current spineIndex, + // reusing the buffer (no per-spine string allocation, no std::to_string). + void rebuildFilePathForSpine(); public: + static constexpr size_t MAX_SEARCH_QUERY_BYTES = 64; uint16_t pageCount = 0; int currentPage = 0; explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer, const char* cacheSuffix = "") - : epub(epub), - spineIndex(spineIndex), - renderer(renderer), - filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + (cacheSuffix ? cacheSuffix : "") + - ".bin") {} + : epub(epub), spineIndex(spineIndex), renderer(renderer), cacheSuffix(cacheSuffix ? cacheSuffix : "") { + // Build the constant "/sections/" prefix once and remember its + // length; resetForSpine() then rewrites only the numeric suffix in place. + const std::string& cachePath = this->epub->getCachePath(); + filePath.reserve(cachePath.size() + 32 + this->cacheSuffix.size()); // prefix + up to 11 digits + suffix + ".bin" + filePath.assign(cachePath).append("/sections/"); + sectionPathPrefixLen = filePath.size(); + rebuildFilePathForSpine(); + } ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, @@ -55,6 +88,50 @@ class Section { EpubRenderMode renderMode = EpubRenderMode::CrossInkDefault, SectionBuildOptions buildOptions = {}); std::unique_ptr loadPageFromSectionFile(); + std::string getTextFromSectionFile(); + + // Get the page count from the section cache file without fully loading it. + std::optional getCachedPageCount() const; + + // Reuse this Section object for another spine item without another heap + // allocation. Intended for sequential, book-wide operations such as search. + void resetForSpine(int newSpineIndex); + + // Single source of truth for whether a query is usable for search: non-empty, + // not all-whitespace, and within the byte limit. The UI validates with this + // before launching a search. + static bool isValidSearchQuery(std::string_view query); + + // A query compiled once for a whole-book search: the normalized pattern + // (lowercase, spaces and hyphens dropped), its length, and the KMP failure + // table over it. Built by compileSearchQuery(), consumed by pageContainsText(). + struct CompiledSearchQuery { + std::array pattern{}; + std::array prefix{}; + size_t length = 0; + }; + + // Normalize a query for matching: fold ASCII A-Z to lowercase and drop ASCII + // spaces and hyphens (so layout-time hyphenation and spacing differences do + // not block a match). Writes the normalized bytes into `out` and returns the + // normalized length. + static size_t normalizeSearchQuery(std::string_view query, std::array& out); + + // Compile a query once for a book-wide search: normalize it and build the KMP + // failure table over the result, so every page scan reuses one consistent + // pattern + table. Returns false for an empty/oversized query or one that + // normalizes to nothing (e.g. only spaces or hyphens). + static bool compileSearchQuery(std::string_view query, CompiledSearchQuery& out); + + // Streams the compact text record for one page through a fixed-size buffer, + // matching the compiled query while skipping spaces and hyphens in the record. + // `matched` is the KMP partial-match length carried in and out: pass the value + // left by the previous adjacent page so a query split across a page boundary + // (e.g. a line-hyphenated word, "inter-" then "national") still matches; the + // caller must reset it to 0 at any reading-order discontinuity (scan start, + // spine change, wrap). An empty record resets it. nullopt indicates an + // invalid/corrupt cache record; false is a valid miss. + std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 3f877858f7..26d18003ab 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -12,6 +12,8 @@ #include #include +#include "../AsciiCase.h" + namespace { // Stack-allocated string buffer to avoid heap reallocations during parsing @@ -69,7 +71,9 @@ constexpr std::string_view trimCssWhitespace(std::string_view s) { return s; } -constexpr char asciiToLower(const char c) { return (c >= 'A' && c <= 'Z') ? static_cast(c + 32) : c; } +// Thin char wrapper over the shared byte-wise fold so the logic lives in one +// place; CSS selectors are ASCII so the char<->uint8_t round-trip is exact. +constexpr char asciiToLower(const char c) { return static_cast(epub::asciiToLower(static_cast(c))); } // Case-insensitive equality on ASCII. lowercaseKeyword MUST already be // lowercase; CSS keywords are ASCII by spec so byte-wise tolower is safe. diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index fc120ac7d5..90cce152ac 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -226,6 +226,11 @@ STR_SUNLIGHT_FADING_FIX: "Кампенсацыя выцвітання" STR_QUICK_RESUME_TIMEOUT: "Хуткае узнаўленне пасля таймаўту" STR_REMAP_FRONT_BUTTONS: "Пераназначыць кнопкі" STR_OPDS_BROWSER: "OPDS браўзер" +STR_SEARCH: "Пошук" +STR_SEARCHING_BOOK: "Пошук у кнізе..." +STR_NO_SEARCH_RESULTS: "Супадзенняў не знойдзена." +STR_SEARCH_MATCH_FOUND: "Супадзенне знойдзена." +STR_INVALID_SEARCH_QUERY: "Увядзіце пошукавы запыт." STR_COVER_CUSTOM: "Вокладка + Свой" STR_PAGE_OVERLAY: "Накладка старонкі" STR_QUICK_RESUME: "Хуткае узнаўленне" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 8a93143564..6e9e4b4358 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -403,6 +403,10 @@ STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_SEARCH: "Cerca" +STR_SEARCHING_BOOK: "Cercant al llibre..." +STR_NO_SEARCH_RESULTS: "No s'han trobat coincidències." +STR_SEARCH_MATCH_FOUND: "Coincidència trobada." +STR_INVALID_SEARCH_QUERY: "Introduïu un terme de cerca." STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afegeix servidor" STR_SERVER_NAME: "Nom del servidor" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index c5d4edff6b..adacd3fc90 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -242,6 +242,11 @@ STR_QUICK_RESUME_TIMEOUT: "Rychlé navázání po vypršení" STR_AFTER_TIMEOUT: "Po vypršení" STR_REMAP_FRONT_BUTTONS: "Přemapovat tlačítka" STR_OPDS_BROWSER: "Prohlížeč OPDS" +STR_SEARCH: "Hledat" +STR_SEARCHING_BOOK: "Vyhledávání v knize..." +STR_NO_SEARCH_RESULTS: "Nebyly nalezeny žádné shody." +STR_SEARCH_MATCH_FOUND: "Shoda nalezena." +STR_INVALID_SEARCH_QUERY: "Zadejte hledaný výraz." STR_COVER_CUSTOM: "Obálka + Vlastní" STR_PAGE_OVERLAY: "Překrytí stránky" STR_QUICK_RESUME: "Rychlé navázání" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 7edd79f41f..1310b6258e 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -273,6 +273,11 @@ STR_QUICK_RESUME_TIMEOUT: "Hurtig genoptagelse ved timeout" STR_AFTER_TIMEOUT: "Efter timeout" STR_REMAP_FRONT_BUTTONS: "Omtildel knapper" STR_OPDS_BROWSER: "OPDS Browser" +STR_SEARCH: "Søg" +STR_SEARCHING_BOOK: "Søger i bog..." +STR_NO_SEARCH_RESULTS: "Ingen resultater fundet." +STR_SEARCH_MATCH_FOUND: "Resultat fundet." +STR_INVALID_SEARCH_QUERY: "Indtast et søgeord." STR_COVER_CUSTOM: "Omslag + Brugerdefineret" STR_PAGE_OVERLAY: "Sideoverlejring" STR_QUICK_RESUME: "Hurtig genoptagelse" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 6feb6ac142..4a763cbdf5 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -273,6 +273,11 @@ STR_QUICK_RESUME_TIMEOUT: "Snel hervatten bij timeout" STR_AFTER_TIMEOUT: "Na timeout" STR_REMAP_FRONT_BUTTONS: "Knoppen wijzigen" STR_OPDS_BROWSER: "OPDS-browser" +STR_SEARCH: "Zoeken" +STR_SEARCHING_BOOK: "Boek zoeken..." +STR_NO_SEARCH_RESULTS: "Geen resultaten gevonden." +STR_SEARCH_MATCH_FOUND: "Resultaat gevonden." +STR_INVALID_SEARCH_QUERY: "Voer een zoekterm in." STR_COVER_CUSTOM: "Omslag + Aangepast" STR_PAGE_OVERLAY: "Pagina-overlay" STR_QUICK_RESUME: "Snel hervatten" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8d5f2121be..0c2fba54fa 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -345,6 +345,10 @@ STR_REMAP_FRONT_BUTTONS: "Remap Buttons" STR_REMAP_FRONT_BUTTONS_READER: "Remap Buttons (Reader)" STR_OPDS_BROWSER: "OPDS Browser" STR_SEARCH: "Search" +STR_SEARCHING_BOOK: "Searching book..." +STR_NO_SEARCH_RESULTS: "No matches found." +STR_SEARCH_MATCH_FOUND: "Match found." +STR_INVALID_SEARCH_QUERY: "Enter a search term." STR_COVER_CUSTOM: "Cover + Custom" STR_PAGE_OVERLAY: "Page Overlay" STR_RECENTS: "Recents" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 67ab8f6c00..b1c945668f 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -245,6 +245,11 @@ STR_QUICK_RESUME_TIMEOUT: "Pikajatko aikakatkaisulla" STR_AFTER_TIMEOUT: "Aikakatkon jälkeen" STR_REMAP_FRONT_BUTTONS: "Uudelleenmääritä painikkeet" STR_OPDS_BROWSER: "OPDS-selain" +STR_SEARCH: "Haku" +STR_SEARCHING_BOOK: "Etsitään kirjasta..." +STR_NO_SEARCH_RESULTS: "Ei tuloksia." +STR_SEARCH_MATCH_FOUND: "Tulos löytyi." +STR_INVALID_SEARCH_QUERY: "Syötä hakusana." STR_COVER_CUSTOM: "Kansi + mukautettu" STR_PAGE_OVERLAY: "Sivupeite" STR_QUICK_RESUME: "Nopea jatkaminen" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 8c48df753c..e2faaa147d 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -274,6 +274,11 @@ STR_QUICK_RESUME_TIMEOUT: "Reprise rapide après délai" STR_AFTER_TIMEOUT: "Après délai" STR_REMAP_FRONT_BUTTONS: "Configurer boutons" STR_OPDS_BROWSER: "Navigateur OPDS" +STR_SEARCH: "Recherche" +STR_SEARCHING_BOOK: "Recherche dans le livre..." +STR_NO_SEARCH_RESULTS: "Aucun résultat trouvé." +STR_SEARCH_MATCH_FOUND: "Correspondance trouvée." +STR_INVALID_SEARCH_QUERY: "Saisissez un terme de recherche." STR_COVER_CUSTOM: "Couverture + Perso" STR_PAGE_OVERLAY: "Superposition de page" STR_QUICK_RESUME: "Reprise rapide" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 3dfafdd7e5..e023e999b5 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -333,6 +333,10 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_MINIMAL_STATS: "Minimal mit Statistiken" +STR_SEARCHING_BOOK: "Buch wird durchsucht..." +STR_NO_SEARCH_RESULTS: "Keine Treffer gefunden." +STR_SEARCH_MATCH_FOUND: "Treffer gefunden." +STR_INVALID_SEARCH_QUERY: "Geben Sie einen Suchbegriff ein." STR_QUICK_RESUME: "Schnelles Fortsetzen" STR_RECENT_BOOKS_VIEW: "Ansicht der zuletzt gelesenen Bücher" STR_LIST_VIEW: "Liste" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index f77249549f..44fd44160c 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -260,6 +260,10 @@ STR_SUNLIGHT_FADING_FIX: "תיקון דהיית מסך בשמש" STR_REMAP_FRONT_BUTTONS: "שנה תפקיד לכפתורים קדמיים" STR_OPDS_BROWSER: "דפדפן OPDS" STR_SEARCH: "חיפוש" +STR_SEARCHING_BOOK: "מחפש בספר..." +STR_NO_SEARCH_RESULTS: "לא נמצאו התאמות." +STR_SEARCH_MATCH_FOUND: "נמצאה התאמה." +STR_INVALID_SEARCH_QUERY: "הזן מונח לחיפוש." STR_COVER_CUSTOM: "כריכה + מותאם אישית" STR_MENU_RECENT_BOOKS: "ספרים אחרונים" STR_NO_RECENT_BOOKS: "אין ספרים אחרונים" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 58aeb0d563..420ff6bd82 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -267,6 +267,11 @@ STR_QUICK_RESUME_TIMEOUT: "Gyors folytatás időtúllépéskor" STR_AFTER_TIMEOUT: "Időtúllépés után" STR_REMAP_FRONT_BUTTONS: "Gombok átállítása" STR_OPDS_BROWSER: "OPDS böngésző" +STR_SEARCH: "Keresés" +STR_SEARCHING_BOOK: "Keresés a könyvben..." +STR_NO_SEARCH_RESULTS: "Nincs találat." +STR_SEARCH_MATCH_FOUND: "Találat." +STR_INVALID_SEARCH_QUERY: "Adjon meg egy keresőszót." STR_COVER_CUSTOM: "Borító + Egyéni" STR_PAGE_OVERLAY: "Oldalátfedés" STR_QUICK_RESUME: "Gyors folytatás" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index e87392a9b4..da0633373d 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -281,6 +281,10 @@ STR_AFTER_TIMEOUT: "Dopo timeout" STR_REMAP_FRONT_BUTTONS: "Rimappa pulsanti" STR_OPDS_BROWSER: "Browser OPDS" STR_SEARCH: "Cerca" +STR_SEARCHING_BOOK: "Ricerca nel libro..." +STR_NO_SEARCH_RESULTS: "Nessuna corrispondenza trovata." +STR_SEARCH_MATCH_FOUND: "Corrispondenza trovata." +STR_INVALID_SEARCH_QUERY: "Inserisci un termine di ricerca." STR_COVER_CUSTOM: "Copertina + Sfondo" STR_PAGE_OVERLAY: "Sovrapposizione pagina" STR_QUICK_RESUME: "Ripresa rapida" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index a4acd9c524..540968cd05 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -240,6 +240,11 @@ STR_QUICK_RESUME_TIMEOUT: "Таймауттан кейін жылдам жалғ STR_AFTER_TIMEOUT: "Таймауттан кейін" STR_REMAP_FRONT_BUTTONS: "Түймелерді қайта баптау" STR_OPDS_BROWSER: "OPDS шолғышы" +STR_SEARCH: "Іздеу" +STR_SEARCHING_BOOK: "Кітаптан іздеу..." +STR_NO_SEARCH_RESULTS: "Сәйкестіктер табылмады." +STR_SEARCH_MATCH_FOUND: "Сәйкестік табылды." +STR_INVALID_SEARCH_QUERY: "Іздеу сөзін енгізіңіз." STR_COVER_CUSTOM: "Мұқаба + Өзгертілген" STR_PAGE_OVERLAY: "Бет қабаты" STR_QUICK_RESUME: "Жылдам жалғастыру" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 5ca92eb316..b8c85afcde 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -267,6 +267,11 @@ STR_QUICK_RESUME_TIMEOUT: "Greitas tęsimas po skirtojo laiko" STR_AFTER_TIMEOUT: "Po skirtojo laiko" STR_REMAP_FRONT_BUTTONS: "Keisti mygtukus" STR_OPDS_BROWSER: "OPDS naršyklė" +STR_SEARCH: "Paieška" +STR_SEARCHING_BOOK: "Ieškoma knygoje..." +STR_NO_SEARCH_RESULTS: "Atitikmenų nerasta." +STR_SEARCH_MATCH_FOUND: "Rastas atitikmuo." +STR_INVALID_SEARCH_QUERY: "Įveskite paieškos frazę." STR_COVER_CUSTOM: "Viršelis + Kita" STR_PAGE_OVERLAY: "Puslapio perdanga" STR_QUICK_RESUME: "Greitas tęsimas" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 0078976d9a..9b9678ac46 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -273,6 +273,11 @@ STR_QUICK_RESUME_TIMEOUT: "Szybkie wznawianie po czasie" STR_AFTER_TIMEOUT: "Po upływie czasu" STR_REMAP_FRONT_BUTTONS: "Skonfiguruj przyciski" STR_OPDS_BROWSER: "OPDS Browser" +STR_SEARCH: "Szukaj" +STR_SEARCHING_BOOK: "Przeszukiwanie książki..." +STR_NO_SEARCH_RESULTS: "Brak pasających wyników." +STR_SEARCH_MATCH_FOUND: "Znaleziono dopasowanie." +STR_INVALID_SEARCH_QUERY: "Wpisz szukaną frazę." STR_COVER_CUSTOM: "Okładka + Własne" STR_PAGE_OVERLAY: "Nakładka strony" STR_QUICK_RESUME: "Szybkie kontynuowanie" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 6b6c10034b..d50c2766d8 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -244,6 +244,11 @@ STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite" STR_AFTER_TIMEOUT: "Após tempo limite" STR_REMAP_FRONT_BUTTONS: "Remapear botões" STR_OPDS_BROWSER: "Navegador OPDS" +STR_SEARCH: "Pesquisar" +STR_SEARCHING_BOOK: "Pesquisando no livro..." +STR_NO_SEARCH_RESULTS: "Nenhum resultado encontrado." +STR_SEARCH_MATCH_FOUND: "Resultado encontrado." +STR_INVALID_SEARCH_QUERY: "Digite um termo de pesquisa." STR_COVER_CUSTOM: "Capa + personalizado" STR_PAGE_OVERLAY: "Sobreposição da página" STR_QUICK_RESUME: "Retomada rápida" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 6a6b2d4ebc..123a9bd34f 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -272,6 +272,11 @@ STR_QUICK_RESUME_TIMEOUT: "Reluare rapidă la timeout" STR_AFTER_TIMEOUT: "După timeout" STR_REMAP_FRONT_BUTTONS: "Remapare butoane" STR_OPDS_BROWSER: "Browser OPDS" +STR_SEARCH: "Căutare" +STR_SEARCHING_BOOK: "Căutare în carte..." +STR_NO_SEARCH_RESULTS: "Nu s-au găsit potriviri." +STR_SEARCH_MATCH_FOUND: "Potrivire găsită." +STR_INVALID_SEARCH_QUERY: "Introduceți un termen de căutare." STR_COVER_CUSTOM: "Copertă + Personalizat" STR_PAGE_OVERLAY: "Suprapunere pagină" STR_QUICK_RESUME: "Reluare rapidă" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index fc5b7514be..6a92a90740 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -301,6 +301,10 @@ STR_BOOKMARK_ADDED: "Закладка добавлена" STR_DELETE_BOOKMARKS: "Очистить список закладок" STR_OPDS_BROWSER: "OPDS браузер" STR_SEARCH: "Поиск" +STR_SEARCHING_BOOK: "Поиск в книге..." +STR_NO_SEARCH_RESULTS: "Совпадений не найдено." +STR_SEARCH_MATCH_FOUND: "Найдено совпадение." +STR_INVALID_SEARCH_QUERY: "Введите поисковый запрос." STR_COVER_CUSTOM: "Обложка + Свой" STR_PAGE_OVERLAY: "Наложение страницы" STR_QUICK_RESUME: "Быстрое продолжение" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index 89f6c6554d..6cd386e224 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -296,6 +296,10 @@ STR_BATTERY: "Batéria" STR_THEME_CLASSIC: "Klasická" STR_THEME_LYRA_EXTENDED: "Lyra rozšírená" STR_SEARCH: "Hľadať" +STR_SEARCHING_BOOK: "Hľadanie v knihe..." +STR_NO_SEARCH_RESULTS: "Nenašli sa žiadne zhody." +STR_SEARCH_MATCH_FOUND: "Zhoda nájdená." +STR_INVALID_SEARCH_QUERY: "Zadajte hľadaný výraz." STR_COVER_CUSTOM: "Obálka + Vlastné" STR_PAGE_OVERLAY: "Prekrytie stránky" STR_NO_RECENT_BOOKS: "Žiadne nedávne knihy" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 08646e9548..9fa1a802d9 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -270,6 +270,11 @@ STR_QUICK_RESUME_TIMEOUT: "Hitro nadaljevanje po izteku" STR_AFTER_TIMEOUT: "Po izteku" STR_REMAP_FRONT_BUTTONS: "Prenastavi gumbe" STR_OPDS_BROWSER: "OPDS brskalnik" +STR_SEARCH: "Iskanje" +STR_SEARCHING_BOOK: "Iskanje v knjigi..." +STR_NO_SEARCH_RESULTS: "Ni najdenih zadetkov." +STR_SEARCH_MATCH_FOUND: "Zadetek najden." +STR_INVALID_SEARCH_QUERY: "Vnesite iskalni niz." STR_COVER_CUSTOM: "Naslovnica + po meri" STR_PAGE_OVERLAY: "Prekrivanje strani" STR_QUICK_RESUME: "Hitro nadaljevanje" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index d4fd1a3f65..241a32ea08 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -297,6 +297,10 @@ STR_BOOKMARK_ADDED: "Marcador añadido." STR_DELETE_BOOKMARKS: "Borrar lista de marcadores" STR_OPDS_BROWSER: "Navegador OPDS" STR_SEARCH: "Buscar" +STR_SEARCHING_BOOK: "Buscando en el libro..." +STR_NO_SEARCH_RESULTS: "No se encontraron coincidencias." +STR_SEARCH_MATCH_FOUND: "Coincidencia encontrada." +STR_INVALID_SEARCH_QUERY: "Introduce un término de búsqueda." STR_COVER_CUSTOM: "Portada + Pers." STR_PAGE_OVERLAY: "Superposición de página" STR_QUICK_RESUME: "Reanudación rápida" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 8bf3b5a993..07d92baf2c 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -297,6 +297,10 @@ STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout" STR_REMAP_FRONT_BUTTONS: "Ändra knappar" STR_OPDS_BROWSER: "OPDS-webbläsare" STR_SEARCH: "Sök" +STR_SEARCHING_BOOK: "Söker i boken..." +STR_NO_SEARCH_RESULTS: "Inga träffar funna." +STR_SEARCH_MATCH_FOUND: "Träff funnen." +STR_INVALID_SEARCH_QUERY: "Ange ett sökord." STR_COVER_CUSTOM: "Omslag + Valfri" STR_PAGE_OVERLAY: "Sidöverlägg" STR_QUICK_RESUME: "Snabb återupptagning" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index f84c13d6bf..372bc5f519 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -248,6 +248,11 @@ STR_QUICK_RESUME_TIMEOUT: "Zaman aşımında Hızlı Devam" STR_AFTER_TIMEOUT: "Zaman aşımında" STR_REMAP_FRONT_BUTTONS: "Tuşları Yeniden Ata" STR_OPDS_BROWSER: "OPDS Tarayıcı" +STR_SEARCH: "Ara" +STR_SEARCHING_BOOK: "Kitapta aranıyor..." +STR_NO_SEARCH_RESULTS: "Eşleşme bulunamadı." +STR_SEARCH_MATCH_FOUND: "Eşleşme bulundu." +STR_INVALID_SEARCH_QUERY: "Bir arama terimi girin." STR_COVER_CUSTOM: "Kapak + Özel" STR_PAGE_OVERLAY: "Sayfa bindirmesi" STR_QUICK_RESUME: "Hızlı Devam" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index b6b1e900a7..7bdcf4a217 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -282,6 +282,10 @@ STR_AFTER_TIMEOUT: "Після таймауту" STR_REMAP_FRONT_BUTTONS: "Налаштувати кнопки" STR_OPDS_BROWSER: "Браузер OPDS" STR_SEARCH: "Пошук" +STR_SEARCHING_BOOK: "Пошук у кнізі..." +STR_NO_SEARCH_RESULTS: "Збігів не знайдено." +STR_SEARCH_MATCH_FOUND: "Збіг знайдено." +STR_INVALID_SEARCH_QUERY: "Введіть пошуковий запит." STR_COVER_CUSTOM: "Обкл. + власне" STR_PAGE_OVERLAY: "Накладання сторінки" STR_QUICK_RESUME: "Швидке продовження" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 1b1d61a7a3..ed2f3ad023 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -371,6 +371,10 @@ STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_SEARCH: "Busca" +STR_SEARCHING_BOOK: "Buscant en el llibre..." +STR_NO_SEARCH_RESULTS: "No s'han trobat coincidències." +STR_SEARCH_MATCH_FOUND: "Coincidència trobada." +STR_INVALID_SEARCH_QUERY: "Introduïu un terme de cerca." STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afig servidor" STR_SERVER_NAME: "Nom del servidor" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 8c1a46ca39..692ad816e2 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -303,6 +303,11 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SEARCH: "Tìm kiếm" STR_COVER_CUSTOM: "Bìa + Tùy chỉnh" STR_PAGE_OVERLAY: "Lớp phủ trang" +STR_SEARCHING_BOOK: "Đang tìm trong sách..." +STR_NO_SEARCH_RESULTS: "Không tìm thấy kết quả." +STR_SEARCH_MATCH_FOUND: "Đã tìm thấy kết quả." +STR_INVALID_SEARCH_QUERY: "Nhập từ khóa tìm kiếm." +STR_REMOVE_FROM_RECENTS: "Xóa khỏi sách gần đây?" STR_NO_RECENT_BOOKS: "Không có sách gần đây" STR_RECENT_BOOKS_VIEW: "Chế độ xem sách gần đây" STR_LIST_VIEW: "Danh sách" diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c391f39414..3c58a455cf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include "EpubReaderClippingListActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" +#include "EpubReaderSearchActivity.h" #include "EpubReaderUtils.h" #include "GlobalActions.h" #include "KOReaderCredentialStore.h" @@ -46,6 +48,7 @@ #include "activities/boot_sleep/SleepCoverAssets.h" #include "activities/util/ConfirmationActivity.h" #include "activities/util/IntervalSelectionActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "clippings/ClippingsManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -66,8 +69,6 @@ constexpr uint8_t READER_SETTINGS_FLAG_CUSTOM = 1 << 0; constexpr uint8_t READER_SETTINGS_FLAG_AUTO_PAGE_TURN = 1 << 1; constexpr uint8_t READER_SETTINGS_FLAG_RENDER_MODE = 1 << 2; constexpr char READER_SETTINGS_FILE_NAME[] = "/reader_settings.bin"; -constexpr char BALANCED_SECTION_CACHE_SUFFIX[] = "_balanced"; -constexpr char LIGHT_SECTION_CACHE_SUFFIX[] = "_light"; constexpr unsigned long RENDER_MODE_TOAST_MS = 1500UL; constexpr unsigned long MIN_READING_STATS_PAGE_MS = 2000UL; constexpr uint32_t MIN_READING_PACE_SAMPLE_SECONDS = 2; @@ -107,18 +108,6 @@ EpubRenderMode normalizeRenderMode(const uint8_t rawMode) { uint8_t normalizeRenderModeRaw(const uint8_t rawMode) { return static_cast(normalizeRenderMode(rawMode)); } -const char* sectionCacheSuffixForRenderMode(const EpubRenderMode renderMode) { - switch (renderMode) { - case EpubRenderMode::Balanced: - return BALANCED_SECTION_CACHE_SUFFIX; - case EpubRenderMode::Light: - return LIGHT_SECTION_CACHE_SUFFIX; - case EpubRenderMode::CrossInkDefault: - default: - return ""; - } -} - uint64_t hashFootnotePreviewAnchor(const std::string& anchor) { uint64_t hash = 1469598103934665603ULL; for (const char c : anchor) { @@ -1072,6 +1061,60 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& !SETTINGS.removeReadBooksFromRecents); } +struct ReaderViewport { + int top; + int right; + int bottom; + int left; + uint16_t width; + uint16_t height; +}; + +ReaderViewport calculateReaderViewport(GfxRenderer& renderer, const bool automaticPageTurnActive) { + ReaderViewport viewport{}; + renderer.getOrientedViewableTRBL(&viewport.top, &viewport.right, &viewport.bottom, &viewport.left); + viewport.top += SETTINGS.screenMargin; + viewport.left += SETTINGS.screenMargin; + viewport.right += SETTINGS.screenMargin; + + const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + if (automaticPageTurnActive && + (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { + viewport.bottom += + std::max(SETTINGS.screenMargin, + static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin)); + } else { + viewport.bottom += std::max(SETTINGS.screenMargin, statusBarHeight); + } + + viewport.width = renderer.getScreenWidth() - viewport.left - viewport.right; + viewport.height = renderer.getScreenHeight() - viewport.top - viewport.bottom; + return viewport; +} + +// Pick a non-colliding destination path inside /Read/ for a finished book. +// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc. +std::string buildReadFolderDestination(const std::string& srcPath) { + const size_t lastSlash = srcPath.rfind('/'); + const std::string filename = (lastSlash != std::string::npos) ? srcPath.substr(lastSlash + 1) : srcPath; + + Storage.mkdir(READ_FOLDER); + std::string dstPath = std::string(READ_FOLDER) + "/" + filename; + if (!Storage.exists(dstPath.c_str())) { + return dstPath; + } + + const size_t dotPos = filename.rfind('.'); + const std::string base = (dotPos != std::string::npos) ? filename.substr(0, dotPos) : filename; + const std::string ext = (dotPos != std::string::npos) ? filename.substr(dotPos) : ""; + int suffix = 2; + do { + dstPath = std::string(READ_FOLDER) + "/" + base + " (" + std::to_string(suffix) + ")" + ext; + suffix++; + } while (Storage.exists(dstPath.c_str()) && suffix < 100); + return dstPath; +} + } // namespace uint8_t EpubReaderActivity::loadBookRenderMode(const std::string& filePath) { @@ -1918,6 +1961,11 @@ void EpubReaderActivity::loop() { } } + if (transientMessage && (millis() - transientMessageTime) >= ReaderUtils::READER_MESSAGE_DURATION_MS) { + transientMessage = nullptr; + requestUpdate(); + } + // Long-press Confirm: execute the configured reader action without opening the menu if (longPressMenuHandled) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || @@ -1936,6 +1984,7 @@ void EpubReaderActivity::loop() { return; } } + if (SETTINGS.longPressMenuAction != CrossPointSettings::LONG_MENU_OFF && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= longPressMenuMs) { longPressMenuHandled = true; @@ -2268,7 +2317,7 @@ void EpubReaderActivity::jumpToPercent(int percent) { } // Normalize input to 0-100 to avoid invalid jumps. - percent = clampPercent(percent); + percent = ReaderUtils::clampPercent(percent); int locationSpineIndex = 0; float locationSpineProgress = 0.0f; @@ -2373,6 +2422,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } + case EpubReaderMenuActivity::MenuAction::SEARCH: { + launchSearchInput(); + break; + } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { pauseReadingPaceTimer("footnotes"); startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), @@ -3288,7 +3341,6 @@ void EpubReaderActivity::suppressPowerShortcutRelease() { mappedInput.suppressNextPowerRelease(); mappedInput.suppressNextPowerConfirmRelease(); } - void EpubReaderActivity::setBookCompleted(bool isCompleted) { if (stats.isCompleted == isCompleted) { return; @@ -3327,6 +3379,108 @@ void EpubReaderActivity::setBookCompleted(bool isCompleted) { globalStats.save(); } +void EpubReaderActivity::launchSearchInput() { + // KeyboardEntryActivity is already the project's bounded text-input path. + // The activity allocation is one-shot and owned by ActivityManager; the + // 64-byte limit bounds its internal query string. + auto keyboard = makeUniqueNoThrow( + renderer, mappedInput, tr(STR_SEARCH), lastSearchQuery.data(), Section::MAX_SEARCH_QUERY_BYTES, InputType::Text); + if (!keyboard) { + LOG_ERR("ERS", "OOM: KeyboardEntryActivity (%u bytes)", static_cast(sizeof(KeyboardEntryActivity))); + requestUpdate(); + return; + } + + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + + const auto& query = std::get(result.data).text; + if (!Section::isValidSearchQuery(query)) { + // Surface why the search was not accepted (e.g. whitespace/hyphen-only + // input) instead of silently repainting, which reads as a no-op. + showTransientMessage(tr(STR_INVALID_SEARCH_QUERY)); + requestUpdate(); + return; + } + launchBookSearch(query); + }); +} + +void EpubReaderActivity::launchBookSearch(const std::string& query) { + if (!epub || epub->getSpineItemsCount() <= 0) { + requestUpdate(); + return; + } + + const int resumePage = section ? section->currentPage : nextPageNumber; + const int searchStartSpine = + (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount()) ? currentSpineIndex : 0; + const bool hasPendingPageRemap = !section && cachedChapterTotalPageCount > 0 && cachedSpineIndex == searchStartSpine; + // The page the search is initiated from (the wrap normally stops before + // re-examining it). A fresh search may revisit it only to complete a match + // begun on the preceding page. "Find next" begins one page past it so a wrap + // cannot re-return it; SearchRoute owns that start/stop relationship. + const int initiatedFromPage = searchStartSpine == currentSpineIndex ? std::max(0, resumePage) : 0; + const bool sameQuery = strcmp(lastSearchQuery.data(), query.c_str()) == 0; + const bool isFindNext = !hasPendingPageRemap && sameQuery && lastSearchResultSpine == currentSpineIndex && + lastSearchResultPage == resumePage; + const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::make( + searchStartSpine, initiatedFromPage, isFindNext, hasPendingPageRemap ? cachedChapterTotalPageCount : 0); + + const ReaderViewport viewport = calculateReaderViewport(renderer, automaticPageTurnActive); + + // Release the current page graph before allocating the search activity. It + // will be reconstructed from the SD cache on return, while nextPageNumber + // preserves the reader position if search is cancelled or misses. + { + RenderLock lock(*this); + if (section) { + nextPageNumber = section->currentPage; + } + section.reset(); + } + + // One activity allocation is required by ActivityManager ownership. Query, + // matcher state, and the reusable Section live inline in that allocation; + // no per-page or per-chapter activity allocations are performed. + auto searchActivity = makeUniqueNoThrow(renderer, mappedInput, epub, query.c_str(), route, + viewport.width, viewport.height); + if (!searchActivity) { + LOG_ERR("ERS", "OOM: EpubReaderSearchActivity (%u bytes)", static_cast(sizeof(EpubReaderSearchActivity))); + requestUpdate(); + return; + } + + memcpy(lastSearchQuery.data(), query.data(), query.size()); + lastSearchQuery[query.size()] = '\0'; + if (!sameQuery) { + lastSearchResultSpine = -1; + lastSearchResultPage = -1; + } + + startActivityForResult(std::move(searchActivity), [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& match = std::get(result.data); + RenderLock lock(*this); + currentSpineIndex = match.spineIndex; + nextPageNumber = match.page; + section.reset(); + cachedChapterTotalPageCount = 0; + lastSearchResultSpine = match.spineIndex; + lastSearchResultPage = match.page; + showTransientMessage(tr(STR_SEARCH_MATCH_FOUND)); + } + }); +} + +void EpubReaderActivity::showTransientMessage(const char* message) { + transientMessage = message; + transientMessageTime = millis(); +} + void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { completedFeedbackIsFinished = isCompleted; pendingCompletedFeedback = true; @@ -3552,7 +3706,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { auto loadSectionWithFont = [&](const int fontId, const EpubRenderMode renderMode) { const std::string cacheSuffix = buildingFootnotePreview ? footnotePreviewCacheSuffix(renderMode, pendingFootnotePreviewAnchor) - : std::string(sectionCacheSuffixForRenderMode(renderMode)); + : std::string(ReaderUtils::sectionCacheSuffixForRenderMode(renderMode)); section = makeUniqueNoThrow
(epub, currentSpineIndex, renderer, cacheSuffix.c_str()); if (!section) { LOG_ERR("ERS", "Failed to allocate section for spine %d (font=%d, free=%u, maxAlloc=%u)", currentSpineIndex, @@ -3589,7 +3743,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { GUI.drawPopup(renderer, tr(STR_INDEXING)); const std::string cacheSuffix = buildingFootnotePreview ? footnotePreviewCacheSuffix(profile.renderMode, pendingFootnotePreviewAnchor) - : std::string(sectionCacheSuffixForRenderMode(profile.renderMode)); + : std::string(ReaderUtils::sectionCacheSuffixForRenderMode(profile.renderMode)); section = makeUniqueNoThrow
(epub, currentSpineIndex, renderer, cacheSuffix.c_str()); if (!section) { LOG_ERR("ERS", "Failed to allocate %s section builder for spine %d (free=%u, maxAlloc=%u)", profile.label, @@ -3894,6 +4048,9 @@ void EpubReaderActivity::render(RenderLock&& lock) { pendingScreenshot = false; ScreenshotUtil::takeScreenshot(renderer); } + if (transientMessage) { + GUI.drawPopup(renderer, transientMessage); + } } void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { @@ -3932,7 +4089,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW ESP.getMaxAllocHeap()); const EpubRenderMode renderMode = normalizeRenderMode(SETTINGS.epubRenderMode); - Section nextSection(epub, nextSpineIndex, renderer, sectionCacheSuffixForRenderMode(renderMode)); + Section nextSection(epub, nextSpineIndex, renderer, ReaderUtils::sectionCacheSuffixForRenderMode(renderMode)); if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.forceParagraphIndents, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, @@ -3971,7 +4128,8 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW layoutAbortedForLowMemory = false; const SectionBuildProfile profile = buildProfileForRenderMode(attemptMode); - Section attemptSection(epub, nextSpineIndex, renderer, sectionCacheSuffixForRenderMode(profile.renderMode)); + Section attemptSection(epub, nextSpineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(profile.renderMode)); buildSucceeded = attemptSection.createSectionFile( SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.forceParagraphIndents, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, @@ -3993,7 +4151,8 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW releaseReaderSdFontCachesForLowMemory(renderer, "ERS", "silent next-chapter safe mode indexing"); layoutAbortedForLowMemory = false; const SectionBuildProfile profile = safeModeBuildProfile(); - Section attemptSection(epub, nextSpineIndex, renderer, sectionCacheSuffixForRenderMode(profile.renderMode)); + Section attemptSection(epub, nextSpineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(profile.renderMode)); buildSucceeded = attemptSection.createSectionFile( SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.forceParagraphIndents, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, @@ -4423,7 +4582,7 @@ std::string EpubReaderActivity::footnotePreviewCacheSuffix(const EpubRenderMode char previewSuffix[32]; snprintf(previewSuffix, sizeof(previewSuffix), "_fn_%08lx%08lx", static_cast(anchorHash >> 32), static_cast(anchorHash & 0xffffffffULL)); - return std::string(sectionCacheSuffixForRenderMode(renderMode)) + previewSuffix; + return std::string(ReaderUtils::sectionCacheSuffixForRenderMode(renderMode)) + previewSuffix; } void EpubReaderActivity::clearFootnotePreviewState() { @@ -4539,8 +4698,8 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf const int readerFontId = SETTINGS.getReaderFontId(); int renderFontId = readerFontId; const EpubRenderMode selectedRenderMode = normalizeRenderMode(SETTINGS.epubRenderMode); - auto section = - makeUniqueNoThrow
(epub, spineIndex, renderer, sectionCacheSuffixForRenderMode(selectedRenderMode)); + auto section = makeUniqueNoThrow
(epub, spineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(selectedRenderMode)); if (!section) { LOG_ERR("SLP", "EPUB: failed to allocate section for spine %d", spineIndex); return false; @@ -4575,8 +4734,8 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf } layoutAbortedForLowMemory = false; const SectionBuildProfile profile = buildProfileForRenderMode(attemptMode); - section = - makeUniqueNoThrow
(epub, spineIndex, renderer, sectionCacheSuffixForRenderMode(profile.renderMode)); + section = makeUniqueNoThrow
(epub, spineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(profile.renderMode)); if (!section) { LOG_ERR("SLP", "EPUB: failed to allocate section builder for spine %d", spineIndex); return false; @@ -4595,8 +4754,8 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf releaseReaderSdFontCachesForLowMemory(renderer, "SLP", "sleep-page safe mode rebuild"); layoutAbortedForLowMemory = false; const SectionBuildProfile profile = safeModeBuildProfile(); - section = - makeUniqueNoThrow
(epub, spineIndex, renderer, sectionCacheSuffixForRenderMode(profile.renderMode)); + section = makeUniqueNoThrow
(epub, spineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(profile.renderMode)); if (!section) { LOG_ERR("SLP", "EPUB: failed to allocate Safe Mode section builder for spine %d", spineIndex); return false; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 07d164a62f..5fe69d7471 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -118,6 +119,14 @@ class EpubReaderActivity final : public Activity { bool completionTriggerCrossed = false; bool lastAtOrPastCompletionTrigger = false; + // Transient popup shared by the bookmark and search-match messages: the text + // to show (null when hidden) and when it was shown. The pointer is from tr(), + // which returns stable storage in the static i18n string table. + const char* transientMessage = nullptr; + unsigned long transientMessageTime = 0UL; + std::array lastSearchQuery{}; + int lastSearchResultSpine = -1; + int lastSearchResultPage = -1; // Tracks whether this book is currently removed from Recent Books by the // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). bool recentsEntryRemoved = false; @@ -191,6 +200,10 @@ class EpubReaderActivity final : public Activity { bool executeLongPowerButtonAction(); void handleClippingJump(const ClippingJumpResult& clipping); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + void launchSearchInput(); + void launchBookSearch(const std::string& query); + // Show a transient popup (bookmark or search) for READER_MESSAGE_DURATION_MS. + void showTransientMessage(const char* message); void applyOrientation(uint8_t orientation); void pageTurn(bool isForwardTurn, const char* source = "unknown"); float getCurrentBookProgressPercent() const; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index a279f3b07e..8d7da849b3 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -170,7 +170,7 @@ EpubReaderMenuActivity::TabMenuItems EpubReaderMenuActivity::buildMenuItems(bool auto& bookmarkItems = items[BOOKMARKS_TAB_INDEX]; auto& settingsItems = items[SETTINGS_TAB_INDEX]; - mainItems.reserve(8 + (hasFootnotes ? 1u : 0u)); + mainItems.reserve(9 + (hasFootnotes ? 1u : 0u)); bookmarkItems.reserve(7 + (hasBookmarks ? 2u : 0u) + (hasClippings ? 1u : 0u)); settingsItems.reserve(2 + (showReadingPaceReset ? 1u : 0u)); @@ -178,6 +178,7 @@ EpubReaderMenuActivity::TabMenuItems EpubReaderMenuActivity::buildMenuItems(bool mainItems.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); } mainItems.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); + mainItems.push_back({MenuAction::SEARCH, StrId::STR_SEARCH}); mainItems.push_back({MenuAction::READER_OPTIONS, StrId::STR_READER_OPTIONS}); mainItems.push_back({MenuAction::CONTROLS_OPTIONS, StrId::STR_CAT_CONTROLS}); mainItems.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); @@ -205,6 +206,7 @@ EpubReaderMenuActivity::TabMenuItems EpubReaderMenuActivity::buildMenuItems(bool if (showReadingPaceReset) { settingsItems.push_back({MenuAction::RESET_READING_PACE, StrId::STR_RESET_READING_PACE}); } + return items; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index a2789b8da9..cebc02919f 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -18,6 +18,7 @@ class EpubReaderMenuActivity final : public Activity { // Menu actions available from the reader menu. enum class MenuAction { SELECT_CHAPTER, + SEARCH, FOOTNOTES, GO_TO_PERCENT, AUTO_PAGE_TURN, diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp new file mode 100644 index 0000000000..f381182772 --- /dev/null +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -0,0 +1,348 @@ +#include "EpubReaderSearchActivity.h" + +#include +#include +#include + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +// Repaint the progress screen only once the percentage has advanced this much, +// keeping e-ink refreshes bounded now that progress moves per page. +constexpr int PROGRESS_REPAINT_STEP_PERCENT = 1; +} // namespace + +void EpubReaderSearchActivity::SearchRoute::resolvePageCount(const int targetPageCount) { + if (sourcePageCount <= 0) { + return; + } + + const bool findNext = startPage > stopPage; + if (targetPageCount <= 0) { + startPage = 0; + stopPage = 0; + sourcePageCount = 0; + return; + } + + const int64_t remappedPage = static_cast(stopPage) * targetPageCount / sourcePageCount; + stopPage = std::min(static_cast(remappedPage), targetPageCount - 1); + startPage = stopPage + (findNext ? 1 : 0); + sourcePageCount = 0; +} + +EpubReaderSearchActivity::EpubReaderSearchActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::shared_ptr& epub, const char* query, + const SearchRoute& route, const uint16_t viewportWidth, + const uint16_t viewportHeight) + : Activity("EpubReaderSearch", renderer, mappedInput), + epub(epub), + section(this->epub, route.startSpineIndex, renderer, + ReaderUtils::sectionCacheSuffixForRenderMode(isValidEpubRenderMode(SETTINGS.epubRenderMode) + ? static_cast(SETTINGS.epubRenderMode) + : EpubRenderMode::CrossInkDefault)), + route(route), + currentSpineIndex(route.startSpineIndex), + currentPage(route.startPage), + viewportWidth(viewportWidth), + viewportHeight(viewportHeight) { + if (query) { + const size_t length = std::min(strlen(query), this->query.size() - 1); + memcpy(this->query.data(), query, length); + this->query[length] = '\0'; + } + // Compile the query once here; every page scan reuses the pattern + table. A + // rejected query (empty/oversized, or only separators) means there is nothing + // to scan, so fail closed rather than relying solely on the caller's gate. + if (!Section::compileSearchQuery(this->query.data(), compiledQuery)) { + state = SearchState::NotFound; + } +} + +void EpubReaderSearchActivity::onEnter() { + Activity::onEnter(); + // Paint the status screen before an uncached chapter starts its potentially + // long layout pass. + requestUpdateAndWait(); +} + +void EpubReaderSearchActivity::onExit() { Activity::onExit(); } + +bool EpubReaderSearchActivity::skipLoopDelay() { return state == SearchState::Searching; } + +bool EpubReaderSearchActivity::preventAutoSleep() { return state == SearchState::Searching; } + +void EpubReaderSearchActivity::cancel() { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); +} + +void EpubReaderSearchActivity::setFailure(const SearchState failureState) { + state = failureState; + requestUpdate(); +} + +bool EpubReaderSearchActivity::reachedWrappedStop() const { + if (!wrapped) { + return false; + } + return currentSpineIndex > route.startSpineIndex || + (currentSpineIndex == route.startSpineIndex && currentPage >= route.stopPage); +} + +bool EpubReaderSearchActivity::shouldScanWrappedStopContinuation() const { + // A fresh search already scanned stopPage from a clean KMP state. Revisit it + // only when the preceding page left a partial match; this admits the one + // occurrence that crosses the circular route boundary without changing find + // next's originating-page exclusion. + return wrapped && route.startPage == route.stopPage && currentSpineIndex == route.startSpineIndex && + currentPage == route.stopPage && scanMatched > 0; +} + +void EpubReaderSearchActivity::advanceSpine() { + ++currentSpineIndex; + currentPage = 0; + sectionLoaded = false; + sectionCacheRepairAttempted = false; + scanMatched = 0; // spine boundary: don't carry a partial match across chapters +} + +bool EpubReaderSearchActivity::loadCurrentSection() { + section.resetForSpine(currentSpineIndex); + const EpubRenderMode renderMode = isValidEpubRenderMode(SETTINGS.epubRenderMode) + ? static_cast(SETTINGS.epubRenderMode) + : EpubRenderMode::CrossInkDefault; + if (section.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.forceParagraphIndents, + SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, + SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.bionicReadingEnabled, + SETTINGS.guideReadingEnabled, renderMode)) { + sectionLoaded = true; + return true; + } + + LOG_DBG("EPS", "Building section %d for search", currentSpineIndex); + if (!section.createSectionFile( + SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, + SETTINGS.forceParagraphIndents, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, + SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.bionicReadingEnabled, + SETTINGS.guideReadingEnabled, nullptr, nullptr, nullptr, renderMode)) { + LOG_ERR("EPS", "Failed to build section %d for search", currentSpineIndex); + return false; + } + + sectionLoaded = true; + return true; +} + +bool EpubReaderSearchActivity::invalidateCurrentSectionCache() { + // resetForSpine closes the member HalFile before clearCache removes its path. + section.resetForSpine(currentSpineIndex); + sectionLoaded = false; + if (!section.clearCache()) { + LOG_ERR("EPS", "Failed to clear corrupt section %d", currentSpineIndex); + return false; + } + return true; +} + +bool EpubReaderSearchActivity::preparePage() { + const int spineCount = epub ? epub->getSpineItemsCount() : 0; + if (spineCount <= 0) { + setFailure(SearchState::Error); + return false; + } + + while (true) { + if (currentSpineIndex >= spineCount) { + if (wrapped) { + setFailure(SearchState::NotFound); + return false; + } + wrapped = true; + currentSpineIndex = 0; + currentPage = 0; + sectionLoaded = false; + sectionCacheRepairAttempted = false; + scanMatched = 0; // wrap is not contiguous reading text + } + + if (reachedWrappedStop() && !shouldScanWrappedStopContinuation()) { + setFailure(SearchState::NotFound); + return false; + } + + if (!sectionLoaded && !loadCurrentSection()) { + setFailure(SearchState::Error); + return false; + } + + if (!wrapped && currentSpineIndex == route.startSpineIndex && route.sourcePageCount > 0) { + route.resolvePageCount(section.pageCount); + currentPage = route.startPage; + } + + // Once the start spine is loaded, capture the byte-weighted positions of the + // scan's start and stop pages and the resulting route length, so progress + // reads against the reader's own progress model rather than a spine-count + // approximation. Done once (scanStartPos stays negative until captured). + if (scanStartPos < 0.0f && !wrapped && currentSpineIndex == route.startSpineIndex && epub) { + const float pc = section.pageCount > 0 ? static_cast(section.pageCount) : 1.0f; + scanStartPos = + epub->calculateProgress(route.startSpineIndex, std::min(1.0f, static_cast(route.startPage) / pc)); + const float stopPos = + epub->calculateProgress(route.startSpineIndex, std::min(1.0f, static_cast(route.stopPage) / pc)); + // Route: forward from start to the end of the book (1.0), wrap, then up to + // the stop page. For a fresh search start == stop, giving a full route of 1. + scanRouteLength = (1.0f - scanStartPos) + stopPos; + } + + if (currentPage >= 0 && currentPage < section.pageCount) { + return true; + } + + advanceSpine(); + } +} + +void EpubReaderSearchActivity::scanNextPage() { + if (!preparePage()) { + return; + } + + const size_t matchedBeforePage = scanMatched; + auto match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); + if (!match.has_value() && !sectionCacheRepairAttempted) { + sectionCacheRepairAttempted = true; + scanMatched = matchedBeforePage; + if (!invalidateCurrentSectionCache() || !loadCurrentSection()) { + setFailure(SearchState::Error); + return; + } + match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); + } + + if (!match.has_value()) { + // Do not leave a version-valid but unreadable cache to fail every future search. + invalidateCurrentSectionCache(); + setFailure(SearchState::Error); + return; + } + if (*match) { + setResult(ProgressChangeResult{currentSpineIndex, currentPage}); + finish(); + return; + } + + ++currentPage; +} + +int EpubReaderSearchActivity::searchProgressPercent() const { + if (!epub || scanStartPos < 0.0f) { + return 0; // not yet started / start spine not loaded + } + if (scanRouteLength <= 0.0f) { + return 100; // degenerate route (nothing eligible to scan) + } + // Current byte-weighted book position, using the same model as the reader's + // own progress bar (so the percentage tracks the bar rather than approximating + // every spine as equal length). + const float pc = section.pageCount > 0 ? static_cast(section.pageCount) : 1.0f; + const float posNow = epub->calculateProgress(currentSpineIndex, std::min(1.0f, static_cast(currentPage) / pc)); + // Work done since the scan began: forward distance before the wrap, plus a + // full forward lap (1.0 - start) once wrapped. + const float workDone = wrapped ? (1.0f - scanStartPos) + posNow : posNow - scanStartPos; + return ReaderUtils::clampPercent(static_cast((workDone / scanRouteLength) * 100.0f + 0.5f)); +} + +void EpubReaderSearchActivity::loop() { + // Do NOT poll input here. main.cpp's loop() already calls gpio.update() once + // per iteration before dispatching to this activity; a second poll would clear + // the just-latched press/release events (InputManager::update zeroes them every + // call) before wasReleased() reads them, making Back/Confirm undismissable. + switch (state) { + case SearchState::Searching: + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancel(); + return; + } + scanNextPage(); + // Progress is now page-granular, so only repaint once it has advanced a + // whole step. This bounds e-ink refreshes to ~100/step over an entire + // scan regardless of book structure, instead of one per page. + if (state == SearchState::Searching) { + const int percent = searchProgressPercent(); + if (percent - lastProgressPercent >= PROGRESS_REPAINT_STEP_PERCENT) { + lastProgressPercent = percent; + requestUpdate(); + } + } + return; + + case SearchState::NotFound: + case SearchState::Error: + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + cancel(); + } + return; + } +} + +void EpubReaderSearchActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + GUI.drawHeader(renderer, Rect{screen.x, screen.y + metrics.topPadding, screen.width, metrics.headerHeight}, + tr(STR_SEARCH)); + GUI.drawSubHeader( + renderer, + Rect{screen.x, screen.y + metrics.topPadding + metrics.headerHeight, screen.width, metrics.tabBarHeight}, + query.data()); + + const char* message = nullptr; + switch (state) { + case SearchState::Searching: + message = tr(STR_SEARCHING_BOOK); + break; + case SearchState::NotFound: + message = tr(STR_NO_SEARCH_RESULTS); + break; + case SearchState::Error: + message = tr(STR_ERROR_GENERAL_FAILURE); + break; + } + + const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight; + const int messageY = contentTop + (screen.height - contentTop) / 2; + UITheme::drawCenteredText(renderer, screen, UI_12_FONT_ID, messageY, message, true, EpdFontFamily::BOLD); + + if (state == SearchState::Searching) { + // Draw the value loop() already computed and gated the repaint on, rather + // than recomputing the progress here. + char percentText[8]; + snprintf(percentText, sizeof(percentText), "%d%%", lastProgressPercent); + UITheme::drawCenteredText(renderer, screen, UI_12_FONT_ID, messageY + renderer.getLineHeight(UI_12_FONT_ID), + percentText, true); + } + + // While searching, Back cancels. On terminal states (NotFound/Error) both Back + // and Confirm dismiss to the reader (see loop()), so advertise both. + const bool terminal = state != SearchState::Searching; + const char* backLabel = terminal ? tr(STR_BACK) : tr(STR_CANCEL); + const char* confirmLabel = terminal ? tr(STR_DONE) : ""; + const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h new file mode 100644 index 0000000000..d2758ec714 --- /dev/null +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "activities/Activity.h" + +class EpubReaderSearchActivity final : public Activity { + public: + // The book-wide scan route, owned as one concept. The scan starts at + // (startSpineIndex, startPage), runs forward to the end of the book, wraps + // once, and stops before re-examining stopPage (the page the search was + // initiated from). A fresh search may scan stopPage once more only to finish + // a partial match that began on its preceding page. make() encodes the one + // invariant tying the route together: + // a repeated "find next" begins one page past stopPage so it cannot re-return + // the originating page, while a fresh search begins exactly at it. + struct SearchRoute { + int startSpineIndex; + int startPage; + int stopPage; + int sourcePageCount; + + static SearchRoute make(int spineIndex, int initiatedFromPage, bool findNext, int sourcePageCount = 0) { + return SearchRoute{spineIndex, initiatedFromPage + (findNext ? 1 : 0), initiatedFromPage, sourcePageCount}; + } + + // Translate page coordinates captured before a viewport reflow into the + // loaded section's pagination. A zero source count means no remap is pending. + void resolvePageCount(int targetPageCount); + }; + + EpubReaderSearchActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::shared_ptr& epub, + const char* query, const SearchRoute& route, uint16_t viewportWidth, + uint16_t viewportHeight); + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool skipLoopDelay() override; + bool preventAutoSleep() override; + + private: + enum class SearchState : uint8_t { Searching, NotFound, Error }; + + std::shared_ptr epub; + Section section; + std::array query{}; + // `query` compiled once in the constructor (normalized pattern + KMP table) + // and reused for every page scan instead of being rebuilt per page. + Section::CompiledSearchQuery compiledQuery{}; + SearchRoute route; + int currentSpineIndex; + int currentPage; + const uint16_t viewportWidth; + const uint16_t viewportHeight; + SearchState state = SearchState::Searching; + bool sectionLoaded = false; + bool sectionCacheRepairAttempted = false; + bool wrapped = false; + // KMP partial-match length carried across consecutive pages of the same spine + // so a query split across a page boundary (line-hyphenated word, or a phrase) + // still matches. Reset at every reading-order discontinuity: scan start (0 + // init), spine change (advanceSpine), and the wrap. + size_t scanMatched = 0; + // Last progress percentage painted to the panel. Repaints are gated on this + // changing so the e-ink panel is not refreshed per page. Starts at 0 because + // onEnter() paints the initial 0% screen before the scan begins. + int lastProgressPercent = 0; + // Byte-weighted book position (0-1, via Epub::calculateProgress) where the + // scan began, and the length of its route to the stop page (forward to the end + // of the book, wrap, then up to the stop page). Captured once the start spine + // loads; progress is (work since start) / route length. scanStartPos is + // negative until captured. + float scanStartPos = -1.0f; + float scanRouteLength = 0.0f; + + bool preparePage(); + bool loadCurrentSection(); + bool invalidateCurrentSectionCache(); + bool reachedWrappedStop() const; + bool shouldScanWrappedStopContinuation() const; + void advanceSpine(); + void scanNextPage(); + // Approximate 0-100 fraction of the scan route completed: the byte-weighted + // distance travelled since the search began, over the route length (forward to + // the end of the book, wrap, then up to the originating page). + int searchProgressPercent() const; + void setFailure(SearchState failureState); + void cancel(); +}; diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index d86f967482..742447d7c8 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -13,9 +14,30 @@ namespace ReaderUtils { +constexpr char BALANCED_SECTION_CACHE_SUFFIX[] = "_balanced"; +constexpr char LIGHT_SECTION_CACHE_SUFFIX[] = "_light"; + +inline const char* sectionCacheSuffixForRenderMode(const EpubRenderMode renderMode) { + switch (renderMode) { + case EpubRenderMode::Balanced: + return BALANCED_SECTION_CACHE_SUFFIX; + case EpubRenderMode::Light: + return LIGHT_SECTION_CACHE_SUFFIX; + case EpubRenderMode::CrossInkDefault: + default: + return ""; + } +} + constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; constexpr uint8_t STATUS_BAR_TEXT_PADDING = 3; +constexpr unsigned long BOOKMARK_HOLD_MS = 400; +// Duration any transient reader popup (bookmark added/removed, search match) stays on screen. +constexpr unsigned long READER_MESSAGE_DURATION_MS = 2500; + +// Clamp a progress value to the inclusive 0-100 percent range. +constexpr int clampPercent(int percent) { return percent < 0 ? 0 : (percent > 100 ? 100 : percent); } inline GfxRenderer::Orientation toRendererOrientation(const uint8_t orientation) { switch (orientation) { diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 8b244f8f80..5a3fec74f8 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -581,8 +581,14 @@ void KeyboardEntryActivity::render(RenderLock&&) { tipCount = 1 + (inputType == InputType::Url ? 1 : 0) + (!text.empty() ? 1 : 0); } - if (tipCount > 0) { - int y = (underlineBottom + keyboardStartY) / 2 - (tipCount + 1) * tipsLh / 2; + // The tips are centered in the gap between the input field and the keyboard. + // In short layouts (e.g. landscape) that gap can be smaller than the tips + // block, which would overlap the input above and the keyboard below; only draw + // the tips when the gap can actually hold them. The block is tipCount + 1 lines + // (the STR_KB_TIPS header plus the per-mode hints). + const int tipsBlockHeight = (tipCount + 1) * tipsLh; + if (tipCount > 0 && keyboardStartY - underlineBottom >= tipsBlockHeight) { + int y = (underlineBottom + keyboardStartY) / 2 - tipsBlockHeight / 2; drawTip(tr(STR_KB_TIPS), y); y += tipsLh; if (cursorMode) { From 2d8854e3dd6074b537841aaef7a5f06d00317448 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 12:17:23 -0700 Subject: [PATCH 02/91] feat: highlight search matches --- docs/search-architecture.md | 7 +- src/activities/reader/EpubReaderActivity.cpp | 123 +++++++++++++++++++ src/activities/reader/EpubReaderActivity.h | 8 ++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 865a84efbc..dc4e428837 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -291,7 +291,10 @@ searching. page. - Matches are page-level. Repeating a query skips the rest of the current page, so multiple occurrences on one page are not individually navigable. -- There is no match highlighting or result list. +- There is no match result list, but the matching page highlights all occurrences of the query. +- Search match highlighting uses a high-contrast inverted style (solid black background with white/light text) to make matches immediately stand out on the screen. +- Highlighting is transient and scoped: it is only rendered on the initial search-match result page. Turning the page or navigating away automatically clears the highlight state so it does not persist on subsequent reads. +- Highlight detection runs at render time by normalizing the current page's visible words (lowercase, hyphens/spaces stripped) and matching them against the normalized query. This guarantees alignment with KMP indexing but adds a minor, one-off CPU and temporary RAM cost during page composition. - Case-insensitive matching is ASCII-only. Non-ASCII case variants must match exactly. - Search text is reconstructed from rendered word tokens with single spaces, so @@ -353,8 +356,6 @@ heap alone is insufficient to detect fragmentation. ## Possible future extensions -- Store token/word offsets if exact same-page occurrences and highlighting are - worth the extra cache space and rendering complexity. - Add compact Unicode case-fold support for languages available on the input method, with an explicit flash budget. - Make section layout cooperatively cancellable if cold-search latency becomes diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3c58a455cf..71e941d5ed 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1690,6 +1690,12 @@ void EpubReaderActivity::onEnter() { return; } + // Pre-allocate search highlight buffers to avoid render-path heap churn + searchHighlightQuery.reserve(64); + searchHighlightPageText.reserve(4096); + searchHighlightCharToWordIndex.reserve(4096); + searchHighlightMatchRanges.reserve(128); + captureGlobalReaderSettings(); epub->setupCacheDir(); loadBookReaderSettings(); @@ -4211,6 +4217,11 @@ void EpubReaderActivity::cacheCurrentSectionPosition() { void EpubReaderActivity::renderContents(std::unique_ptr page, const int fontId, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { + if (section && (currentSpineIndex != lastSearchResultSpine || section->currentPage != lastSearchResultPage)) { + lastSearchResultSpine = -1; + lastSearchResultPage = -1; + } + const auto t0 = millis(); // Font prewarm: scan pass accumulates text, then prewarm, then real render @@ -4241,6 +4252,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const auto finalizeBufferComposition = [&]() { drawClippingHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); + drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); drawPublisherPageMarkers(renderer, *page, orientedMarginTop, contentBottom, foregroundBlack); }; @@ -4501,6 +4513,117 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font }); } +void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, + const int orientedMarginLeft) const { + if (lastSearchQuery[0] == '\0' || !section || currentSpineIndex != lastSearchResultSpine || + section->currentPage != lastSearchResultPage) { + return; + } + + // 1. Normalize the search query (lowercase, drop spaces and hyphens) + searchHighlightQuery.clear(); + const char* q = lastSearchQuery.data(); + while (*q != '\0') { + const char c = *q; + if (c != ' ' && c != '-') { + searchHighlightQuery.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); + } + q++; + } + if (searchHighlightQuery.empty()) { + return; + } + + // 2. Normalize the page text and map characters to word indices + searchHighlightPageText.clear(); + searchHighlightCharToWordIndex.clear(); + + forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + const std::string& wordText = block.getWords()[i]; + for (char c : wordText) { + if (c == ' ' || c == '-') { + continue; + } + if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || + searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { + return false; + } + searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); + searchHighlightCharToWordIndex.push_back(pageWordIndex); + } + return true; + }); + + // 3. Find matches of normalizedQuery in normalizedPageText + searchHighlightMatchRanges.clear(); + size_t pos = 0; + while ((pos = searchHighlightPageText.find(searchHighlightQuery, pos)) != std::string::npos) { + const size_t endPos = pos + searchHighlightQuery.size() - 1; + if (pos < searchHighlightCharToWordIndex.size() && endPos < searchHighlightCharToWordIndex.size()) { + if (searchHighlightMatchRanges.size() >= searchHighlightMatchRanges.capacity()) { + break; + } + searchHighlightMatchRanges.push_back( + {searchHighlightCharToWordIndex[pos], searchHighlightCharToWordIndex[endPos]}); + } + pos += searchHighlightQuery.size(); + } + + if (searchHighlightMatchRanges.empty()) { + return; + } + + // 4. Highlight matched words on page + const bool foregroundBlack = ReaderUtils::readerForegroundBlack(); + const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { + return std::any_of( + searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), + [pageWordIndex](const auto& range) { return pageWordIndex >= range.first && pageWordIndex <= range.second; }); + }; + + forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + if (!isSearchMatchWord(pageWordIndex)) { + return true; + } + + const auto& wordList = block.getWords(); + const auto& xpos = block.getWordXpos(); + const auto& styles = block.getWordStyles(); + if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { + return true; + } + + const std::string& wordText = wordList[i]; + const bool hasEmSpace = hasEmSpacePrefix(wordText); + const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); + const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); + const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; + const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; + const int wordY = orientedMarginTop + line.yPos; + int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; + const int wordH = renderer.getLineHeight(fontId); + if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { + const std::string& nextWordText = wordList[i + 1]; + const bool nextHasEmSpace = hasEmSpacePrefix(nextWordText); + const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); + const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; + const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; + if (isSearchMatchWord(pageWordIndex + 1) && nextWordX > wordX + wordW) { + wordW = nextWordX - wordX; + } else if (nextWordX > wordX && wordW > nextWordX - wordX) { + wordW = nextWordX - wordX; + } + } + if (wordW > 0) { + renderer.fillRect(wordX, wordY, wordW, wordH, true); + renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); + } + return true; + }); +} + void EpubReaderActivity::renderStatusBar() const { const int currentPage = section->currentPage + 1; const int pageCount = section->pageCount; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 5fe69d7471..782359a7f9 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "BookReadingStats.h" #include "BookmarkStore.h" @@ -127,6 +129,11 @@ class EpubReaderActivity final : public Activity { std::array lastSearchQuery{}; int lastSearchResultSpine = -1; int lastSearchResultPage = -1; + // Reusable buffers for search highlighting to avoid render-path allocations + mutable std::string searchHighlightQuery; + mutable std::string searchHighlightPageText; + mutable std::vector searchHighlightCharToWordIndex; + mutable std::vector> searchHighlightMatchRanges; // Tracks whether this book is currently removed from Recent Books by the // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). bool recentsEntryRemoved = false; @@ -147,6 +154,7 @@ class EpubReaderActivity final : public Activity { void renderContents(std::unique_ptr page, int fontId, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void drawClippingHighlights(const Page& page, int fontId, int orientedMarginTop, int orientedMarginLeft) const; + void drawSearchHighlights(const Page& page, int fontId, int orientedMarginTop, int orientedMarginLeft) const; void renderStatusBar() const; bool shouldUseFootnotePreview(int targetSpineIndex, const std::string& anchor) const; std::string footnotePreviewCacheSuffix(EpubRenderMode renderMode, const std::string& anchor) const; From 7f0b17aed4e8bb41929a98c342f15b46212fe9d2 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:23:39 -0700 Subject: [PATCH 03/91] fix: resolve typos in Polish and Ukrainian translations --- lib/I18n/translations/polish.yaml | 2 +- lib/I18n/translations/ukrainian.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 9b9678ac46..751161d7e5 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -275,7 +275,7 @@ STR_REMAP_FRONT_BUTTONS: "Skonfiguruj przyciski" STR_OPDS_BROWSER: "OPDS Browser" STR_SEARCH: "Szukaj" STR_SEARCHING_BOOK: "Przeszukiwanie książki..." -STR_NO_SEARCH_RESULTS: "Brak pasających wyników." +STR_NO_SEARCH_RESULTS: "Brak pasujących wyników." STR_SEARCH_MATCH_FOUND: "Znaleziono dopasowanie." STR_INVALID_SEARCH_QUERY: "Wpisz szukaną frazę." STR_COVER_CUSTOM: "Okładka + Własne" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 7bdcf4a217..7f5ffe2587 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -282,7 +282,7 @@ STR_AFTER_TIMEOUT: "Після таймауту" STR_REMAP_FRONT_BUTTONS: "Налаштувати кнопки" STR_OPDS_BROWSER: "Браузер OPDS" STR_SEARCH: "Пошук" -STR_SEARCHING_BOOK: "Пошук у кнізі..." +STR_SEARCHING_BOOK: "Пошук у книзі..." STR_NO_SEARCH_RESULTS: "Збігів не знайдено." STR_SEARCH_MATCH_FOUND: "Збіг знайдено." STR_INVALID_SEARCH_QUERY: "Введіть пошуковий запит." From 5396fc2ea3c69f9c561a6bfec26d8e42f2e7c510 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:24:08 -0700 Subject: [PATCH 04/91] fix: enforce Section cache bounds, handle error cleanups, and extend pageContainsText API --- lib/Epub/Epub/Section.cpp | 49 ++++++++++++++++++++++++++++++++------- lib/Epub/Epub/Section.h | 4 +++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ed51c01b7a..c82fbc5d50 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -554,10 +554,22 @@ std::unique_ptr Section::loadPageFromSectionFile() { return nullptr; } + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + LOG_ERR("SCT", "Failed to seek to page count"); + file.close(); + return nullptr; + } + uint16_t headerPageCount = 0; + if (!serialization::tryReadPod(file, headerPageCount)) { + LOG_ERR("SCT", "Failed to read page count from header"); + file.close(); + return nullptr; + } + // Validate LUT-derived offsets against the file before trusting them (mirrors // pageContainsText). Compute in 64-bit so a corrupt (huge) lutOffset cannot // wrap the uint32 sum into a small in-bounds value. - if (lutOffset == 0 || currentPage < 0) { + if (lutOffset == 0 || currentPage < 0 || static_cast(currentPage) >= headerPageCount) { LOG_ERR("SCT", "Invalid page LUT request"); file.close(); return nullptr; @@ -608,16 +620,19 @@ void Section::rebuildFilePathForSpine() { filePath.append(".bin"); } -void Section::resetForSpine(const int newSpineIndex) { +void Section::closeSearchState() { if (file) { - // Member handle persists across calls, so close before switching paths. file.close(); } + searchHeaderReady = false; +} + +void Section::resetForSpine(const int newSpineIndex) { + closeSearchState(); spineIndex = newSpineIndex; rebuildFilePathForSpine(); pageCount = 0; currentPage = 0; - searchHeaderReady = false; } bool Section::ensureSearchHeader() { if (searchHeaderReady) { @@ -637,14 +652,14 @@ bool Section::ensureSearchHeader() { LOG_ERR("SCT", "Search failed: section cache header is truncated"); // Release the handle so the corrupt cache can be invalidated/rebuilt; the // next call reopens lazily (searchHeaderReady stays false). - file.close(); + closeSearchState(); return false; } uint32_t lutOffset = 0; if (!readPageLutOffset(lutOffset)) { LOG_ERR("SCT", "Search failed: could not read page LUT offset"); - file.close(); + closeSearchState(); return false; } @@ -708,10 +723,16 @@ bool Section::compileSearchQuery(const std::string_view query, CompiledSearchQue return true; } -std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched) { +std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched, + const bool resetMatched) { + if (resetMatched) { + matched = 0; + } + if (query.length == 0 || page >= pageCount) { LOG_ERR("SCT", "Invalid page search request (page=%u count=%u patternLen=%u)", page, pageCount, static_cast(query.length)); + closeSearchState(); return std::nullopt; } @@ -727,28 +748,33 @@ std::optional Section::pageContainsText(const uint16_t page, const Compile const uint64_t entryOffset = static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * page; if (lutOffset == 0 || entryOffset > fileSize || fileSize - entryOffset < PAGE_LUT_ENTRY_SIZE) { LOG_ERR("SCT", "Search failed: invalid page LUT entry"); + closeSearchState(); return std::nullopt; } if (!file.seek(static_cast(entryOffset) + sizeof(uint32_t))) { LOG_ERR("SCT", "Search failed: could not seek to page LUT entry"); + closeSearchState(); return std::nullopt; } uint32_t searchTextOffset = 0; if (file.read(reinterpret_cast(&searchTextOffset), sizeof(searchTextOffset)) != sizeof(searchTextOffset) || searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); + closeSearchState(); return std::nullopt; } if (!file.seek(searchTextOffset)) { LOG_ERR("SCT", "Search failed: could not seek to text record"); + closeSearchState(); return std::nullopt; } uint32_t remaining = 0; if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record length"); + closeSearchState(); return std::nullopt; } @@ -772,6 +798,7 @@ std::optional Section::pageContainsText(const uint16_t page, const Compile const size_t chunkSize = std::min(buffer.size(), remaining); if (file.read(buffer.data(), chunkSize) != chunkSize) { LOG_ERR("SCT", "Search failed: truncated text record"); + closeSearchState(); return std::nullopt; } remaining -= chunkSize; @@ -827,9 +854,13 @@ std::optional Section::getCachedPageCount() const { return std::nullopt; } - f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t)); + if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + return std::nullopt; + } uint16_t count; - serialization::readPod(f, count); + if (!serialization::tryReadPod(f, count)) { + return std::nullopt; + } return count; } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index d80e11bd63..26acc2cceb 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -54,6 +54,7 @@ class Section { // Lazily open the scan file and cache its size and page-LUT offset. Returns // false on open failure or a truncated/corrupt header. bool ensureSearchHeader(); + void closeSearchState(); // Rewrite filePath's numeric suffix in place for the current spineIndex, // reusing the buffer (no per-spine string allocation, no std::to_string). void rebuildFilePathForSpine(); @@ -131,7 +132,8 @@ class Section { // caller must reset it to 0 at any reading-order discontinuity (scan start, // spine change, wrap). An empty record resets it. nullopt indicates an // invalid/corrupt cache record; false is a valid miss. - std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched); + std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched, + bool resetMatched); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; From 749219f8430ded20b1e2b660e708b1701b7f3af7 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:24:17 -0700 Subject: [PATCH 05/91] fix: pass isChapterBoundary flag during pageContainsText scanning --- .../reader/EpubReaderSearchActivity.cpp | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index f381182772..42137e7a2b 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -220,8 +220,27 @@ void EpubReaderSearchActivity::scanNextPage() { return; } + bool isChapterBoundary = false; + if (currentPage > 0 && epub) { + const int startTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (startTocIndex >= 0) { + for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) { + auto entry = epub->getTocItem(i); + if (entry.spineIndex != currentSpineIndex) break; + if (!entry.anchor.empty()) { + const auto entryPage = section.getPageForAnchor(entry.anchor); + if (entryPage.has_value() && *entryPage == currentPage) { + isChapterBoundary = true; + break; + } + } + } + } + } + const size_t matchedBeforePage = scanMatched; - auto match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); + auto match = + section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched, isChapterBoundary); if (!match.has_value() && !sectionCacheRepairAttempted) { sectionCacheRepairAttempted = true; scanMatched = matchedBeforePage; @@ -229,7 +248,7 @@ void EpubReaderSearchActivity::scanNextPage() { setFailure(SearchState::Error); return; } - match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); + match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched, isChapterBoundary); } if (!match.has_value()) { From 59f9ccb910aa2d04413513e4e0042d951aca61c4 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:25:15 -0700 Subject: [PATCH 06/91] fix: align search viewport, footnote preview, highlights, and pace timer in EpubReaderActivity --- src/activities/reader/EpubReaderActivity.cpp | 102 +++++++++++++------ 1 file changed, 72 insertions(+), 30 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 71e941d5ed..45820a3834 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1073,18 +1073,27 @@ struct ReaderViewport { ReaderViewport calculateReaderViewport(GfxRenderer& renderer, const bool automaticPageTurnActive) { ReaderViewport viewport{}; renderer.getOrientedViewableTRBL(&viewport.top, &viewport.right, &viewport.bottom, &viewport.left); - viewport.top += SETTINGS.screenMargin; - viewport.left += SETTINGS.screenMargin; + viewport.left += effectiveReaderLeftMargin(); viewport.right += SETTINGS.screenMargin; const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + const int topStatusBarReservedHeight = ReaderUtils::getTopClockStatusBarReservedHeight(); + if (topStatusBarReservedHeight > 0) { + viewport.top += std::max(static_cast(SETTINGS.screenMargin), + topStatusBarReservedHeight + ReaderUtils::STATUS_BAR_TEXT_PADDING); + } else { + viewport.top += SETTINGS.screenMargin; + } + if (automaticPageTurnActive && (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { viewport.bottom += std::max(SETTINGS.screenMargin, - static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin)); + static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin + + ReaderUtils::STATUS_BAR_TEXT_PADDING)); } else { - viewport.bottom += std::max(SETTINGS.screenMargin, statusBarHeight); + viewport.bottom += + std::max(SETTINGS.screenMargin, static_cast(statusBarHeight + ReaderUtils::STATUS_BAR_TEXT_PADDING)); } viewport.width = renderer.getScreenWidth() - viewport.left - viewport.right; @@ -3397,8 +3406,10 @@ void EpubReaderActivity::launchSearchInput() { return; } + pauseReadingPaceTimer("search"); startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { if (result.isCancelled) { + resumeReadingPaceTimer("search_cancel"); requestUpdate(); return; } @@ -3408,6 +3419,7 @@ void EpubReaderActivity::launchSearchInput() { // Surface why the search was not accepted (e.g. whitespace/hyphen-only // input) instead of silently repainting, which reads as a no-op. showTransientMessage(tr(STR_INVALID_SEARCH_QUERY)); + resumeReadingPaceTimer("search_invalid"); requestUpdate(); return; } @@ -3417,22 +3429,27 @@ void EpubReaderActivity::launchSearchInput() { void EpubReaderActivity::launchBookSearch(const std::string& query) { if (!epub || epub->getSpineItemsCount() <= 0) { + resumeReadingPaceTimer("search_invalid_book"); requestUpdate(); return; } const int resumePage = section ? section->currentPage : nextPageNumber; - const int searchStartSpine = - (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount()) ? currentSpineIndex : 0; + const int realSpine = + (activeFootnotePreview && footnoteDepth > 0) ? savedPositions[footnoteDepth - 1].spineIndex : currentSpineIndex; + const int realPage = + (activeFootnotePreview && footnoteDepth > 0) ? savedPositions[footnoteDepth - 1].pageNumber : resumePage; + + const int searchStartSpine = (realSpine >= 0 && realSpine < epub->getSpineItemsCount()) ? realSpine : 0; const bool hasPendingPageRemap = !section && cachedChapterTotalPageCount > 0 && cachedSpineIndex == searchStartSpine; // The page the search is initiated from (the wrap normally stops before // re-examining it). A fresh search may revisit it only to complete a match // begun on the preceding page. "Find next" begins one page past it so a wrap // cannot re-return it; SearchRoute owns that start/stop relationship. - const int initiatedFromPage = searchStartSpine == currentSpineIndex ? std::max(0, resumePage) : 0; + const int initiatedFromPage = searchStartSpine == realSpine ? std::max(0, realPage) : 0; const bool sameQuery = strcmp(lastSearchQuery.data(), query.c_str()) == 0; - const bool isFindNext = !hasPendingPageRemap && sameQuery && lastSearchResultSpine == currentSpineIndex && - lastSearchResultPage == resumePage; + const bool isFindNext = + !hasPendingPageRemap && sameQuery && lastSearchResultSpine == realSpine && lastSearchResultPage == realPage; const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::make( searchStartSpine, initiatedFromPage, isFindNext, hasPendingPageRemap ? cachedChapterTotalPageCount : 0); @@ -3456,6 +3473,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { viewport.width, viewport.height); if (!searchActivity) { LOG_ERR("ERS", "OOM: EpubReaderSearchActivity (%u bytes)", static_cast(sizeof(EpubReaderSearchActivity))); + resumeReadingPaceTimer("search_oom"); requestUpdate(); return; } @@ -3479,6 +3497,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { lastSearchResultPage = match.page; showTransientMessage(tr(STR_SEARCH_MATCH_FOUND)); } + resumeReadingPaceTimer("search_return"); }); } @@ -4520,17 +4539,9 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId return; } - // 1. Normalize the search query (lowercase, drop spaces and hyphens) - searchHighlightQuery.clear(); - const char* q = lastSearchQuery.data(); - while (*q != '\0') { - const char c = *q; - if (c != ' ' && c != '-') { - searchHighlightQuery.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); - } - q++; - } - if (searchHighlightQuery.empty()) { + // 1. Compile the search query once using KMP + Section::CompiledSearchQuery compiledQuery{}; + if (!Section::compileSearchQuery(lastSearchQuery.data(), compiledQuery)) { return; } @@ -4555,19 +4566,50 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId return true; }); - // 3. Find matches of normalizedQuery in normalizedPageText + // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state searchHighlightMatchRanges.clear(); - size_t pos = 0; - while ((pos = searchHighlightPageText.find(searchHighlightQuery, pos)) != std::string::npos) { - const size_t endPos = pos + searchHighlightQuery.size() - 1; - if (pos < searchHighlightCharToWordIndex.size() && endPos < searchHighlightCharToWordIndex.size()) { - if (searchHighlightMatchRanges.size() >= searchHighlightMatchRanges.capacity()) { - break; + size_t carryMatched = 0; + for (uint16_t p = 0; p < section->currentPage; ++p) { + bool isChapterBoundary = false; + if (p > 0 && epub) { + const int startTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (startTocIndex >= 0) { + for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) { + auto entry = epub->getTocItem(i); + if (entry.spineIndex != currentSpineIndex) break; + if (!entry.anchor.empty()) { + const auto entryPage = section->getPageForAnchor(entry.anchor); + if (entryPage.has_value() && *entryPage == p) { + isChapterBoundary = true; + break; + } + } + } + } + } + section->pageContainsText(p, compiledQuery, carryMatched, isChapterBoundary); + } + + size_t matched = carryMatched; + for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { + const uint8_t value = searchHighlightPageText[charIndex]; + while (matched > 0 && value != compiledQuery.pattern[matched]) { + matched = compiledQuery.prefix[matched - 1]; + } + if (value == compiledQuery.pattern[matched]) { + ++matched; + if (matched == compiledQuery.length) { + size_t startIdx = (charIndex + 1 >= matched) ? (charIndex + 1 - matched) : 0; + size_t endIdx = charIndex; + if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { + if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { + searchHighlightMatchRanges.push_back( + {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); + } + } + matched = compiledQuery.prefix[matched - 1]; } - searchHighlightMatchRanges.push_back( - {searchHighlightCharToWordIndex[pos], searchHighlightCharToWordIndex[endPos]}); } - pos += searchHighlightQuery.size(); } if (searchHighlightMatchRanges.empty()) { From 0834ceb69c5285c7ac7dfd3bebcc0b6c83ef290a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:33:30 -0700 Subject: [PATCH 07/91] fix: display toast instead of popup for search messages --- src/activities/reader/EpubReaderActivity.cpp | 6 +++--- src/activities/reader/EpubReaderActivity.h | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 45820a3834..2ff5e8cebf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4073,9 +4073,6 @@ void EpubReaderActivity::render(RenderLock&& lock) { pendingScreenshot = false; ScreenshotUtil::takeScreenshot(renderer); } - if (transientMessage) { - GUI.drawPopup(renderer, transientMessage); - } } void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { @@ -4319,6 +4316,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo } else if (pendingRenderModeToast) { drawToastBuffer(renderer, labelForRenderModeToast(normalizeRenderMode(renderModeToastMode))); } + if (transientMessage) { + drawToastBuffer(renderer, transientMessage); + } fcm->logStats("bw_render"); const auto tBwRender = millis(); const auto logImagePageProfile = [](const uint32_t imageBlankDisplayMs, const uint32_t imageRestoreRenderMs, diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 782359a7f9..a8c218267b 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -121,9 +121,9 @@ class EpubReaderActivity final : public Activity { bool completionTriggerCrossed = false; bool lastAtOrPastCompletionTrigger = false; - // Transient popup shared by the bookmark and search-match messages: the text - // to show (null when hidden) and when it was shown. The pointer is from tr(), - // which returns stable storage in the static i18n string table. + // Transient toast used by the search feature: the text to show (null when hidden) + // and when it was shown. The pointer is from tr(), which returns stable storage + // in the static i18n string table. const char* transientMessage = nullptr; unsigned long transientMessageTime = 0UL; std::array lastSearchQuery{}; @@ -210,7 +210,7 @@ class EpubReaderActivity final : public Activity { void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void launchSearchInput(); void launchBookSearch(const std::string& query); - // Show a transient popup (bookmark or search) for READER_MESSAGE_DURATION_MS. + // Show a transient search toast for READER_MESSAGE_DURATION_MS. void showTransientMessage(const char* message); void applyOrientation(uint8_t orientation); void pageTurn(bool isForwardTurn, const char* source = "unknown"); From d0181f66824605e695dbe482a76f4cd81dcfcf1b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 16:53:56 -0700 Subject: [PATCH 08/91] fix: undo isChapterBoundary change The search feature was previously checking the EPUB's Table of Contents (TOC) on every single page scan to see if it landed on a "chapter boundary." This resulted in significant I/O overhead because reading a TOC entry involved opening and seeking within the section cache file on the SD card. However, since a TOC anchor inside a single file (spine item) is not actually a discontinuity in the text flow, this entire operation was unnecessary. --- lib/Epub/Epub/Section.cpp | 7 +----- lib/Epub/Epub/Section.h | 3 +-- src/activities/reader/EpubReaderActivity.cpp | 19 +-------------- .../reader/EpubReaderSearchActivity.cpp | 23 ++----------------- 4 files changed, 5 insertions(+), 47 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index c82fbc5d50..df3e0a0ae3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -723,12 +723,7 @@ bool Section::compileSearchQuery(const std::string_view query, CompiledSearchQue return true; } -std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched, - const bool resetMatched) { - if (resetMatched) { - matched = 0; - } - +std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched) { if (query.length == 0 || page >= pageCount) { LOG_ERR("SCT", "Invalid page search request (page=%u count=%u patternLen=%u)", page, pageCount, static_cast(query.length)); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 26acc2cceb..3d10f1df1c 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -132,8 +132,7 @@ class Section { // caller must reset it to 0 at any reading-order discontinuity (scan start, // spine change, wrap). An empty record resets it. nullopt indicates an // invalid/corrupt cache record; false is a valid miss. - std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched, - bool resetMatched); + std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2ff5e8cebf..ffa3387f71 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4570,24 +4570,7 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId searchHighlightMatchRanges.clear(); size_t carryMatched = 0; for (uint16_t p = 0; p < section->currentPage; ++p) { - bool isChapterBoundary = false; - if (p > 0 && epub) { - const int startTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); - if (startTocIndex >= 0) { - for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) { - auto entry = epub->getTocItem(i); - if (entry.spineIndex != currentSpineIndex) break; - if (!entry.anchor.empty()) { - const auto entryPage = section->getPageForAnchor(entry.anchor); - if (entryPage.has_value() && *entryPage == p) { - isChapterBoundary = true; - break; - } - } - } - } - } - section->pageContainsText(p, compiledQuery, carryMatched, isChapterBoundary); + section->pageContainsText(p, compiledQuery, carryMatched); } size_t matched = carryMatched; diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 42137e7a2b..f381182772 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -220,27 +220,8 @@ void EpubReaderSearchActivity::scanNextPage() { return; } - bool isChapterBoundary = false; - if (currentPage > 0 && epub) { - const int startTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); - if (startTocIndex >= 0) { - for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) { - auto entry = epub->getTocItem(i); - if (entry.spineIndex != currentSpineIndex) break; - if (!entry.anchor.empty()) { - const auto entryPage = section.getPageForAnchor(entry.anchor); - if (entryPage.has_value() && *entryPage == currentPage) { - isChapterBoundary = true; - break; - } - } - } - } - } - const size_t matchedBeforePage = scanMatched; - auto match = - section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched, isChapterBoundary); + auto match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); if (!match.has_value() && !sectionCacheRepairAttempted) { sectionCacheRepairAttempted = true; scanMatched = matchedBeforePage; @@ -248,7 +229,7 @@ void EpubReaderSearchActivity::scanNextPage() { setFailure(SearchState::Error); return; } - match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched, isChapterBoundary); + match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); } if (!match.has_value()) { From 22bc9e2f5ffc755492e7ded66a263b7894b4c929 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 17:17:46 -0700 Subject: [PATCH 09/91] refactor: extract KMP matching logic into SearchMatcher Moves the KMP compilation, normalization, and matching state out of Section and into a dedicated SearchMatcher class to simplify state tracking across chapter boundaries. --- lib/Epub/Epub/SearchMatcher.cpp | 85 +++++++++++++++++++++++++++++++++ lib/Epub/Epub/SearchMatcher.h | 35 ++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 lib/Epub/Epub/SearchMatcher.cpp create mode 100644 lib/Epub/Epub/SearchMatcher.h diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp new file mode 100644 index 0000000000..1868a9b590 --- /dev/null +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -0,0 +1,85 @@ +#include "SearchMatcher.h" + +#include +#include + +#include "AsciiCase.h" + +namespace { +constexpr bool isSearchSeparator(const uint8_t b) { return b == ' ' || b == '-'; } +} // namespace + +bool SearchMatcher::isValidSearchQuery(const std::string_view query) { + if (query.empty() || query.size() > MAX_QUERY_BYTES) { + return false; + } + // Require at least one byte that survives normalization. The matcher ignores + // ASCII spaces and hyphens (see normalizeSearchQuery), so a query of only + // whitespace and/or hyphens normalizes to nothing and can never match; the UI + // gate must agree with the matcher on what is searchable. + return std::any_of(query.begin(), query.end(), + [](const unsigned char value) { return std::isspace(value) == 0 && !isSearchSeparator(value); }); +} + +size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::array& out) { + size_t len = 0; + for (const char c : query) { + const uint8_t b = static_cast(c); + if (isSearchSeparator(b)) { + continue; + } + if (len >= out.size()) { + break; // defensive; callers reject query.size() > MAX_QUERY_BYTES + } + out[len++] = epub::asciiToLower(b); + } + return len; +} + +bool SearchMatcher::compile(const std::string_view query) { + // Leave the result in a defined (zeroed, length 0) state even on rejection, so + // a caller that ignores the return value never matches against stale bytes. + pattern.fill(0); + prefix.fill(0); + length = 0; + matched = 0; + + // One validity definition (empty / oversized / no searchable byte) shared with + // the UI gate; a query that passes always normalizes to a non-empty pattern. + if (!isValidSearchQuery(query)) { + return false; + } + + // Normalize once (lowercase, spaces/hyphens dropped); the KMP table is built + // over that same pattern, so pattern and prefix can never disagree. + length = normalizeSearchQuery(query, pattern); + + for (size_t i = 1, m = 0; i < length; ++i) { + const uint8_t value = pattern[i]; + while (m > 0 && value != pattern[m]) { + m = prefix[m - 1]; + } + if (value == pattern[m]) { + ++m; + } + prefix[i] = static_cast(m); + } + return true; +} + +bool SearchMatcher::feed(uint8_t c) { + if (isSearchSeparator(c)) { + return false; // spaces/hyphens are insignificant on both sides + } + const uint8_t value = epub::asciiToLower(c); + while (matched > 0 && value != pattern[matched]) { + matched = prefix[matched - 1]; + } + if (value == pattern[matched]) { + ++matched; + if (matched == length) { + return true; + } + } + return false; +} diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h new file mode 100644 index 0000000000..d67bdf92df --- /dev/null +++ b/lib/Epub/Epub/SearchMatcher.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +class SearchMatcher { + public: + static constexpr size_t MAX_QUERY_BYTES = 64; + + std::array pattern{}; + std::array prefix{}; + size_t length = 0; + size_t matched = 0; + + // Single source of truth for whether a query is usable for search: non-empty, + // not all-whitespace, and within the byte limit. The UI validates with this + // before launching a search. + static bool isValidSearchQuery(std::string_view query); + + // Compile a query once for a book-wide search: normalize it and build the KMP + // failure table over the result, so every page scan reuses one consistent + // pattern + table. Returns false for an empty/oversized query or one that + // normalizes to nothing (e.g. only spaces or hyphens). + bool compile(std::string_view query); + + // Feed one character into the matcher. Returns true if a match is completed. + // Separator characters (spaces and hyphens) are ignored and skipped. + bool feed(uint8_t c); + + void reset() { matched = 0; } + + private: + static size_t normalizeSearchQuery(std::string_view query, std::array& out); +}; From 7ec9a2c518e847df62bbd7adf6877c4fbbe56745 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 17:17:57 -0700 Subject: [PATCH 10/91] refactor: optimize search architecture with batched reads and unified KMP - Replaces per-page SD card scanning with `Section::scanForward` which batches LUT reads and streams text records sequentially. - Deduplicates KMP highlight loop in `EpubReaderActivity` by sharing `SearchMatcher`. - Simplifies `EpubReaderSearchActivity` state machine and implements cooperative 50-page chunking to maintain device responsiveness during long searches. --- lib/Epub/Epub/Section.cpp | 175 +++++++----------- lib/Epub/Epub/Section.h | 41 +--- src/activities/reader/EpubReaderActivity.cpp | 35 ++-- .../reader/EpubReaderSearchActivity.cpp | 90 ++++----- .../reader/EpubReaderSearchActivity.h | 18 +- 5 files changed, 134 insertions(+), 225 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index df3e0a0ae3..06aa0614b0 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -669,66 +669,12 @@ bool Section::ensureSearchHeader() { return true; } -bool Section::isValidSearchQuery(const std::string_view query) { - if (query.empty() || query.size() > MAX_SEARCH_QUERY_BYTES) { - return false; - } - // Require at least one byte that survives normalization. The matcher ignores - // ASCII spaces and hyphens (see normalizeSearchQuery), so a query of only - // whitespace and/or hyphens normalizes to nothing and can never match; the UI - // gate must agree with the matcher on what is searchable. - return std::any_of(query.begin(), query.end(), - [](const unsigned char value) { return std::isspace(value) == 0 && !isSearchSeparator(value); }); -} - -size_t Section::normalizeSearchQuery(const std::string_view query, std::array& out) { - size_t len = 0; - for (const char c : query) { - const uint8_t b = static_cast(c); - if (isSearchSeparator(b)) { - continue; - } - if (len >= out.size()) { - break; // defensive; callers reject query.size() > MAX_SEARCH_QUERY_BYTES - } - out[len++] = epub::asciiToLower(b); +std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher) { + if (startPage >= pageCount || startPage >= endPage) { + return -1; } - return len; -} - -bool Section::compileSearchQuery(const std::string_view query, CompiledSearchQuery& out) { - // Leave the result in a defined (zeroed, length 0) state even on rejection, so - // a caller that ignores the return value never matches against stale bytes. - out = CompiledSearchQuery{}; - // One validity definition (empty / oversized / no searchable byte) shared with - // the UI gate; a query that passes always normalizes to a non-empty pattern. - if (!isValidSearchQuery(query)) { - return false; - } - - // Normalize once (lowercase, spaces/hyphens dropped); the KMP table is built - // over that same pattern, so pattern and prefix can never disagree. - out.length = normalizeSearchQuery(query, out.pattern); - - for (size_t i = 1, matched = 0; i < out.length; ++i) { - const uint8_t value = out.pattern[i]; - while (matched > 0 && value != out.pattern[matched]) { - matched = out.prefix[matched - 1]; - } - if (value == out.pattern[matched]) { - ++matched; - } - out.prefix[i] = static_cast(matched); - } - return true; -} - -std::optional Section::pageContainsText(const uint16_t page, const CompiledSearchQuery& query, size_t& matched) { - if (query.length == 0 || page >= pageCount) { - LOG_ERR("SCT", "Invalid page search request (page=%u count=%u patternLen=%u)", page, pageCount, - static_cast(query.length)); - closeSearchState(); - return std::nullopt; + if (endPage > pageCount) { + endPage = pageCount; } // File size and page-LUT offset are invariant per section; read them once. @@ -738,84 +684,87 @@ std::optional Section::pageContainsText(const uint16_t page, const Compile const uint32_t fileSize = searchFileSize; const uint32_t lutOffset = searchLutOffset; - // Compute in 64-bit so a corrupt (huge) lutOffset cannot wrap the uint32 sum - // into a small in-bounds value that slips past the fileSize bounds check. - const uint64_t entryOffset = static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * page; - if (lutOffset == 0 || entryOffset > fileSize || fileSize - entryOffset < PAGE_LUT_ENTRY_SIZE) { - LOG_ERR("SCT", "Search failed: invalid page LUT entry"); + const uint16_t count = endPage - startPage; + const uint64_t entryOffset = + static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * startPage; + if (lutOffset == 0 || entryOffset > fileSize || + fileSize - entryOffset < static_cast(PAGE_LUT_ENTRY_SIZE) * count) { + LOG_ERR("SCT", "Search failed: invalid page LUT entry range"); closeSearchState(); return std::nullopt; } - if (!file.seek(static_cast(entryOffset) + sizeof(uint32_t))) { - LOG_ERR("SCT", "Search failed: could not seek to page LUT entry"); - closeSearchState(); - return std::nullopt; - } - uint32_t searchTextOffset = 0; - if (file.read(reinterpret_cast(&searchTextOffset), sizeof(searchTextOffset)) != sizeof(searchTextOffset) || - searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { - LOG_ERR("SCT", "Search failed: invalid text record offset"); - closeSearchState(); - return std::nullopt; - } - - if (!file.seek(searchTextOffset)) { - LOG_ERR("SCT", "Search failed: could not seek to text record"); + // Batch read the LUT entries for the requested page range + std::vector lutBuf(count * PAGE_LUT_ENTRY_SIZE); + if (!file.seek(static_cast(entryOffset))) { + LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); closeSearchState(); return std::nullopt; } - uint32_t remaining = 0; - if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || - remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { - LOG_ERR("SCT", "Search failed: invalid text record length"); + if (file.read(lutBuf.data(), lutBuf.size()) != lutBuf.size()) { + LOG_ERR("SCT", "Search failed: could not read page LUT entries"); closeSearchState(); return std::nullopt; } - // A page with no searchable text (e.g. image-only) is a content discontinuity, - // so drop any carried partial match rather than bridging across it. - if (remaining == 0) { - matched = 0; - return false; + std::vector textOffsets; + textOffsets.reserve(count); + for (uint16_t i = 0; i < count; i++) { + uint32_t offset = 0; + // searchTextOffset is the 2nd uint32_t in the LUT entry + memcpy(&offset, lutBuf.data() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); + textOffsets.push_back(offset); } - // KMP keeps overlap handling correct while streaming through a 64-byte SD - // read buffer. The query's normalized pattern and failure table were compiled - // once (compileSearchQuery); the scan skips spaces and hyphens in the record - // so layout hyphenation and spacing differences do not block a match. `matched` - // is carried in from the previous adjacent page so a query split across a page - // boundary still matches. - // Left uninitialized: file.read() fills chunkSize bytes and only [0,chunkSize) - // is ever read, so the per-page zero-fill would be dead work. + // Sequentially read the text records std::array buffer; - while (remaining > 0) { - const size_t chunkSize = std::min(buffer.size(), remaining); - if (file.read(buffer.data(), chunkSize) != chunkSize) { - LOG_ERR("SCT", "Search failed: truncated text record"); + for (uint16_t i = 0; i < count; i++) { + const uint32_t searchTextOffset = textOffsets[i]; + if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); return std::nullopt; } - remaining -= chunkSize; - for (size_t i = 0; i < chunkSize; ++i) { - if (isSearchSeparator(buffer[i])) { - continue; // spaces/hyphens are insignificant on both sides - } - const uint8_t value = epub::asciiToLower(buffer[i]); - while (matched > 0 && value != query.pattern[matched]) { - matched = query.prefix[matched - 1]; + if (!file.seek(searchTextOffset)) { + LOG_ERR("SCT", "Search failed: could not seek to text record"); + closeSearchState(); + return std::nullopt; + } + + uint32_t remaining = 0; + if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || + remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record length"); + closeSearchState(); + return std::nullopt; + } + + // A page with no searchable text (e.g. image-only) is a content discontinuity, + // so drop any carried partial match rather than bridging across it. + if (remaining == 0) { + matcher.reset(); + continue; + } + + while (remaining > 0) { + const size_t chunkSize = std::min(buffer.size(), remaining); + if (file.read(buffer.data(), chunkSize) != chunkSize) { + LOG_ERR("SCT", "Search failed: truncated text record"); + closeSearchState(); + return std::nullopt; } - if (value == query.pattern[matched]) { - ++matched; - if (matched == query.length) { - return true; + remaining -= chunkSize; + + for (size_t j = 0; j < chunkSize; ++j) { + if (matcher.feed(buffer[j])) { + return static_cast(startPage + i); } } } } - return false; + return -1; } std::string Section::getTextFromSectionFile() { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 3d10f1df1c..e30f0c349f 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -9,6 +9,7 @@ #include "Epub.h" #include "EpubRenderMode.h" +#include "SearchMatcher.h" class Page; class GfxRenderer; @@ -98,41 +99,11 @@ class Section { // allocation. Intended for sequential, book-wide operations such as search. void resetForSpine(int newSpineIndex); - // Single source of truth for whether a query is usable for search: non-empty, - // not all-whitespace, and within the byte limit. The UI validates with this - // before launching a search. - static bool isValidSearchQuery(std::string_view query); - - // A query compiled once for a whole-book search: the normalized pattern - // (lowercase, spaces and hyphens dropped), its length, and the KMP failure - // table over it. Built by compileSearchQuery(), consumed by pageContainsText(). - struct CompiledSearchQuery { - std::array pattern{}; - std::array prefix{}; - size_t length = 0; - }; - - // Normalize a query for matching: fold ASCII A-Z to lowercase and drop ASCII - // spaces and hyphens (so layout-time hyphenation and spacing differences do - // not block a match). Writes the normalized bytes into `out` and returns the - // normalized length. - static size_t normalizeSearchQuery(std::string_view query, std::array& out); - - // Compile a query once for a book-wide search: normalize it and build the KMP - // failure table over the result, so every page scan reuses one consistent - // pattern + table. Returns false for an empty/oversized query or one that - // normalizes to nothing (e.g. only spaces or hyphens). - static bool compileSearchQuery(std::string_view query, CompiledSearchQuery& out); - - // Streams the compact text record for one page through a fixed-size buffer, - // matching the compiled query while skipping spaces and hyphens in the record. - // `matched` is the KMP partial-match length carried in and out: pass the value - // left by the previous adjacent page so a query split across a page boundary - // (e.g. a line-hyphenated word, "inter-" then "national") still matches; the - // caller must reset it to 0 at any reading-order discontinuity (scan start, - // spine change, wrap). An empty record resets it. nullopt indicates an - // invalid/corrupt cache record; false is a valid miss. - std::optional pageContainsText(uint16_t page, const CompiledSearchQuery& query, size_t& matched); + // Search forward through cached section pages from `startPage` up to `endPage`, + // batching LUT reads and streaming text records sequentially. Returns the + // first page index where `matcher.feed` completes a match, -1 if no match, + // or nullopt if a cache error occurs. + std::optional scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ffa3387f71..3c9d3235e0 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -3415,7 +3416,7 @@ void EpubReaderActivity::launchSearchInput() { } const auto& query = std::get(result.data).text; - if (!Section::isValidSearchQuery(query)) { + if (!SearchMatcher::isValidSearchQuery(query)) { // Surface why the search was not accepted (e.g. whitespace/hyphen-only // input) instead of silently repainting, which reads as a no-op. showTransientMessage(tr(STR_INVALID_SEARCH_QUERY)); @@ -4540,8 +4541,8 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId } // 1. Compile the search query once using KMP - Section::CompiledSearchQuery compiledQuery{}; - if (!Section::compileSearchQuery(lastSearchQuery.data(), compiledQuery)) { + SearchMatcher matcher; + if (!matcher.compile(lastSearchQuery.data())) { return; } @@ -4568,29 +4569,17 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state searchHighlightMatchRanges.clear(); - size_t carryMatched = 0; - for (uint16_t p = 0; p < section->currentPage; ++p) { - section->pageContainsText(p, compiledQuery, carryMatched); - } + section->scanForward(0, section->currentPage, matcher); - size_t matched = carryMatched; for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { - const uint8_t value = searchHighlightPageText[charIndex]; - while (matched > 0 && value != compiledQuery.pattern[matched]) { - matched = compiledQuery.prefix[matched - 1]; - } - if (value == compiledQuery.pattern[matched]) { - ++matched; - if (matched == compiledQuery.length) { - size_t startIdx = (charIndex + 1 >= matched) ? (charIndex + 1 - matched) : 0; - size_t endIdx = charIndex; - if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { - if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { - searchHighlightMatchRanges.push_back( - {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); - } + if (matcher.feed(searchHighlightPageText[charIndex])) { + size_t startIdx = (charIndex + 1 >= matcher.length) ? (charIndex + 1 - matcher.length) : 0; + size_t endIdx = charIndex; + if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { + if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { + searchHighlightMatchRanges.push_back( + {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); } - matched = compiledQuery.prefix[matched - 1]; } } } diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index f381182772..28cc4d99f5 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -55,14 +55,13 @@ EpubReaderSearchActivity::EpubReaderSearchActivity(GfxRenderer& renderer, Mapped viewportWidth(viewportWidth), viewportHeight(viewportHeight) { if (query) { - const size_t length = std::min(strlen(query), this->query.size() - 1); - memcpy(this->query.data(), query, length); - this->query[length] = '\0'; + strncpy(this->query.data(), query, this->query.size() - 1); + this->query[this->query.size() - 1] = '\0'; } // Compile the query once here; every page scan reuses the pattern + table. A // rejected query (empty/oversized, or only separators) means there is nothing // to scan, so fail closed rather than relying solely on the caller's gate. - if (!Section::compileSearchQuery(this->query.data(), compiledQuery)) { + if (!matcher.compile(this->query.data())) { state = SearchState::NotFound; } } @@ -106,7 +105,7 @@ bool EpubReaderSearchActivity::shouldScanWrappedStopContinuation() const { // occurrence that crosses the circular route boundary without changing find // next's originating-page exclusion. return wrapped && route.startPage == route.stopPage && currentSpineIndex == route.startSpineIndex && - currentPage == route.stopPage && scanMatched > 0; + currentPage == route.stopPage && matcher.matched > 0; } void EpubReaderSearchActivity::advanceSpine() { @@ -114,10 +113,14 @@ void EpubReaderSearchActivity::advanceSpine() { currentPage = 0; sectionLoaded = false; sectionCacheRepairAttempted = false; - scanMatched = 0; // spine boundary: don't carry a partial match across chapters + matcher.reset(); // spine boundary: don't carry a partial match across chapters } -bool EpubReaderSearchActivity::loadCurrentSection() { +bool EpubReaderSearchActivity::ensureSectionLoaded() { + if (sectionLoaded) { + return true; + } + section.resetForSpine(currentSpineIndex); const EpubRenderMode renderMode = isValidEpubRenderMode(SETTINGS.epubRenderMode) ? static_cast(SETTINGS.epubRenderMode) @@ -138,6 +141,7 @@ bool EpubReaderSearchActivity::loadCurrentSection() { SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.bionicReadingEnabled, SETTINGS.guideReadingEnabled, nullptr, nullptr, nullptr, renderMode)) { LOG_ERR("EPS", "Failed to build section %d for search", currentSpineIndex); + setFailure(SearchState::Error); return false; } @@ -145,18 +149,7 @@ bool EpubReaderSearchActivity::loadCurrentSection() { return true; } -bool EpubReaderSearchActivity::invalidateCurrentSectionCache() { - // resetForSpine closes the member HalFile before clearCache removes its path. - section.resetForSpine(currentSpineIndex); - sectionLoaded = false; - if (!section.clearCache()) { - LOG_ERR("EPS", "Failed to clear corrupt section %d", currentSpineIndex); - return false; - } - return true; -} - -bool EpubReaderSearchActivity::preparePage() { +bool EpubReaderSearchActivity::advanceSpineIfNeeded() { const int spineCount = epub ? epub->getSpineItemsCount() : 0; if (spineCount <= 0) { setFailure(SearchState::Error); @@ -174,7 +167,7 @@ bool EpubReaderSearchActivity::preparePage() { currentPage = 0; sectionLoaded = false; sectionCacheRepairAttempted = false; - scanMatched = 0; // wrap is not contiguous reading text + matcher.reset(); // wrap is not contiguous reading text } if (reachedWrappedStop() && !shouldScanWrappedStopContinuation()) { @@ -182,9 +175,8 @@ bool EpubReaderSearchActivity::preparePage() { return false; } - if (!sectionLoaded && !loadCurrentSection()) { - setFailure(SearchState::Error); - return false; + if (!ensureSectionLoaded()) { + return false; // ensureSectionLoaded calls setFailure } if (!wrapped && currentSpineIndex == route.startSpineIndex && route.sourcePageCount > 0) { @@ -192,18 +184,12 @@ bool EpubReaderSearchActivity::preparePage() { currentPage = route.startPage; } - // Once the start spine is loaded, capture the byte-weighted positions of the - // scan's start and stop pages and the resulting route length, so progress - // reads against the reader's own progress model rather than a spine-count - // approximation. Done once (scanStartPos stays negative until captured). if (scanStartPos < 0.0f && !wrapped && currentSpineIndex == route.startSpineIndex && epub) { const float pc = section.pageCount > 0 ? static_cast(section.pageCount) : 1.0f; scanStartPos = epub->calculateProgress(route.startSpineIndex, std::min(1.0f, static_cast(route.startPage) / pc)); const float stopPos = epub->calculateProgress(route.startSpineIndex, std::min(1.0f, static_cast(route.stopPage) / pc)); - // Route: forward from start to the end of the book (1.0), wrap, then up to - // the stop page. For a fresh search start == stop, giving a full route of 1. scanRouteLength = (1.0f - scanStartPos) + stopPos; } @@ -216,35 +202,55 @@ bool EpubReaderSearchActivity::preparePage() { } void EpubReaderSearchActivity::scanNextPage() { - if (!preparePage()) { + if (!advanceSpineIfNeeded()) { return; } - const size_t matchedBeforePage = scanMatched; - auto match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); - if (!match.has_value() && !sectionCacheRepairAttempted) { + int endPage = section.pageCount; + if (wrapped && currentSpineIndex == route.startSpineIndex) { + endPage = route.stopPage; + if (shouldScanWrappedStopContinuation()) { + endPage = route.stopPage + 1; // allow scanning the exact stopPage to finish a carried match + } + } + + // Chunk scan to 50 pages at a time to yield to the main render/input loop + endPage = std::min(endPage, currentPage + 50); + + const size_t matchedBeforeChunk = matcher.matched; + auto match = section.scanForward(currentPage, endPage, matcher); + + if (match == std::nullopt && !sectionCacheRepairAttempted) { sectionCacheRepairAttempted = true; - scanMatched = matchedBeforePage; - if (!invalidateCurrentSectionCache() || !loadCurrentSection()) { - setFailure(SearchState::Error); + matcher.matched = matchedBeforeChunk; + + // Invalidate corrupt cache + section.resetForSpine(currentSpineIndex); + sectionLoaded = false; + section.clearCache(); + + if (!ensureSectionLoaded()) { return; } - match = section.pageContainsText(static_cast(currentPage), compiledQuery, scanMatched); + match = section.scanForward(currentPage, endPage, matcher); } - if (!match.has_value()) { + if (match == std::nullopt) { // Do not leave a version-valid but unreadable cache to fail every future search. - invalidateCurrentSectionCache(); + section.resetForSpine(currentSpineIndex); + sectionLoaded = false; + section.clearCache(); setFailure(SearchState::Error); return; } - if (*match) { - setResult(ProgressChangeResult{currentSpineIndex, currentPage}); + + if (*match >= 0) { + setResult(ProgressChangeResult{currentSpineIndex, *match}); finish(); return; } - ++currentPage; + currentPage = endPage; } int EpubReaderSearchActivity::searchProgressPercent() const { diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h index d2758ec714..40989d311f 100644 --- a/src/activities/reader/EpubReaderSearchActivity.h +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -50,10 +51,8 @@ class EpubReaderSearchActivity final : public Activity { std::shared_ptr epub; Section section; - std::array query{}; - // `query` compiled once in the constructor (normalized pattern + KMP table) - // and reused for every page scan instead of being rebuilt per page. - Section::CompiledSearchQuery compiledQuery{}; + std::array query{}; + SearchMatcher matcher; SearchRoute route; int currentSpineIndex; int currentPage; @@ -63,11 +62,7 @@ class EpubReaderSearchActivity final : public Activity { bool sectionLoaded = false; bool sectionCacheRepairAttempted = false; bool wrapped = false; - // KMP partial-match length carried across consecutive pages of the same spine - // so a query split across a page boundary (line-hyphenated word, or a phrase) - // still matches. Reset at every reading-order discontinuity: scan start (0 - // init), spine change (advanceSpine), and the wrap. - size_t scanMatched = 0; + // Last progress percentage painted to the panel. Repaints are gated on this // changing so the e-ink panel is not refreshed per page. Starts at 0 because // onEnter() paints the initial 0% screen before the scan begins. @@ -80,9 +75,8 @@ class EpubReaderSearchActivity final : public Activity { float scanStartPos = -1.0f; float scanRouteLength = 0.0f; - bool preparePage(); - bool loadCurrentSection(); - bool invalidateCurrentSectionCache(); + bool advanceSpineIfNeeded(); + bool ensureSectionLoaded(); bool reachedWrappedStop() const; bool shouldScanWrappedStopContinuation() const; void advanceSpine(); From f341da9a736205152be4d9b9bd3ccb420ed6a7bc Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 17:31:42 -0700 Subject: [PATCH 11/91] perf: optimize search highlight KMP priming Change the drawSearchHighlights priming pass to only scan the immediately preceding page instead of the entire chapter up to the current page. The KMP state machine can only carry over up to MAX_QUERY_BYTES (255) anyway, so one page is enough, changing O(N) chapter scanning to O(1). --- src/activities/reader/EpubReaderActivity.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3c9d3235e0..cdafa56a58 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4569,7 +4569,9 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state searchHighlightMatchRanges.clear(); - section->scanForward(0, section->currentPage, matcher); + if (section->currentPage > 0) { + section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); + } for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { if (matcher.feed(searchHighlightPageText[charIndex])) { From c601156b33883dbf030836bf394e02ff7758c02b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 17:31:57 -0700 Subject: [PATCH 12/91] feat: add diacritic-insensitive search matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a lightweight UTF-8 decoder into SearchMatcher to strip common Latin diacritics during KMP execution. This allows base ASCII queries (e.g., "cafe") to successfully match text with accented characters (e.g., "café"). To preserve accurate highlight rendering boundaries when multi-byte UTF-8 sequences match single-byte ASCII pattern characters, SearchMatcher now maintains a ring buffer of raw byte widths and returns the total byte length of the completed match. --- lib/Epub/Epub/SearchMatcher.cpp | 148 ++++++++++++++++--- lib/Epub/Epub/SearchMatcher.h | 23 ++- lib/Epub/Epub/Section.cpp | 2 +- src/activities/reader/EpubReaderActivity.cpp | 5 +- 4 files changed, 148 insertions(+), 30 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 1868a9b590..0bbd80b81a 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -7,51 +7,99 @@ namespace { constexpr bool isSearchSeparator(const uint8_t b) { return b == ' ' || b == '-'; } + +uint32_t stripLatinDiacritics(uint32_t cp) { + if (cp >= 'A' && cp <= 'Z') return cp + 32; + + if (cp >= 0x00E0 && cp <= 0x00E5) return 'a'; // à á â ã ä å + if (cp >= 0x00C0 && cp <= 0x00C5) return 'a'; // À Á Â Ã Ä Å + if (cp == 0x00E7 || cp == 0x00C7) return 'c'; // ç Ç + if (cp >= 0x00E8 && cp <= 0x00EB) return 'e'; // è é ê ë + if (cp >= 0x00C8 && cp <= 0x00CB) return 'e'; // È É Ê Ë + if (cp >= 0x00EC && cp <= 0x00EF) return 'i'; // ì í î ï + if (cp >= 0x00CC && cp <= 0x00CF) return 'i'; // Ì Í Î Ï + if (cp == 0x00F1 || cp == 0x00D1) return 'n'; // ñ Ñ + if (cp >= 0x00F2 && cp <= 0x00F6) return 'o'; // ò ó ô õ ö + if (cp >= 0x00D2 && cp <= 0x00D6) return 'o'; // Ò Ó Ô Õ Ö + if (cp >= 0x00F9 && cp <= 0x00FC) return 'u'; // ù ú û ü + if (cp >= 0x00D9 && cp <= 0x00DC) return 'u'; // Ù Ú Û Ü + if (cp == 0x00FD || cp == 0x00FF || cp == 0x00DD) return 'y'; // ý ÿ Ý + + if (cp < 256) return epub::asciiToLower(static_cast(cp)); + return cp; +} } // namespace bool SearchMatcher::isValidSearchQuery(const std::string_view query) { if (query.empty() || query.size() > MAX_QUERY_BYTES) { return false; } - // Require at least one byte that survives normalization. The matcher ignores - // ASCII spaces and hyphens (see normalizeSearchQuery), so a query of only - // whitespace and/or hyphens normalizes to nothing and can never match; the UI - // gate must agree with the matcher on what is searchable. - return std::any_of(query.begin(), query.end(), - [](const unsigned char value) { return std::isspace(value) == 0 && !isSearchSeparator(value); }); + // Require at least one byte that survives normalization. + std::array dummy; + return normalizeSearchQuery(query, dummy) > 0; } size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::array& out) { size_t len = 0; - for (const char c : query) { - const uint8_t b = static_cast(c); + uint32_t utf8State = 0; + uint32_t utf8Codepoint = 0; + + for (const char ch : query) { + const uint8_t c = static_cast(ch); + if (utf8State == 0) { + if ((c & 0x80) == 0) { + utf8Codepoint = c; + } else if ((c & 0xE0) == 0xC0) { + utf8Codepoint = c & 0x1F; + utf8State = 1; + continue; + } else if ((c & 0xF0) == 0xE0) { + utf8Codepoint = c & 0x0F; + utf8State = 2; + continue; + } else if ((c & 0xF8) == 0xF0) { + utf8Codepoint = c & 0x07; + utf8State = 3; + continue; + } else { + utf8Codepoint = c; + } + } else { + if ((c & 0xC0) == 0x80) { + utf8Codepoint = (utf8Codepoint << 6) | (c & 0x3F); + utf8State--; + if (utf8State > 0) continue; + } else { + utf8State = 0; + continue; // drop invalid continuation byte and move on + } + } + + uint32_t norm = stripLatinDiacritics(utf8Codepoint); + if (norm > 255) continue; // Pattern only stores 1-byte ASCII chars + + const uint8_t b = static_cast(norm); if (isSearchSeparator(b)) { continue; } if (len >= out.size()) { - break; // defensive; callers reject query.size() > MAX_QUERY_BYTES + break; } - out[len++] = epub::asciiToLower(b); + out[len++] = b; } return len; } bool SearchMatcher::compile(const std::string_view query) { - // Leave the result in a defined (zeroed, length 0) state even on rejection, so - // a caller that ignores the return value never matches against stale bytes. pattern.fill(0); prefix.fill(0); length = 0; - matched = 0; + reset(); - // One validity definition (empty / oversized / no searchable byte) shared with - // the UI gate; a query that passes always normalizes to a non-empty pattern. if (!isValidSearchQuery(query)) { return false; } - // Normalize once (lowercase, spaces/hyphens dropped); the KMP table is built - // over that same pattern, so pattern and prefix can never disagree. length = normalizeSearchQuery(query, pattern); for (size_t i = 1, m = 0; i < length; ++i) { @@ -67,19 +115,73 @@ bool SearchMatcher::compile(const std::string_view query) { return true; } -bool SearchMatcher::feed(uint8_t c) { - if (isSearchSeparator(c)) { - return false; // spaces/hyphens are insignificant on both sides +int SearchMatcher::feed(uint8_t c) { + if (utf8State == 0) { + if ((c & 0x80) == 0) { + utf8Codepoint = c; + utf8BytesConsumed = 1; + } else if ((c & 0xE0) == 0xC0) { + utf8Codepoint = c & 0x1F; + utf8State = 1; + utf8BytesConsumed = 1; + return 0; + } else if ((c & 0xF0) == 0xE0) { + utf8Codepoint = c & 0x0F; + utf8State = 2; + utf8BytesConsumed = 1; + return 0; + } else if ((c & 0xF8) == 0xF0) { + utf8Codepoint = c & 0x07; + utf8State = 3; + utf8BytesConsumed = 1; + return 0; + } else { + utf8Codepoint = c; + utf8BytesConsumed = 1; + } + } else { + if ((c & 0xC0) == 0x80) { + utf8Codepoint = (utf8Codepoint << 6) | (c & 0x3F); + utf8State--; + utf8BytesConsumed++; + if (utf8State > 0) return 0; + } else { + utf8State = 0; + utf8BytesConsumed = 0; + return feed(c); + } + } + + uint32_t norm = stripLatinDiacritics(utf8Codepoint); + + if (norm < 256 && isSearchSeparator(static_cast(norm))) { + pendingSeparatorBytes += utf8BytesConsumed; + return 0; } - const uint8_t value = epub::asciiToLower(c); + + uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; + pendingSeparatorBytes = 0; + + const uint8_t value = norm > 255 ? '?' : static_cast(norm); + + // Track raw byte width of this valid character in the circular buffer + matchByteWidths[widthBufferHead] = totalBytesForThisChar; + widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; + while (matched > 0 && value != pattern[matched]) { matched = prefix[matched - 1]; } if (value == pattern[matched]) { ++matched; if (matched == length) { - return true; + int totalWidth = 0; + for (size_t i = 0; i < length; ++i) { + int index = (widthBufferHead + MAX_QUERY_BYTES - length + i) % MAX_QUERY_BYTES; + totalWidth += matchByteWidths[index]; + } + matched = prefix[matched - 1]; + return totalWidth; } } - return false; + return 0; } diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index d67bdf92df..75ee868882 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -12,6 +12,13 @@ class SearchMatcher { std::array prefix{}; size_t length = 0; size_t matched = 0; + std::array matchByteWidths{}; + + uint32_t utf8State = 0; + uint32_t utf8Codepoint = 0; + uint8_t utf8BytesConsumed = 0; + uint8_t pendingSeparatorBytes = 0; + uint8_t widthBufferHead = 0; // Single source of truth for whether a query is usable for search: non-empty, // not all-whitespace, and within the byte limit. The UI validates with this @@ -24,11 +31,19 @@ class SearchMatcher { // normalizes to nothing (e.g. only spaces or hyphens). bool compile(std::string_view query); - // Feed one character into the matcher. Returns true if a match is completed. + // Feed one byte into the matcher. Decodes UTF-8 and maps Latin diacritics. // Separator characters (spaces and hyphens) are ignored and skipped. - bool feed(uint8_t c); - - void reset() { matched = 0; } + // Returns the total raw byte width of the full match if completed, or 0 otherwise. + int feed(uint8_t c); + + void reset() { + matched = 0; + utf8State = 0; + utf8Codepoint = 0; + utf8BytesConsumed = 0; + pendingSeparatorBytes = 0; + widthBufferHead = 0; + } private: static size_t normalizeSearchQuery(std::string_view query, std::array& out); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 06aa0614b0..23f9f65efe 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -757,7 +757,7 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se remaining -= chunkSize; for (size_t j = 0; j < chunkSize; ++j) { - if (matcher.feed(buffer[j])) { + if (matcher.feed(buffer[j]) > 0) { return static_cast(startPage + i); } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cdafa56a58..804612d3ff 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4574,8 +4574,9 @@ void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId } for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { - if (matcher.feed(searchHighlightPageText[charIndex])) { - size_t startIdx = (charIndex + 1 >= matcher.length) ? (charIndex + 1 - matcher.length) : 0; + int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); + if (matchBytes > 0) { + size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; size_t endIdx = charIndex; if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { From 0f07f7a635f8bf19392784a5ff9ab4bdb4674a05 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 17:32:07 -0700 Subject: [PATCH 13/91] fix(ui): enforce full screen refresh on search completion The intermediate search progress frames correctly use HalDisplay::FAST_REFRESH to avoid screen flashing during long scans. However, when the search transitions to a terminal state (NotFound/Error), a FULL_REFRESH is needed to clear any accumulated ghosting from the progress updates before the user dismisses the overlay. --- src/activities/reader/EpubReaderSearchActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 28cc4d99f5..164e69b23f 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -350,5 +350,5 @@ void EpubReaderSearchActivity::render(RenderLock&&) { const char* confirmLabel = terminal ? tr(STR_DONE) : ""; const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); + renderer.displayBuffer(terminal ? HalDisplay::FULL_REFRESH : HalDisplay::FAST_REFRESH); } From 837731405faf13f401da8975971a129938f145ad Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 18:08:40 -0700 Subject: [PATCH 14/91] fix(epub): skip unsupported codepoints and accumulate separator bytes conditionally in SearchMatcher::feed --- lib/Epub/Epub/SearchMatcher.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 0bbd80b81a..cef50f291e 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -154,15 +154,21 @@ int SearchMatcher::feed(uint8_t c) { uint32_t norm = stripLatinDiacritics(utf8Codepoint); - if (norm < 256 && isSearchSeparator(static_cast(norm))) { - pendingSeparatorBytes += utf8BytesConsumed; + if (norm > 255) { + return 0; + } + + if (isSearchSeparator(static_cast(norm))) { + if (matched > 0) { + pendingSeparatorBytes += utf8BytesConsumed; + } return 0; } uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; pendingSeparatorBytes = 0; - const uint8_t value = norm > 255 ? '?' : static_cast(norm); + const uint8_t value = static_cast(norm); // Track raw byte width of this valid character in the circular buffer matchByteWidths[widthBufferHead] = totalBytesForThisChar; From 3c720bfc7cbeb9d1da995e059fe63981276ed0c8 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 18:08:56 -0700 Subject: [PATCH 15/91] fix(search): validate query length in EpubReaderSearchActivity to prevent invalid truncated queries --- src/activities/reader/EpubReaderSearchActivity.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 164e69b23f..213d513998 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -54,14 +54,21 @@ EpubReaderSearchActivity::EpubReaderSearchActivity(GfxRenderer& renderer, Mapped currentPage(route.startPage), viewportWidth(viewportWidth), viewportHeight(viewportHeight) { + bool ok = false; if (query) { - strncpy(this->query.data(), query, this->query.size() - 1); - this->query[this->query.size() - 1] = '\0'; + const size_t len = strlen(query); + if (len <= SearchMatcher::MAX_QUERY_BYTES) { + strncpy(this->query.data(), query, this->query.size() - 1); + this->query[this->query.size() - 1] = '\0'; + if (matcher.compile(this->query.data())) { + ok = true; + } + } } // Compile the query once here; every page scan reuses the pattern + table. A // rejected query (empty/oversized, or only separators) means there is nothing // to scan, so fail closed rather than relying solely on the caller's gate. - if (!matcher.compile(this->query.data())) { + if (!ok) { state = SearchState::NotFound; } } From 357ef17651b3776ce74db1d260bbf73e24f502d7 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 18:10:11 -0700 Subject: [PATCH 16/91] fix(search): save and restore full SearchMatcher state on cache-repair retry --- src/activities/reader/EpubReaderSearchActivity.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 213d513998..609129cc24 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -224,12 +224,12 @@ void EpubReaderSearchActivity::scanNextPage() { // Chunk scan to 50 pages at a time to yield to the main render/input loop endPage = std::min(endPage, currentPage + 50); - const size_t matchedBeforeChunk = matcher.matched; + const SearchMatcher matcherBeforeChunk = matcher; auto match = section.scanForward(currentPage, endPage, matcher); if (match == std::nullopt && !sectionCacheRepairAttempted) { sectionCacheRepairAttempted = true; - matcher.matched = matchedBeforeChunk; + matcher = matcherBeforeChunk; // Invalidate corrupt cache section.resetForSpine(currentSpineIndex); From 88862317684359da4fb42ababf55fb0fe9721713 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:14:51 -0700 Subject: [PATCH 17/91] fix: reset pending separators on KMP match restart --- lib/Epub/Epub/SearchMatcher.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index cef50f291e..615c4ad55a 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -165,18 +165,22 @@ int SearchMatcher::feed(uint8_t c) { return 0; } + const uint8_t value = static_cast(norm); + + while (matched > 0 && value != pattern[matched]) { + matched = prefix[matched - 1]; + } + + if (matched == 0) { + pendingSeparatorBytes = 0; + } + uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; pendingSeparatorBytes = 0; - const uint8_t value = static_cast(norm); - // Track raw byte width of this valid character in the circular buffer matchByteWidths[widthBufferHead] = totalBytesForThisChar; widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; - - while (matched > 0 && value != pattern[matched]) { - matched = prefix[matched - 1]; - } if (value == pattern[matched]) { ++matched; if (matched == length) { From fd159d7b202baf6edf44f36c77431b7f19c64ec5 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:15:04 -0700 Subject: [PATCH 18/91] fix: use bounded strnlen for search query validation --- src/activities/reader/EpubReaderSearchActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 609129cc24..3f798fe42b 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -56,7 +56,7 @@ EpubReaderSearchActivity::EpubReaderSearchActivity(GfxRenderer& renderer, Mapped viewportHeight(viewportHeight) { bool ok = false; if (query) { - const size_t len = strlen(query); + const size_t len = strnlen(query, SearchMatcher::MAX_QUERY_BYTES + 1); if (len <= SearchMatcher::MAX_QUERY_BYTES) { strncpy(this->query.data(), query, this->query.size() - 1); this->query[this->query.size() - 1] = '\0'; From c5b2141d93d4b75908ead45dbc9fdae4078e9455 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:21:27 -0700 Subject: [PATCH 19/91] feat(search): support multi-character diacritic folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Maps ß to ss, æ to ae, and typographic ligatures (ff, fi, fl, ffi, ffl) to their multi-character equivalents. - Packs the folded characters into a single 32-bit integer returned by stripLatinDiacritics. - Unpacks and feeds each resulting byte iteratively to the KMP matcher state machine. - Attributes the full UTF-8 source byte width to the first mapped character in the sequence, allowing the match width to perfectly correspond to the original multi-byte sequence in the text without disrupting highlighting. --- lib/Epub/Epub/SearchMatcher.cpp | 98 ++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 37 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 615c4ad55a..a6198d8518 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -25,8 +25,18 @@ uint32_t stripLatinDiacritics(uint32_t cp) { if (cp >= 0x00D9 && cp <= 0x00DC) return 'u'; // Ù Ú Û Ü if (cp == 0x00FD || cp == 0x00FF || cp == 0x00DD) return 'y'; // ý ÿ Ý + // Multi-character folding (packed into 32-bit uint) + if (cp == 0x00DF) return 's' | ('s' << 8); // ß -> ss + if (cp == 0x00E6 || cp == 0x00C6) return 'a' | ('e' << 8); // æ Æ -> ae + if (cp == 0x0153 || cp == 0x0152) return 'o' | ('e' << 8); // œ Œ -> oe + if (cp == 0xFB00) return 'f' | ('f' << 8); // ff -> ff + if (cp == 0xFB01) return 'f' | ('i' << 8); // fi -> fi + if (cp == 0xFB02) return 'f' | ('l' << 8); // fl -> fl + if (cp == 0xFB03) return 'f' | ('f' << 8) | ('i' << 16); // ffi -> ffi + if (cp == 0xFB04) return 'f' | ('f' << 8) | ('l' << 16); // ffl -> ffl + if (cp < 256) return epub::asciiToLower(static_cast(cp)); - return cp; + return 0; } } // namespace @@ -76,16 +86,20 @@ size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::ar } uint32_t norm = stripLatinDiacritics(utf8Codepoint); - if (norm > 255) continue; // Pattern only stores 1-byte ASCII chars + if (norm == 0) continue; // Drop characters that don't normalize to ASCII - const uint8_t b = static_cast(norm); - if (isSearchSeparator(b)) { - continue; - } - if (len >= out.size()) { - break; + for (int shift = 0; shift < 32; shift += 8) { + uint8_t b = (norm >> shift) & 0xFF; + if (b == 0) break; + + if (isSearchSeparator(b)) { + continue; + } + if (len >= out.size()) { + break; + } + out[len++] = b; } - out[len++] = b; } return len; } @@ -154,44 +168,54 @@ int SearchMatcher::feed(uint8_t c) { uint32_t norm = stripLatinDiacritics(utf8Codepoint); - if (norm > 255) { + if (norm == 0) { return 0; } - if (isSearchSeparator(static_cast(norm))) { - if (matched > 0) { - pendingSeparatorBytes += utf8BytesConsumed; + int totalWidthReturn = 0; + + for (int shift = 0; shift < 32; shift += 8) { + uint8_t b = (norm >> shift) & 0xFF; + if (b == 0) break; + + if (isSearchSeparator(b)) { + if (matched > 0) { + pendingSeparatorBytes += utf8BytesConsumed; + } + utf8BytesConsumed = 0; + continue; } - return 0; - } - const uint8_t value = static_cast(norm); + const uint8_t value = b; - while (matched > 0 && value != pattern[matched]) { - matched = prefix[matched - 1]; - } + while (matched > 0 && value != pattern[matched]) { + matched = prefix[matched - 1]; + } - if (matched == 0) { + if (matched == 0) { + pendingSeparatorBytes = 0; + } + + uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; + utf8BytesConsumed = 0; pendingSeparatorBytes = 0; - } - uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; - pendingSeparatorBytes = 0; - - // Track raw byte width of this valid character in the circular buffer - matchByteWidths[widthBufferHead] = totalBytesForThisChar; - widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; - if (value == pattern[matched]) { - ++matched; - if (matched == length) { - int totalWidth = 0; - for (size_t i = 0; i < length; ++i) { - int index = (widthBufferHead + MAX_QUERY_BYTES - length + i) % MAX_QUERY_BYTES; - totalWidth += matchByteWidths[index]; + // Track raw byte width of this valid character in the circular buffer + matchByteWidths[widthBufferHead] = totalBytesForThisChar; + widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; + if (value == pattern[matched]) { + ++matched; + if (matched == length) { + int totalWidth = 0; + for (size_t i = 0; i < length; ++i) { + int index = (widthBufferHead + MAX_QUERY_BYTES - length + i) % MAX_QUERY_BYTES; + totalWidth += matchByteWidths[index]; + } + matched = prefix[matched - 1]; + totalWidthReturn = totalWidth; } - matched = prefix[matched - 1]; - return totalWidth; } } - return 0; + + return totalWidthReturn; } From cd7c4f9ee48448e921079634ee498d9112e60b9a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:26:49 -0700 Subject: [PATCH 20/91] docs(search): update architecture doc for Unicode and folding support - Reflects that diacritic stripping and multi-character folding are now implemented. - Removes the 'future extension' item for Unicode case-fold support since it is now completed using zero-RAM code logic. - Updates the limitations to specify that case/diacritic folding covers ASCII and supported Latin characters. --- docs/search-architecture.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index dc4e428837..59092a680b 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -166,9 +166,11 @@ remeasure them when the implementation or toolchain changes. - needs only a prefix table bounded by the 64-byte query limit ASCII `A-Z` bytes are folded to lowercase during comparison without copying the -query or page. Other UTF-8 bytes are compared exactly. Rendered EPUB words are -already NFC-composed by the layout pipeline, but the search path does not -perform general Unicode normalization or case folding. +query or page. The matcher also performs lightweight diacritic stripping and multi-character +folding (e.g., `ß` to `ss`, `æ` to `ae`, `é` to `e`) for Latin characters and common +typographic ligatures. This is implemented as a sequence of packed logic gates in instruction +flash, requiring zero RAM overhead. Rendered EPUB words are already NFC-composed by the layout +pipeline. General full-Unicode normalization is not performed. ASCII spaces and hyphens are treated as insignificant on both sides: `normalizeSearchQuery()` drops them from the query (and the KMP prefix table is @@ -295,8 +297,7 @@ searching. - Search match highlighting uses a high-contrast inverted style (solid black background with white/light text) to make matches immediately stand out on the screen. - Highlighting is transient and scoped: it is only rendered on the initial search-match result page. Turning the page or navigating away automatically clears the highlight state so it does not persist on subsequent reads. - Highlight detection runs at render time by normalizing the current page's visible words (lowercase, hyphens/spaces stripped) and matching them against the normalized query. This guarantees alignment with KMP indexing but adds a minor, one-off CPU and temporary RAM cost during page composition. -- Case-insensitive matching is ASCII-only. Non-ASCII case variants must match - exactly. +- Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Characters outside the supported Latin set must match exactly. - Search text is reconstructed from rendered word tokens with single spaces, so it can differ from the EPUB source in spacing and in words split by layout-time hyphenation. Matching ignores ASCII spaces and hyphens and carries match state @@ -356,8 +357,6 @@ heap alone is insufficient to detect fragmentation. ## Possible future extensions -- Add compact Unicode case-fold support for languages available on the input - method, with an explicit flash budget. - Make section layout cooperatively cancellable if cold-search latency becomes a usability problem. - Reduce per-page seeks during a warm scan. The invariant header state (file @@ -393,6 +392,5 @@ heap alone is insufficient to detect fragmentation. performance- and stability-sensitive code in the project. Defer until exact-spacing search is actually wanted; the current normalized matching is the better trade for finding a half-remembered passage. -- Normalize punctuation and Unicode for cross-medium search. Even with - space/hyphen folding, curly vs straight quotes, em dash vs hyphen, and NFD vs - NFC input can still cause a miss, and case folding is ASCII-only. +- Normalize punctuation for cross-medium search. Even with space/hyphen folding, + curly vs straight quotes and em dash vs hyphen can still cause a miss. From 97f6171e01bd03d08b1892529a5155823855f93e Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:44:01 -0700 Subject: [PATCH 21/91] refactor: encapsulate SearchMatcher state and optimize compile() --- lib/Epub/Epub/SearchMatcher.cpp | 5 ++++- lib/Epub/Epub/SearchMatcher.h | 26 ++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index a6198d8518..48d31d85f0 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -110,11 +110,14 @@ bool SearchMatcher::compile(const std::string_view query) { length = 0; reset(); - if (!isValidSearchQuery(query)) { + if (query.empty() || query.size() > MAX_QUERY_BYTES) { return false; } length = normalizeSearchQuery(query, pattern); + if (length == 0) { + return false; + } for (size_t i = 1, m = 0; i < length; ++i) { const uint8_t value = pattern[i]; diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index 75ee868882..d65858c26c 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -8,18 +8,6 @@ class SearchMatcher { public: static constexpr size_t MAX_QUERY_BYTES = 64; - std::array pattern{}; - std::array prefix{}; - size_t length = 0; - size_t matched = 0; - std::array matchByteWidths{}; - - uint32_t utf8State = 0; - uint32_t utf8Codepoint = 0; - uint8_t utf8BytesConsumed = 0; - uint8_t pendingSeparatorBytes = 0; - uint8_t widthBufferHead = 0; - // Single source of truth for whether a query is usable for search: non-empty, // not all-whitespace, and within the byte limit. The UI validates with this // before launching a search. @@ -45,6 +33,20 @@ class SearchMatcher { widthBufferHead = 0; } + bool hasPartialMatch() const { return matched > 0; } + private: + std::array pattern{}; + std::array prefix{}; + size_t length = 0; + size_t matched = 0; + std::array matchByteWidths{}; + + uint32_t utf8State = 0; + uint32_t utf8Codepoint = 0; + uint8_t utf8BytesConsumed = 0; + uint8_t pendingSeparatorBytes = 0; + uint8_t widthBufferHead = 0; + static size_t normalizeSearchQuery(std::string_view query, std::array& out); }; From 078c2b8c5379ad4f0227dc6bc0ac79e24e9fca6e Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:44:13 -0700 Subject: [PATCH 22/91] refactor: extract SearchHighlighter and consolidate utilities --- src/activities/reader/EpubReaderActivity.cpp | 237 ++++-------------- src/activities/reader/EpubReaderActivity.h | 8 +- .../reader/EpubReaderSearchActivity.cpp | 2 +- src/activities/reader/EpubReaderUtils.h | 42 ++++ src/activities/reader/SearchHighlighter.cpp | 118 +++++++++ src/activities/reader/SearchHighlighter.h | 28 +++ 6 files changed, 237 insertions(+), 198 deletions(-) create mode 100644 src/activities/reader/SearchHighlighter.cpp create mode 100644 src/activities/reader/SearchHighlighter.h diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 804612d3ff..58740cd8ea 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -287,44 +287,10 @@ bool advanceClipCursorToToken(const std::string& text, const uint16_t targetInde bool wordMatchesToken(const std::string& word, const char* token, const size_t tokenLen) { if (!token || tokenLen == 0) return false; - const char* visibleWord = word.c_str() + (hasEmSpacePrefix(word) ? 3 : 0); + const char* visibleWord = word.c_str() + (EpubReaderUtils::hasEmSpacePrefix(word) ? 3 : 0); return std::strlen(visibleWord) == tokenLen && std::strncmp(visibleWord, token, tokenLen) == 0; } -template -bool forEachVisiblePageWord(const Page& page, Callback&& callback) { - uint16_t wordIndex = 0; - for (const auto& element : page.elements) { - if (element->getTag() != TAG_PageLine) continue; - const auto& line = static_cast(*element); - if (!line.getBlock()) continue; - - const auto& block = *line.getBlock(); - const auto& wordList = block.getWords(); - const auto& xpos = block.getWordXpos(); - const auto& styles = block.getWordStyles(); - const size_t count = std::min({wordList.size(), xpos.size(), styles.size()}); - for (size_t i = 0; i < count; ++i) { - const std::string& word = wordList[i]; - const char* visibleWord = word.c_str() + (hasEmSpacePrefix(word) ? 3 : 0); - bool hasVisibleText = false; - for (const char* p = visibleWord; *p != '\0'; ++p) { - if (*p != ' ' && *p != '\t' && *p != '\r' && *p != '\n') { - hasVisibleText = true; - break; - } - } - if (!hasVisibleText) continue; - - if (!callback(wordIndex, line, block, i)) { - return false; - } - wordIndex++; - } - } - return true; -} - bool matchClipRunFromPageWord(const Page& page, const Clipping& clipping, const uint16_t startPageWord, const uint16_t startClipToken, const uint16_t minPartialMatch, ClippingPageMatch& match) { const char* cursor = nullptr; @@ -339,25 +305,26 @@ bool matchClipRunFromPageWord(const Page& page, const Clipping& clipping, const bool reachedClipEnd = false; bool stoppedByMismatch = false; - forEachVisiblePageWord(page, [&](const uint16_t wordIndex, const PageLine&, const TextBlock& block, const size_t i) { - if (wordIndex < startPageWord) { - return true; - } + EpubReaderUtils::forEachVisiblePageWord( + page, [&](const uint16_t wordIndex, const PageLine&, const TextBlock& block, const size_t i) { + if (wordIndex < startPageWord) { + return true; + } - const std::string& word = block.getWords()[i]; - if (!wordMatchesToken(word, token, tokenLen)) { - stoppedByMismatch = true; - return false; - } + const std::string& word = block.getWords()[i]; + if (!wordMatchesToken(word, token, tokenLen)) { + stoppedByMismatch = true; + return false; + } - matchedTokens++; - lastWord = wordIndex; - if (!nextClipToken(cursor, token, tokenLen)) { - reachedClipEnd = true; - return false; - } - return true; - }); + matchedTokens++; + lastWord = wordIndex; + if (!nextClipToken(cursor, token, tokenLen)) { + reachedClipEnd = true; + return false; + } + return true; + }); if (matchedTokens == 0) { return false; @@ -391,32 +358,33 @@ bool findClippingTextOnPage(const Page& page, const Clipping& clipping, Clipping bool found = false; - forEachVisiblePageWord(page, [&](const uint16_t wordIndex, const PageLine&, const TextBlock& block, const size_t i) { - const std::string& word = block.getWords()[i]; - const char* cursor = clipping.text.c_str(); - const char* token = nullptr; - size_t tokenLen = 0; - uint16_t tokenIndex = 0; - while (nextClipToken(cursor, token, tokenLen)) { - if (tokenIndex >= tokenCount) { - break; - } - if (wordMatchesToken(word, token, tokenLen) && - matchClipRunFromPageWord(page, clipping, wordIndex, tokenIndex, minPartialMatch, match)) { - found = true; - return false; - } - tokenIndex++; - } - return true; - }); + EpubReaderUtils::forEachVisiblePageWord( + page, [&](const uint16_t wordIndex, const PageLine&, const TextBlock& block, const size_t i) { + const std::string& word = block.getWords()[i]; + const char* cursor = clipping.text.c_str(); + const char* token = nullptr; + size_t tokenLen = 0; + uint16_t tokenIndex = 0; + while (nextClipToken(cursor, token, tokenLen)) { + if (tokenIndex >= tokenCount) { + break; + } + if (wordMatchesToken(word, token, tokenLen) && + matchClipRunFromPageWord(page, clipping, wordIndex, tokenIndex, minPartialMatch, match)) { + found = true; + return false; + } + tokenIndex++; + } + return true; + }); return found; } uint16_t countVisiblePageWords(const Page& page) { uint16_t count = 0; - forEachVisiblePageWord(page, [&](const uint16_t, const PageLine&, const TextBlock&, const size_t) { + EpubReaderUtils::forEachVisiblePageWord(page, [&](const uint16_t, const PageLine&, const TextBlock&, const size_t) { if (count == UINT16_MAX) return false; count++; return true; @@ -1700,12 +1668,6 @@ void EpubReaderActivity::onEnter() { return; } - // Pre-allocate search highlight buffers to avoid render-path heap churn - searchHighlightQuery.reserve(64); - searchHighlightPageText.reserve(4096); - searchHighlightCharToWordIndex.reserve(4096); - searchHighlightMatchRanges.reserve(128); - captureGlobalReaderSettings(); epub->setupCacheDir(); loadBookReaderSettings(); @@ -2947,7 +2909,7 @@ void EpubReaderActivity::startClipSelection() { const bool newLine = i == 0 || words[i].pageIdx != words[i - 1].pageIdx || words[i].y != words[i - 1].y; if (!newLine) continue; - const bool byEmSpace = hasEmSpacePrefix(words[i].text); + const bool byEmSpace = EpubReaderUtils::hasEmSpacePrefix(words[i].text); const bool byIndent = !byEmSpace && previousLineFirstIdx >= 0 && words[i].x > words[previousLineFirstIdx].x + indentThreshold && !endsWithHyphen(words[i - 1].text); @@ -4269,7 +4231,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const auto finalizeBufferComposition = [&]() { drawClippingHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); - drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); + searchHighlighter.drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft, section.get(), + lastSearchQuery.data(), renderer); drawPublisherPageMarkers(renderer, *page, orientedMarginTop, contentBottom, foregroundBlack); }; @@ -4491,7 +4454,7 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font return false; }; - forEachVisiblePageWord( + EpubReaderUtils::forEachVisiblePageWord( page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { if (!isHighlightedWord(pageWordIndex)) { return true; @@ -4505,7 +4468,7 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font } const std::string& wordText = wordList[i]; - const bool hasEmSpace = hasEmSpacePrefix(wordText); + const bool hasEmSpace = EpubReaderUtils::hasEmSpacePrefix(wordText); const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; @@ -4515,7 +4478,7 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font const int wordH = renderer.getLineHeight(fontId); if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { const std::string& nextWordText = wordList[i + 1]; - const bool nextHasEmSpace = hasEmSpacePrefix(nextWordText); + const bool nextHasEmSpace = EpubReaderUtils::hasEmSpacePrefix(nextWordText); const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; @@ -4533,114 +4496,6 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font }); } -void EpubReaderActivity::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, - const int orientedMarginLeft) const { - if (lastSearchQuery[0] == '\0' || !section || currentSpineIndex != lastSearchResultSpine || - section->currentPage != lastSearchResultPage) { - return; - } - - // 1. Compile the search query once using KMP - SearchMatcher matcher; - if (!matcher.compile(lastSearchQuery.data())) { - return; - } - - // 2. Normalize the page text and map characters to word indices - searchHighlightPageText.clear(); - searchHighlightCharToWordIndex.clear(); - - forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - const std::string& wordText = block.getWords()[i]; - for (char c : wordText) { - if (c == ' ' || c == '-') { - continue; - } - if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || - searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { - return false; - } - searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); - searchHighlightCharToWordIndex.push_back(pageWordIndex); - } - return true; - }); - - // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state - searchHighlightMatchRanges.clear(); - if (section->currentPage > 0) { - section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); - } - - for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { - int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); - if (matchBytes > 0) { - size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; - size_t endIdx = charIndex; - if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { - if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { - searchHighlightMatchRanges.push_back( - {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); - } - } - } - } - - if (searchHighlightMatchRanges.empty()) { - return; - } - - // 4. Highlight matched words on page - const bool foregroundBlack = ReaderUtils::readerForegroundBlack(); - const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { - return std::any_of( - searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), - [pageWordIndex](const auto& range) { return pageWordIndex >= range.first && pageWordIndex <= range.second; }); - }; - - forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - if (!isSearchMatchWord(pageWordIndex)) { - return true; - } - - const auto& wordList = block.getWords(); - const auto& xpos = block.getWordXpos(); - const auto& styles = block.getWordStyles(); - if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { - return true; - } - - const std::string& wordText = wordList[i]; - const bool hasEmSpace = hasEmSpacePrefix(wordText); - const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); - const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); - const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; - const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; - const int wordY = orientedMarginTop + line.yPos; - int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; - const int wordH = renderer.getLineHeight(fontId); - if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { - const std::string& nextWordText = wordList[i + 1]; - const bool nextHasEmSpace = hasEmSpacePrefix(nextWordText); - const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); - const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; - const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; - if (isSearchMatchWord(pageWordIndex + 1) && nextWordX > wordX + wordW) { - wordW = nextWordX - wordX; - } else if (nextWordX > wordX && wordW > nextWordX - wordX) { - wordW = nextWordX - wordX; - } - } - if (wordW > 0) { - renderer.fillRect(wordX, wordY, wordW, wordH, true); - renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); - } - return true; - }); -} - void EpubReaderActivity::renderStatusBar() const { const int currentPage = section->currentPage + 1; const int pageCount = section->pageCount; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index a8c218267b..69c034bf55 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -15,6 +15,7 @@ #include "BookmarkStore.h" #include "EpubReaderMenuActivity.h" #include "GlobalReadingStats.h" +#include "SearchHighlighter.h" #include "activities/Activity.h" class EpubReaderActivity final : public Activity { @@ -129,11 +130,7 @@ class EpubReaderActivity final : public Activity { std::array lastSearchQuery{}; int lastSearchResultSpine = -1; int lastSearchResultPage = -1; - // Reusable buffers for search highlighting to avoid render-path allocations - mutable std::string searchHighlightQuery; - mutable std::string searchHighlightPageText; - mutable std::vector searchHighlightCharToWordIndex; - mutable std::vector> searchHighlightMatchRanges; + SearchHighlighter searchHighlighter; // Tracks whether this book is currently removed from Recent Books by the // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). bool recentsEntryRemoved = false; @@ -154,7 +151,6 @@ class EpubReaderActivity final : public Activity { void renderContents(std::unique_ptr page, int fontId, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void drawClippingHighlights(const Page& page, int fontId, int orientedMarginTop, int orientedMarginLeft) const; - void drawSearchHighlights(const Page& page, int fontId, int orientedMarginTop, int orientedMarginLeft) const; void renderStatusBar() const; bool shouldUseFootnotePreview(int targetSpineIndex, const std::string& anchor) const; std::string footnotePreviewCacheSuffix(EpubRenderMode renderMode, const std::string& anchor) const; diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 3f798fe42b..ce8f1ec15a 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -112,7 +112,7 @@ bool EpubReaderSearchActivity::shouldScanWrappedStopContinuation() const { // occurrence that crosses the circular route boundary without changing find // next's originating-page exclusion. return wrapped && route.startPage == route.stopPage && currentSpineIndex == route.startSpineIndex && - currentPage == route.stopPage && matcher.matched > 0; + currentPage == route.stopPage && matcher.hasPartialMatch(); } void EpubReaderSearchActivity::advanceSpine() { diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index 79864e3569..c58e362165 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -4,9 +4,13 @@ #include #include +#include #include +#include #include +#include "Epub/Page.h" + namespace EpubReaderUtils { struct Progress { @@ -133,4 +137,42 @@ inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCou return true; } +inline bool hasEmSpacePrefix(const std::string& word) { + return word.size() >= 3 && word.compare(0, 3, "\xe2\x80\x83") == 0; +} + +template +bool forEachVisiblePageWord(const Page& page, Callback&& callback) { + uint16_t wordIndex = 0; + for (const auto& element : page.elements) { + if (element->getTag() != TAG_PageLine) continue; + const auto& line = static_cast(*element); + if (!line.getBlock()) continue; + + const auto& block = *line.getBlock(); + const auto& wordList = block.getWords(); + const auto& xpos = block.getWordXpos(); + const auto& styles = block.getWordStyles(); + const size_t count = std::min({wordList.size(), xpos.size(), styles.size()}); + for (size_t i = 0; i < count; ++i) { + const std::string& word = wordList[i]; + const char* visibleWord = word.c_str() + (hasEmSpacePrefix(word) ? 3 : 0); + bool hasVisibleText = false; + for (const char* p = visibleWord; *p != '\0'; ++p) { + if (*p != ' ' && *p != '\t' && *p != '\r' && *p != '\n') { + hasVisibleText = true; + break; + } + } + if (!hasVisibleText) continue; + + if (!callback(wordIndex, line, block, i)) { + return false; + } + wordIndex++; + } + } + return true; +} + } // namespace EpubReaderUtils diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp new file mode 100644 index 0000000000..a90d2b79df --- /dev/null +++ b/src/activities/reader/SearchHighlighter.cpp @@ -0,0 +1,118 @@ +#include "SearchHighlighter.h" + +#include +#include +#include +#include + +#include +#include + +#include "EpubReaderUtils.h" + +void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, + const int orientedMarginLeft, Section* section, + const char* lastSearchQuery, GfxRenderer& renderer) const { + if (lastSearchQuery == nullptr || lastSearchQuery[0] == '\0' || !section) { + return; + } + + // 1. Compile the search query once using KMP + SearchMatcher matcher; + if (!matcher.compile(lastSearchQuery)) { + return; + } + + // 2. Normalize the page text and map characters to word indices + searchHighlightPageText.clear(); + searchHighlightCharToWordIndex.clear(); + + EpubReaderUtils::forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + const std::string& wordText = block.getWords()[i]; + for (char c : wordText) { + if (c == ' ' || c == '-') { + continue; + } + if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || + searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { + return false; + } + searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); + searchHighlightCharToWordIndex.push_back(pageWordIndex); + } + return true; + }); + + // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state + searchHighlightMatchRanges.clear(); + if (section->currentPage > 0) { + section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); + } + + for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { + int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); + if (matchBytes > 0) { + size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; + size_t endIdx = charIndex; + if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { + if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { + searchHighlightMatchRanges.push_back( + {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); + } + } + } + } + + if (searchHighlightMatchRanges.empty()) { + return; + } + + // 4. Highlight matched words on page + const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { + return std::any_of( + searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), + [pageWordIndex](const auto& range) { return pageWordIndex >= range.first && pageWordIndex <= range.second; }); + }; + + EpubReaderUtils::forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + if (!isSearchMatchWord(pageWordIndex)) { + return true; + } + + const auto& wordList = block.getWords(); + const auto& xpos = block.getWordXpos(); + const auto& styles = block.getWordStyles(); + if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { + return true; + } + + const std::string& wordText = wordList[i]; + const bool hasEmSpace = EpubReaderUtils::hasEmSpacePrefix(wordText); + const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); + const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); + const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; + const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; + const int wordY = orientedMarginTop + line.yPos; + int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; + const int wordH = renderer.getLineHeight(fontId); + if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { + const std::string& nextWordText = wordList[i + 1]; + const bool nextHasEmSpace = EpubReaderUtils::hasEmSpacePrefix(nextWordText); + const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); + const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; + const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; + if (isSearchMatchWord(pageWordIndex + 1) && nextWordX > wordX + wordW) { + wordW = nextWordX - wordX; + } else if (nextWordX > wordX && wordW > nextWordX - wordX) { + wordW = nextWordX - wordX; + } + } + if (wordW > 0) { + renderer.fillRect(wordX, wordY, wordW, wordH, true); + renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); + } + return true; + }); +} diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h new file mode 100644 index 0000000000..157b28ec5b --- /dev/null +++ b/src/activities/reader/SearchHighlighter.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +class GfxRenderer; +class Page; +class Section; + +class SearchHighlighter { + public: + SearchHighlighter() { + searchHighlightPageText.reserve(4096); + searchHighlightCharToWordIndex.reserve(4096); + searchHighlightMatchRanges.reserve(128); + } + + void drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, + const int orientedMarginLeft, Section* section, const char* lastSearchQuery, + GfxRenderer& renderer) const; + + private: + mutable std::string searchHighlightPageText; + mutable std::vector searchHighlightCharToWordIndex; + mutable std::vector> searchHighlightMatchRanges; +}; From 320fdd035ad12ff6e058a2d50164858c32b89e96 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:44:25 -0700 Subject: [PATCH 23/91] docs: update search architecture to include SearchHighlighter --- docs/search-architecture.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 59092a680b..fc1e34cf52 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -74,6 +74,8 @@ The responsibilities are split as follows: - `EpubReaderMenuActivity` exposes the existing translated `Search` command. - `EpubReaderActivity` owns query history and coordinates the keyboard, search activity, reader position, and result popup. +- `SearchHighlighter` encapsulates the transient on-page text highlighting logic + and manages its own reusable memory buffers to avoid rendering-path allocations. - `EpubReaderSearchActivity` is a small state machine that scans one page per main-loop iteration and distinguishes `Searching`, `NotFound`, and `Error`. - `Page::serializeSearchText()` writes compact searchable text while the page @@ -87,6 +89,7 @@ reach into SdFat directly. Implementation entry points: - reader orchestration: [`EpubReaderActivity.cpp`](../../src/activities/reader/EpubReaderActivity.cpp) +- on-page highlighting: [`SearchHighlighter.cpp`](../../src/activities/reader/SearchHighlighter.cpp) - cooperative scan activity: [`EpubReaderSearchActivity.cpp`](../../src/activities/reader/EpubReaderSearchActivity.cpp) - cache creation and streaming matcher: [`Section.cpp`](../../lib/Epub/Epub/Section.cpp) - per-page text serialization: [`Page.cpp`](../../lib/Epub/Epub/Page.cpp) @@ -143,7 +146,10 @@ The search activity owns one reusable `Section`. `Section::resetForSpine()` changes its spine and cache path in place, avoiding a new/delete cycle for each chapter. Before the activity is allocated, the reader releases its current `Section` and deserialized page graph. This avoids keeping the normal reader -working set and the search working set live together. +working set and the search working set live together. Result highlighting is +delegated to `SearchHighlighter`, which pre-allocates its own reusable vectors +during `EpubReaderActivity` initialization to avoid heap fragmentation in the +render loop. The 12,288-byte LUT reservation is not a new steady-state index. Section layout already needs a data-dependent page LUT; reserving 1,024 entries once avoids From 15cbfa0bd66a3e35f47c71aafd0dcb674c1cc48f Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:48:44 -0700 Subject: [PATCH 24/91] fix: conditionally highlight search results only on matching page --- src/activities/reader/EpubReaderActivity.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 58740cd8ea..d9e8456f67 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4231,8 +4231,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const auto finalizeBufferComposition = [&]() { drawClippingHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); + const char* activeSearchQuery = + (lastSearchResultSpine != -1 && lastSearchResultPage != -1) ? lastSearchQuery.data() : nullptr; searchHighlighter.drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft, section.get(), - lastSearchQuery.data(), renderer); + activeSearchQuery, renderer); drawPublisherPageMarkers(renderer, *page, orientedMarginTop, contentBottom, foregroundBlack); }; From 3bee5bb6fc9300e76246d1a37bb4a5f2f614cacc Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 21:53:48 -0700 Subject: [PATCH 25/91] fix: sum unique source-codepoint widths for search highlighting --- lib/Epub/Epub/SearchMatcher.cpp | 24 +++++++++++++++--------- lib/Epub/Epub/SearchMatcher.h | 3 +++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 48d31d85f0..d83b5d5bcc 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -175,6 +175,11 @@ int SearchMatcher::feed(uint8_t c) { return 0; } + currentCodepointId++; + uint8_t currentCodepointWidth = utf8BytesConsumed + pendingSeparatorBytes; + utf8BytesConsumed = 0; + pendingSeparatorBytes = 0; + int totalWidthReturn = 0; for (int shift = 0; shift < 32; shift += 8) { @@ -183,9 +188,8 @@ int SearchMatcher::feed(uint8_t c) { if (isSearchSeparator(b)) { if (matched > 0) { - pendingSeparatorBytes += utf8BytesConsumed; + pendingSeparatorBytes += currentCodepointWidth; } - utf8BytesConsumed = 0; continue; } @@ -199,20 +203,22 @@ int SearchMatcher::feed(uint8_t c) { pendingSeparatorBytes = 0; } - uint8_t totalBytesForThisChar = utf8BytesConsumed + pendingSeparatorBytes; - utf8BytesConsumed = 0; - pendingSeparatorBytes = 0; - - // Track raw byte width of this valid character in the circular buffer - matchByteWidths[widthBufferHead] = totalBytesForThisChar; + matchByteWidths[widthBufferHead] = currentCodepointWidth; + matchCodepointIds[widthBufferHead] = currentCodepointId; widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; + if (value == pattern[matched]) { ++matched; if (matched == length) { int totalWidth = 0; + uint32_t lastSeenCodepoint = 0; for (size_t i = 0; i < length; ++i) { int index = (widthBufferHead + MAX_QUERY_BYTES - length + i) % MAX_QUERY_BYTES; - totalWidth += matchByteWidths[index]; + uint32_t cpId = matchCodepointIds[index]; + if (cpId != lastSeenCodepoint) { + totalWidth += matchByteWidths[index]; + lastSeenCodepoint = cpId; + } } matched = prefix[matched - 1]; totalWidthReturn = totalWidth; diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index d65858c26c..e3c5a26b8a 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -31,6 +31,7 @@ class SearchMatcher { utf8BytesConsumed = 0; pendingSeparatorBytes = 0; widthBufferHead = 0; + currentCodepointId = 0; } bool hasPartialMatch() const { return matched > 0; } @@ -41,12 +42,14 @@ class SearchMatcher { size_t length = 0; size_t matched = 0; std::array matchByteWidths{}; + std::array matchCodepointIds{}; uint32_t utf8State = 0; uint32_t utf8Codepoint = 0; uint8_t utf8BytesConsumed = 0; uint8_t pendingSeparatorBytes = 0; uint8_t widthBufferHead = 0; + uint32_t currentCodepointId = 0; static size_t normalizeSearchQuery(std::string_view query, std::array& out); }; From f1158e463b833dcc06659f6794871e535a66d998 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 28 Jun 2026 22:12:56 -0700 Subject: [PATCH 26/91] fix: safely multiplex Section file handle to prevent search crash - Fixes issue where Section::loadPageFromSectionFile unconditionally closed the active file handle - Fixes issue where Section accessor methods attempted to open multiple file handles - Wraps the persistent HalFile instance in a ScopedSectionFile RAII guard to safely borrow the active handle and prevent multiple concurrent open file errors on ESP32-C3 - Removed const qualifiers from accessor methods to correctly reflect that file state may be mutated --- lib/Epub/Epub/Section.cpp | 127 ++++++++++++++++++++------------------ lib/Epub/Epub/Section.h | 10 +-- 2 files changed, 72 insertions(+), 65 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 23f9f65efe..0ef97a29bf 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -57,6 +57,24 @@ bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCa static_assert(sizeof(PageLutEntry) == 12, "Unexpected PageLutEntry padding changes the transient RAM budget"); +struct ScopedSectionFile { + HalFile& file; + bool openedLocally; + ScopedSectionFile(HalFile& f, const std::string& path) : file(f), openedLocally(false) { + if (!file) { + if (Storage.openFileForRead("SCT", path, file)) { + openedLocally = true; + } + } + } + ~ScopedSectionFile() { + if (openedLocally) { + file.close(); + } + } + bool ok() const { return static_cast(file); } +}; + // On-disk page LUT stride: only pageOffset and searchTextOffset are stored // inline; paragraphIndex and listItemIndex are written to separate LUTs. constexpr size_t PAGE_LUT_ENTRY_SIZE = sizeof(uint32_t) * 2; @@ -536,33 +554,30 @@ bool Section::readPageLutOffset(uint32_t& lutOffset) { } std::unique_ptr Section::loadPageFromSectionFile() { - if (!Storage.openFileForRead("SCT", filePath, file)) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return nullptr; } const uint32_t fileSize = file.size(); if (fileSize < HEADER_SIZE) { LOG_ERR("SCT", "Section cache header is truncated"); - file.close(); return nullptr; } uint32_t lutOffset = 0; if (!readPageLutOffset(lutOffset)) { LOG_ERR("SCT", "Failed to read page LUT offset"); - file.close(); return nullptr; } if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { LOG_ERR("SCT", "Failed to seek to page count"); - file.close(); return nullptr; } uint16_t headerPageCount = 0; if (!serialization::tryReadPod(file, headerPageCount)) { LOG_ERR("SCT", "Failed to read page count from header"); - file.close(); return nullptr; } @@ -571,37 +586,29 @@ std::unique_ptr Section::loadPageFromSectionFile() { // wrap the uint32 sum into a small in-bounds value. if (lutOffset == 0 || currentPage < 0 || static_cast(currentPage) >= headerPageCount) { LOG_ERR("SCT", "Invalid page LUT request"); - file.close(); return nullptr; } const uint64_t entryOffset = static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * static_cast(currentPage); if (entryOffset > fileSize || fileSize - entryOffset < PAGE_LUT_ENTRY_SIZE) { LOG_ERR("SCT", "Invalid page LUT entry"); - file.close(); return nullptr; } if (!file.seek(static_cast(entryOffset))) { LOG_ERR("SCT", "Failed to seek to page LUT entry"); - file.close(); return nullptr; } uint32_t pagePos = 0; if (file.read(reinterpret_cast(&pagePos), sizeof(pagePos)) != sizeof(pagePos) || pagePos >= fileSize) { LOG_ERR("SCT", "Failed to read page offset"); - file.close(); return nullptr; } if (!file.seek(pagePos)) { LOG_ERR("SCT", "Failed to seek to page record"); - file.close(); return nullptr; } - auto page = Page::deserialize(file); - // Explicit close() required: member variable persists beyond function scope - file.close(); - return page; + return Page::deserialize(file); } void Section::rebuildFilePathForSpine() { @@ -787,56 +794,56 @@ std::string Section::getTextFromSectionFile() { return fullText; } -std::optional Section::getCachedPageCount() const { - HalFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +std::optional Section::getCachedPageCount() { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return std::nullopt; } - const uint32_t fileSize = f.size(); + const uint32_t fileSize = file.size(); if (fileSize < HEADER_SIZE) { return std::nullopt; } - if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { return std::nullopt; } uint16_t count; - if (!serialization::tryReadPod(f, count)) { + if (!serialization::tryReadPod(file, count)) { return std::nullopt; } return count; } -std::optional Section::getPageForAnchor(const std::string& anchor) const { - FsFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +std::optional Section::getPageForAnchor(const std::string& anchor) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return std::nullopt; } - const uint32_t fileSize = f.size(); - if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 3)) { + const uint32_t fileSize = file.size(); + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 3)) { return std::nullopt; } uint32_t anchorMapOffset; - if (!serialization::tryReadPod(f, anchorMapOffset)) { + if (!serialization::tryReadPod(file, anchorMapOffset)) { return std::nullopt; } if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) { return std::nullopt; } - if (!f.seek(anchorMapOffset)) { + if (!file.seek(anchorMapOffset)) { return std::nullopt; } uint16_t count; - if (!serialization::tryReadPod(f, count)) { + if (!serialization::tryReadPod(file, count)) { return std::nullopt; } for (uint16_t i = 0; i < count; i++) { std::string key; uint16_t page; - if (!serialization::tryReadString(f, key) || !serialization::tryReadPod(f, page)) { + if (!serialization::tryReadString(file, key) || !serialization::tryReadPod(file, page)) { return std::nullopt; } if (key == anchor) { @@ -847,29 +854,29 @@ std::optional Section::getPageForAnchor(const std::string& anchor) con return std::nullopt; } -std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) const { - FsFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return std::nullopt; } - const uint32_t fileSize = f.size(); - if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + const uint32_t fileSize = file.size(); + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { return std::nullopt; } uint32_t paragraphLutOffset; - if (!serialization::tryReadPod(f, paragraphLutOffset)) { + if (!serialization::tryReadPod(file, paragraphLutOffset)) { return std::nullopt; } if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { return std::nullopt; } - if (!f.seek(paragraphLutOffset)) { + if (!file.seek(paragraphLutOffset)) { return std::nullopt; } uint16_t count; - if (!serialization::tryReadPod(f, count)) { + if (!serialization::tryReadPod(file, count)) { return std::nullopt; } if (count == 0) { @@ -884,7 +891,7 @@ std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) uint16_t resultPage = count - 1; for (uint16_t i = 0; i < count; i++) { uint16_t pagePIdx; - if (!serialization::tryReadPod(f, pagePIdx)) { + if (!serialization::tryReadPod(file, pagePIdx)) { return std::nullopt; } if (pagePIdx >= pIndex) { @@ -896,29 +903,29 @@ std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) return resultPage; } -std::optional Section::getParagraphIndexForPage(const uint16_t page) const { - FsFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +std::optional Section::getParagraphIndexForPage(const uint16_t page) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return std::nullopt; } - const uint32_t fileSize = f.size(); - if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + const uint32_t fileSize = file.size(); + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { return std::nullopt; } uint32_t paragraphLutOffset; - if (!serialization::tryReadPod(f, paragraphLutOffset)) { + if (!serialization::tryReadPod(file, paragraphLutOffset)) { return std::nullopt; } if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { return std::nullopt; } - if (!f.seek(paragraphLutOffset)) { + if (!file.seek(paragraphLutOffset)) { return std::nullopt; } uint16_t count; - if (!serialization::tryReadPod(f, count)) { + if (!serialization::tryReadPod(file, count)) { return std::nullopt; } if (count == 0 || page >= count) { @@ -930,28 +937,28 @@ std::optional Section::getParagraphIndexForPage(const uint16_t page) c return std::nullopt; } - if (!f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t))) { + if (!file.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t))) { return std::nullopt; } uint16_t pIdx; - if (!serialization::tryReadPod(f, pIdx)) { + if (!serialization::tryReadPod(file, pIdx)) { return std::nullopt; } return pIdx; } -std::optional Section::getPageForListItemIndex(const uint16_t liIndex) const { - FsFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +std::optional Section::getPageForListItemIndex(const uint16_t liIndex) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return std::nullopt; } - const uint32_t fileSize = f.size(); - if (!f.seek(HEADER_SIZE - sizeof(uint32_t))) { + const uint32_t fileSize = file.size(); + if (!file.seek(HEADER_SIZE - sizeof(uint32_t))) { return std::nullopt; } uint32_t liLutOffset; - if (!serialization::tryReadPod(f, liLutOffset)) { + if (!serialization::tryReadPod(file, liLutOffset)) { return std::nullopt; } if (liLutOffset == 0 || liLutOffset >= fileSize) { @@ -959,22 +966,22 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) } // The li LUT shares count with the paragraph LUT; read count from paragraphLutOffset - if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { return std::nullopt; } uint32_t paragraphLutOffset; - if (!serialization::tryReadPod(f, paragraphLutOffset)) { + if (!serialization::tryReadPod(file, paragraphLutOffset)) { return std::nullopt; } if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { return std::nullopt; } - if (!f.seek(paragraphLutOffset)) { + if (!file.seek(paragraphLutOffset)) { return std::nullopt; } uint16_t count; - if (!serialization::tryReadPod(f, count)) { + if (!serialization::tryReadPod(file, count)) { return std::nullopt; } if (count == 0) { @@ -986,13 +993,13 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) return std::nullopt; } - if (!f.seek(liLutOffset)) { + if (!file.seek(liLutOffset)) { return std::nullopt; } uint16_t resultPage = count - 1; for (uint16_t i = 0; i < count; i++) { uint16_t pageLiIdx; - if (!serialization::tryReadPod(f, pageLiIdx)) { + if (!serialization::tryReadPod(file, pageLiIdx)) { return std::nullopt; } if (pageLiIdx >= liIndex) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index e30f0c349f..f393dcde62 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -93,7 +93,7 @@ class Section { std::string getTextFromSectionFile(); // Get the page count from the section cache file without fully loading it. - std::optional getCachedPageCount() const; + std::optional getCachedPageCount(); // Reuse this Section object for another spine item without another heap // allocation. Intended for sequential, book-wide operations such as search. @@ -106,14 +106,14 @@ class Section { std::optional scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher); // Look up the page number for an anchor id from the section cache file. - std::optional getPageForAnchor(const std::string& anchor) const; + std::optional getPageForAnchor(const std::string& anchor); // Look up the page number for a synthetic paragraph index from XPath p[N]. - std::optional getPageForParagraphIndex(uint16_t pIndex) const; + std::optional getPageForParagraphIndex(uint16_t pIndex); // Look up the page number for a running list-item index from the li LUT. - std::optional getPageForListItemIndex(uint16_t liIndex) const; + std::optional getPageForListItemIndex(uint16_t liIndex); // Look up the synthetic paragraph index for the given rendered page. - std::optional getParagraphIndexForPage(uint16_t page) const; + std::optional getParagraphIndexForPage(uint16_t page); }; From 2068d772fbb3f02b731acfbc097d3ddc2e9520a1 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:20:24 -0700 Subject: [PATCH 27/91] docs: correct search scan loop to 50-page chunk cadence --- docs/search-architecture.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index fc1e34cf52..fb0928cb0d 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -76,8 +76,9 @@ The responsibilities are split as follows: activity, reader position, and result popup. - `SearchHighlighter` encapsulates the transient on-page text highlighting logic and manages its own reusable memory buffers to avoid rendering-path allocations. -- `EpubReaderSearchActivity` is a small state machine that scans one page per - main-loop iteration and distinguishes `Searching`, `NotFound`, and `Error`. +- `EpubReaderSearchActivity` is a small state machine whose `scanNextPage()` + scan loop scans a bounded chunk of up to 50 pages per main-loop iteration + before yielding, and distinguishes `Searching`, `NotFound`, and `Error`. - `Page::serializeSearchText()` writes compact searchable text while the page already exists during layout. - `Section::pageContainsText()` searches one record without deserializing a @@ -285,9 +286,9 @@ interaction through the same menu command. Rejected for the initial implementation. The device is single-core, SdFat access must remain serialized, and a task would add stack and activity-lifetime -coordination. The cooperative activity scans one cached page per loop iteration, -keeps cancellation responsive between pages, and prevents automatic sleep while -searching. +coordination. The cooperative activity scans a bounded chunk of up to 50 cached +pages per loop iteration, keeps cancellation responsive between chunks, and +prevents automatic sleep while searching. ## Accepted trade-offs and limitations From 840aa028b7b0affdd90426bbb269db36da976683 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:20:25 -0700 Subject: [PATCH 28/91] fix: widen search match-width accounting to avoid uint8_t overflow --- lib/Epub/Epub/SearchMatcher.cpp | 2 +- lib/Epub/Epub/SearchMatcher.h | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index d83b5d5bcc..ebfefcd801 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -176,7 +176,7 @@ int SearchMatcher::feed(uint8_t c) { } currentCodepointId++; - uint8_t currentCodepointWidth = utf8BytesConsumed + pendingSeparatorBytes; + uint16_t currentCodepointWidth = utf8BytesConsumed + pendingSeparatorBytes; utf8BytesConsumed = 0; pendingSeparatorBytes = 0; diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index e3c5a26b8a..f5465b1b80 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -41,13 +41,16 @@ class SearchMatcher { std::array prefix{}; size_t length = 0; size_t matched = 0; - std::array matchByteWidths{}; + // Per-codepoint source byte widths. uint16_t (not uint8_t) so a matched span + // whose ignored separators total more than 255 bytes cannot wrap and corrupt + // the reported match width used for highlight offsets. + std::array matchByteWidths{}; std::array matchCodepointIds{}; uint32_t utf8State = 0; uint32_t utf8Codepoint = 0; uint8_t utf8BytesConsumed = 0; - uint8_t pendingSeparatorBytes = 0; + uint16_t pendingSeparatorBytes = 0; uint8_t widthBufferHead = 0; uint32_t currentCodepointId = 0; From f5ca125830e538cfd4ebc5cc0e2d1ca37cde0869 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:23:12 -0700 Subject: [PATCH 29/91] perf: reuse scanForward page-LUT buffer to avoid per-chunk heap churn --- lib/Epub/Epub/Section.cpp | 32 +++++++++++++++++++------------- lib/Epub/Epub/Section.h | 7 +++++++ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 0ef97a29bf..08c266bf2d 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -701,32 +701,38 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se return std::nullopt; } - // Batch read the LUT entries for the requested page range - std::vector lutBuf(count * PAGE_LUT_ENTRY_SIZE); + // Batch read the LUT entries for the requested page range into a reused + // buffer, allocated (or grown) once with nothrow ownership so repeated chunked + // scans do not churn the heap and an allocation failure is a recoverable + // search error rather than an abort. + const size_t lutBytes = static_cast(count) * PAGE_LUT_ENTRY_SIZE; + if (searchLutBufCapacity < lutBytes) { + searchLutBuf = makeUniqueNoThrow(lutBytes); + if (!searchLutBuf) { + searchLutBufCapacity = 0; + LOG_ERR("SCT", "Search failed: OOM for page LUT buffer (%u bytes)", static_cast(lutBytes)); + closeSearchState(); + return std::nullopt; + } + searchLutBufCapacity = lutBytes; + } if (!file.seek(static_cast(entryOffset))) { LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); closeSearchState(); return std::nullopt; } - if (file.read(lutBuf.data(), lutBuf.size()) != lutBuf.size()) { + if (file.read(searchLutBuf.get(), lutBytes) != lutBytes) { LOG_ERR("SCT", "Search failed: could not read page LUT entries"); closeSearchState(); return std::nullopt; } - std::vector textOffsets; - textOffsets.reserve(count); - for (uint16_t i = 0; i < count; i++) { - uint32_t offset = 0; - // searchTextOffset is the 2nd uint32_t in the LUT entry - memcpy(&offset, lutBuf.data() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); - textOffsets.push_back(offset); - } - // Sequentially read the text records std::array buffer; for (uint16_t i = 0; i < count; i++) { - const uint32_t searchTextOffset = textOffsets[i]; + uint32_t searchTextOffset = 0; + // searchTextOffset is the 2nd uint32_t in the LUT entry + memcpy(&searchTextOffset, searchLutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index f393dcde62..fa96f419c3 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -43,6 +43,13 @@ class Section { uint32_t searchFileSize = 0; uint32_t searchLutOffset = 0; + // Reused scratch buffer for batched page-LUT reads in scanForward(). Allocated + // once on first use (nothrow) and grown only if a larger page range appears, + // so repeated chunked scans do not churn the heap; an OOM is a recoverable + // search failure rather than an abort. Freed when the Section is destroyed. + std::unique_ptr searchLutBuf; + size_t searchLutBufCapacity = 0; + bool writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, From 77f71e288dfbe8757f7586ef7c3ff7730278e145 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:23:24 -0700 Subject: [PATCH 30/91] refactor: unify search query limit on SearchMatcher::MAX_QUERY_BYTES --- lib/Epub/Epub/Section.h | 1 - src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/reader/EpubReaderActivity.h | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index fa96f419c3..1a7b19273d 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -68,7 +68,6 @@ class Section { void rebuildFilePathForSpine(); public: - static constexpr size_t MAX_SEARCH_QUERY_BYTES = 64; uint16_t pageCount = 0; int currentPage = 0; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d9e8456f67..e5c5c46d1d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3362,7 +3362,7 @@ void EpubReaderActivity::launchSearchInput() { // The activity allocation is one-shot and owned by ActivityManager; the // 64-byte limit bounds its internal query string. auto keyboard = makeUniqueNoThrow( - renderer, mappedInput, tr(STR_SEARCH), lastSearchQuery.data(), Section::MAX_SEARCH_QUERY_BYTES, InputType::Text); + renderer, mappedInput, tr(STR_SEARCH), lastSearchQuery.data(), SearchMatcher::MAX_QUERY_BYTES, InputType::Text); if (!keyboard) { LOG_ERR("ERS", "OOM: KeyboardEntryActivity (%u bytes)", static_cast(sizeof(KeyboardEntryActivity))); requestUpdate(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 69c034bf55..d8835b1949 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -127,7 +127,7 @@ class EpubReaderActivity final : public Activity { // in the static i18n string table. const char* transientMessage = nullptr; unsigned long transientMessageTime = 0UL; - std::array lastSearchQuery{}; + std::array lastSearchQuery{}; int lastSearchResultSpine = -1; int lastSearchResultPage = -1; SearchHighlighter searchHighlighter; From 9656a6c6f7013b97d31ee9325ef93c1a48a2f935 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:23:34 -0700 Subject: [PATCH 31/91] fix: never return a colliding /Read destination path Make BookMoveUtils::buildReadFolderDestination return an empty path when no free 'name (N)' slot is available instead of handing rename() an existing path, and remove the dead duplicate of this helper left in EpubReaderActivity. --- src/activities/reader/EpubReaderActivity.cpp | 23 -------------------- src/util/BookMoveUtils.cpp | 14 +++++++----- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e5c5c46d1d..785a0afb39 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1070,29 +1070,6 @@ ReaderViewport calculateReaderViewport(GfxRenderer& renderer, const bool automat return viewport; } -// Pick a non-colliding destination path inside /Read/ for a finished book. -// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc. -std::string buildReadFolderDestination(const std::string& srcPath) { - const size_t lastSlash = srcPath.rfind('/'); - const std::string filename = (lastSlash != std::string::npos) ? srcPath.substr(lastSlash + 1) : srcPath; - - Storage.mkdir(READ_FOLDER); - std::string dstPath = std::string(READ_FOLDER) + "/" + filename; - if (!Storage.exists(dstPath.c_str())) { - return dstPath; - } - - const size_t dotPos = filename.rfind('.'); - const std::string base = (dotPos != std::string::npos) ? filename.substr(0, dotPos) : filename; - const std::string ext = (dotPos != std::string::npos) ? filename.substr(dotPos) : ""; - int suffix = 2; - do { - dstPath = std::string(READ_FOLDER) + "/" + base + " (" + std::to_string(suffix) + ")" + ext; - suffix++; - } while (Storage.exists(dstPath.c_str()) && suffix < 100); - return dstPath; -} - } // namespace uint8_t EpubReaderActivity::loadBookRenderMode(const std::string& filePath) { diff --git a/src/util/BookMoveUtils.cpp b/src/util/BookMoveUtils.cpp index c2b86adf6a..a33e05638f 100644 --- a/src/util/BookMoveUtils.cpp +++ b/src/util/BookMoveUtils.cpp @@ -28,12 +28,16 @@ std::string buildReadFolderDestination(const std::string& srcPath) { const size_t dotPos = filename.rfind('.'); const std::string base = (dotPos != std::string::npos) ? filename.substr(0, dotPos) : filename; const std::string ext = (dotPos != std::string::npos) ? filename.substr(dotPos) : ""; - int suffix = 2; - do { + for (int suffix = 2; suffix < 100; ++suffix) { dstPath = std::string(READ_FOLDER) + "/" + base + " (" + std::to_string(suffix) + ")" + ext; - suffix++; - } while (Storage.exists(dstPath.c_str()) && suffix < 100); - return dstPath; + if (!Storage.exists(dstPath.c_str())) { + return dstPath; + } + } + // No free "name (N)" slot under the limit: signal failure rather than hand + // rename() a path that already exists (which would clobber another book). + LOG_ERR("BookMove", "No free destination name in %s for %s", READ_FOLDER, filename.c_str()); + return ""; } bool migrateMovedEpubState(const std::string& oldPath, const std::string& newPath, const std::string& oldCachePath, From a8831b971c5e69ebbfaa46fcf1594e8ad97936ef Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:45:52 -0700 Subject: [PATCH 32/91] refactor: remove dead getTextFromSectionFile() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It had no callers and rebuilt a full Page plus a heap std::string via repeated += (no reserve) — the exact reconstruction the search-text records were designed to avoid. --- lib/Epub/Epub/Section.cpp | 20 -------------------- lib/Epub/Epub/Section.h | 1 - 2 files changed, 21 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 08c266bf2d..670bf02bb3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -780,26 +780,6 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se return -1; } -std::string Section::getTextFromSectionFile() { - std::string fullText; - auto p = this->loadPageFromSectionFile(); - if (p) { - for (const auto& el : p->elements) { - if (el->getTag() == TAG_PageLine) { - const auto& line = static_cast(*el); - if (line.getBlock()) { - const auto& words = line.getBlock()->getWords(); - for (const auto& w : words) { - if (!fullText.empty()) fullText += " "; - fullText += w; - } - } - } - } - } - return fullText; -} - std::optional Section::getCachedPageCount() { ScopedSectionFile sf(file, filePath); if (!sf.ok()) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 1a7b19273d..db58bf2815 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -96,7 +96,6 @@ class Section { EpubRenderMode renderMode = EpubRenderMode::CrossInkDefault, SectionBuildOptions buildOptions = {}); std::unique_ptr loadPageFromSectionFile(); - std::string getTextFromSectionFile(); // Get the page count from the section cache file without fully loading it. std::optional getCachedPageCount(); From 7d9717ce27bac0b2286122265517be1e27368b78 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:46:24 -0700 Subject: [PATCH 33/91] fix: guard empty /Read destination path before rename() buildReadFolderDestination() can now return "" when no free name slot exists; both callers treated it as a normal path and would call Storage.rename(src, ""). Short-circuit to the existing move-failed path. --- src/activities/home/BookActions.cpp | 4 +++- src/activities/reader/EpubReaderActivity.cpp | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/activities/home/BookActions.cpp b/src/activities/home/BookActions.cpp index 7cf1b53dd6..81ac275f9b 100644 --- a/src/activities/home/BookActions.cpp +++ b/src/activities/home/BookActions.cpp @@ -168,7 +168,9 @@ bool toggleEpubCompleted(const std::string& fullPath, const std::string& display const std::string title = epub.getTitle(); const std::string author = epub.getAuthor(); LOG_INF("BookActions", "Moving completed epub: %s -> %s", fullPath.c_str(), dstPath.c_str()); - if (!Storage.rename(fullPath.c_str(), dstPath.c_str())) { + // buildReadFolderDestination returns "" when no free name is available; never + // hand rename() an empty target (it would fail or clobber). Treat as a move failure. + if (dstPath.empty() || !Storage.rename(fullPath.c_str(), dstPath.c_str())) { LOG_ERR("BookActions", "Failed to move book to 'Read' folder"); snprintf(APP_STATE.pendingAlertTitle, sizeof(APP_STATE.pendingAlertTitle), "%s", tr(STR_MOVE_TO_READ_FAILED_TITLE)); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 785a0afb39..b543cc8767 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1016,7 +1016,9 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& const std::string& oldCachePath, const std::string& title, const std::string& author) { LOG_INF("ERS", "Moving finished epub: %s -> %s", srcPath.c_str(), dstPath.c_str()); - if (!Storage.rename(srcPath.c_str(), dstPath.c_str())) { + // buildReadFolderDestination returns "" when no free name is available; never + // hand rename() an empty target (it would fail or clobber). Treat as a move failure. + if (dstPath.empty() || !Storage.rename(srcPath.c_str(), dstPath.c_str())) { LOG_ERR("ERS", "Failed to move finished book to '/Read' folder"); snprintf(APP_STATE.pendingAlertTitle, sizeof(APP_STATE.pendingAlertTitle), "%s", tr(STR_MOVE_TO_READ_FAILED_TITLE)); snprintf(APP_STATE.pendingAlertBody, sizeof(APP_STATE.pendingAlertBody), tr(STR_MOVE_TO_READ_FAILED_BODY), From 373ef9daa4056db935377837f9b20b7ec06d267a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:47:06 -0700 Subject: [PATCH 34/91] refactor: drop duplicate calculateReaderViewport helper It was a field-for-field clone of computeReaderViewportLayout; the search launch path now reuses the existing helper so reader and search pagination stay computed by one function. --- src/activities/reader/EpubReaderActivity.cpp | 44 +------------------- 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b543cc8767..8c2e64994c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1032,46 +1032,6 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& !SETTINGS.removeReadBooksFromRecents); } -struct ReaderViewport { - int top; - int right; - int bottom; - int left; - uint16_t width; - uint16_t height; -}; - -ReaderViewport calculateReaderViewport(GfxRenderer& renderer, const bool automaticPageTurnActive) { - ReaderViewport viewport{}; - renderer.getOrientedViewableTRBL(&viewport.top, &viewport.right, &viewport.bottom, &viewport.left); - viewport.left += effectiveReaderLeftMargin(); - viewport.right += SETTINGS.screenMargin; - - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - const int topStatusBarReservedHeight = ReaderUtils::getTopClockStatusBarReservedHeight(); - if (topStatusBarReservedHeight > 0) { - viewport.top += std::max(static_cast(SETTINGS.screenMargin), - topStatusBarReservedHeight + ReaderUtils::STATUS_BAR_TEXT_PADDING); - } else { - viewport.top += SETTINGS.screenMargin; - } - - if (automaticPageTurnActive && - (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { - viewport.bottom += - std::max(SETTINGS.screenMargin, - static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin + - ReaderUtils::STATUS_BAR_TEXT_PADDING)); - } else { - viewport.bottom += - std::max(SETTINGS.screenMargin, static_cast(statusBarHeight + ReaderUtils::STATUS_BAR_TEXT_PADDING)); - } - - viewport.width = renderer.getScreenWidth() - viewport.left - viewport.right; - viewport.height = renderer.getScreenHeight() - viewport.top - viewport.bottom; - return viewport; -} - } // namespace uint8_t EpubReaderActivity::loadBookRenderMode(const std::string& filePath) { @@ -3395,7 +3355,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::make( searchStartSpine, initiatedFromPage, isFindNext, hasPendingPageRemap ? cachedChapterTotalPageCount : 0); - const ReaderViewport viewport = calculateReaderViewport(renderer, automaticPageTurnActive); + const ReaderViewportLayout viewport = computeReaderViewportLayout(renderer, automaticPageTurnActive); // Release the current page graph before allocating the search activity. It // will be reconstructed from the SD cache on return, while nextPageNumber @@ -3412,7 +3372,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { // matcher state, and the reusable Section live inline in that allocation; // no per-page or per-chapter activity allocations are performed. auto searchActivity = makeUniqueNoThrow(renderer, mappedInput, epub, query.c_str(), route, - viewport.width, viewport.height); + viewport.viewportWidth, viewport.viewportHeight); if (!searchActivity) { LOG_ERR("ERS", "OOM: EpubReaderSearchActivity (%u bytes)", static_cast(sizeof(EpubReaderSearchActivity))); resumeReadingPaceTimer("search_oom"); From 965f332c2dbf9d15901cc5e18514842e4371bf84 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:50:02 -0700 Subject: [PATCH 35/91] refactor: share isSearchSeparator via AsciiCase.h The space/hyphen separator rule was open-coded in three places (a dead copy in Section.cpp, SearchMatcher.cpp, and the highlighter). Hoist it next to asciiToLower so the query normalizer, scan matcher, and highlighter share one definition that cannot drift. --- lib/Epub/Epub/AsciiCase.h | 5 +++++ lib/Epub/Epub/SearchMatcher.cpp | 6 ++---- lib/Epub/Epub/Section.cpp | 6 ------ src/activities/reader/SearchHighlighter.cpp | 3 ++- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/Epub/Epub/AsciiCase.h b/lib/Epub/Epub/AsciiCase.h index e0c9ab0a85..45e736838b 100644 --- a/lib/Epub/Epub/AsciiCase.h +++ b/lib/Epub/Epub/AsciiCase.h @@ -12,4 +12,9 @@ constexpr uint8_t asciiToLower(const uint8_t value) { return (value >= 'A' && value <= 'Z') ? static_cast(value + ('a' - 'A')) : value; } +// Bytes treated as insignificant by in-book search on both the query and the +// page/record sides: ASCII space and hyphen. Single definition so the query +// normalizer, the streaming matcher, and the result highlighter agree. +constexpr bool isSearchSeparator(const uint8_t value) { return value == ' ' || value == '-'; } + } // namespace epub diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index ebfefcd801..27b2d4a7b7 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -6,8 +6,6 @@ #include "AsciiCase.h" namespace { -constexpr bool isSearchSeparator(const uint8_t b) { return b == ' ' || b == '-'; } - uint32_t stripLatinDiacritics(uint32_t cp) { if (cp >= 'A' && cp <= 'Z') return cp + 32; @@ -92,7 +90,7 @@ size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::ar uint8_t b = (norm >> shift) & 0xFF; if (b == 0) break; - if (isSearchSeparator(b)) { + if (epub::isSearchSeparator(b)) { continue; } if (len >= out.size()) { @@ -186,7 +184,7 @@ int SearchMatcher::feed(uint8_t c) { uint8_t b = (norm >> shift) & 0xFF; if (b == 0) break; - if (isSearchSeparator(b)) { + if (epub::isSearchSeparator(b)) { if (matched > 0) { pendingSeparatorBytes += currentCodepointWidth; } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 670bf02bb3..7aa8a760ca 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -82,12 +82,6 @@ constexpr size_t PAGE_LUT_ENTRY_SIZE = sizeof(uint32_t) * 2; // inline LUT field can't silently desync it from the write/read sites. static_assert(PAGE_LUT_ENTRY_SIZE == sizeof(PageLutEntry::fileOffset) + sizeof(PageLutEntry::searchTextOffset), "On-disk page-LUT stride must match the inline offset fields"); - -// ASCII bytes treated as insignificant during search, dropped from both the -// query and the scanned record so layout-time hyphenation (a word split across -// a line break stores "-" + space + "") and spacing differences -// between the query and the rendered text do not block a match. -constexpr bool isSearchSeparator(const uint8_t b) { return b == ' ' || b == '-'; } } // namespace uint32_t Section::onPageComplete(std::unique_ptr page, uint32_t& searchTextOffset) { diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index a90d2b79df..92b60dfd1a 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -1,5 +1,6 @@ #include "SearchHighlighter.h" +#include #include #include #include @@ -31,7 +32,7 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { const std::string& wordText = block.getWords()[i]; for (char c : wordText) { - if (c == ' ' || c == '-') { + if (epub::isSearchSeparator(static_cast(c))) { continue; } if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || From eb625aaa24444f200c02e6ea3200792a012400ea Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:52:39 -0700 Subject: [PATCH 36/91] fix: distinguish corrupt-cache from transient I/O in search scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanForward now returns a typed ScanResult (Match/NoMatch/CorruptCache/ IoError) instead of std::optional. The scan activity only deletes and rebuilds the section cache on CorruptCache; a transient seek failure or OOM (IoError) surfaces the error without wiping a valid cache — fixing the case where the nothrow LUT-buffer OOM defeated its own 'recoverable' intent. --- lib/Epub/Epub/Section.cpp | 26 ++++++++--------- lib/Epub/Epub/Section.h | 23 ++++++++++++--- .../reader/EpubReaderSearchActivity.cpp | 29 ++++++++++++++----- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7aa8a760ca..f28aedcd4c 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -670,9 +670,9 @@ bool Section::ensureSearchHeader() { return true; } -std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher) { +Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher) { if (startPage >= pageCount || startPage >= endPage) { - return -1; + return {ScanStatus::NoMatch, -1}; } if (endPage > pageCount) { endPage = pageCount; @@ -680,7 +680,7 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se // File size and page-LUT offset are invariant per section; read them once. if (!ensureSearchHeader()) { - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } const uint32_t fileSize = searchFileSize; const uint32_t lutOffset = searchLutOffset; @@ -692,7 +692,7 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se fileSize - entryOffset < static_cast(PAGE_LUT_ENTRY_SIZE) * count) { LOG_ERR("SCT", "Search failed: invalid page LUT entry range"); closeSearchState(); - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } // Batch read the LUT entries for the requested page range into a reused @@ -706,19 +706,19 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se searchLutBufCapacity = 0; LOG_ERR("SCT", "Search failed: OOM for page LUT buffer (%u bytes)", static_cast(lutBytes)); closeSearchState(); - return std::nullopt; + return {ScanStatus::IoError, -1}; } searchLutBufCapacity = lutBytes; } if (!file.seek(static_cast(entryOffset))) { LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); closeSearchState(); - return std::nullopt; + return {ScanStatus::IoError, -1}; } if (file.read(searchLutBuf.get(), lutBytes) != lutBytes) { LOG_ERR("SCT", "Search failed: could not read page LUT entries"); closeSearchState(); - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } // Sequentially read the text records @@ -730,13 +730,13 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } if (!file.seek(searchTextOffset)) { LOG_ERR("SCT", "Search failed: could not seek to text record"); closeSearchState(); - return std::nullopt; + return {ScanStatus::IoError, -1}; } uint32_t remaining = 0; @@ -744,7 +744,7 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record length"); closeSearchState(); - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } // A page with no searchable text (e.g. image-only) is a content discontinuity, @@ -759,19 +759,19 @@ std::optional Section::scanForward(uint16_t startPage, uint16_t endPage, Se if (file.read(buffer.data(), chunkSize) != chunkSize) { LOG_ERR("SCT", "Search failed: truncated text record"); closeSearchState(); - return std::nullopt; + return {ScanStatus::CorruptCache, -1}; } remaining -= chunkSize; for (size_t j = 0; j < chunkSize; ++j) { if (matcher.feed(buffer[j]) > 0) { - return static_cast(startPage + i); + return {ScanStatus::Match, static_cast(startPage + i)}; } } } } - return -1; + return {ScanStatus::NoMatch, -1}; } std::optional Section::getCachedPageCount() { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index db58bf2815..6198f35874 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -104,11 +104,26 @@ class Section { // allocation. Intended for sequential, book-wide operations such as search. void resetForSpine(int newSpineIndex); + // Why a forward search scan stopped. Distinguishes a structurally corrupt + // cache (which rebuilding can repair) from a transient I/O failure or OOM + // (which it cannot), so the caller does not delete a valid cache over a + // momentary glitch. + enum class ScanStatus : uint8_t { + Match, // a match was found; `page` holds the page index + NoMatch, // the requested range was scanned with no match (or was empty) + CorruptCache, // structurally invalid cache data; a rebuild may help + IoError, // seek/open failure or OOM; rebuilding will not help + }; + struct ScanResult { + ScanStatus status = ScanStatus::NoMatch; + int page = -1; // valid only when status == Match + }; + // Search forward through cached section pages from `startPage` up to `endPage`, - // batching LUT reads and streaming text records sequentially. Returns the - // first page index where `matcher.feed` completes a match, -1 if no match, - // or nullopt if a cache error occurs. - std::optional scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher); + // batching LUT reads and streaming text records sequentially. Returns Match + // with the first matching page index, NoMatch when the range is exhausted, or + // a failure status distinguishing corrupt-cache from transient I/O. + ScanResult scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher); // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor); diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index ce8f1ec15a..3519a2082e 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -225,13 +225,20 @@ void EpubReaderSearchActivity::scanNextPage() { endPage = std::min(endPage, currentPage + 50); const SearchMatcher matcherBeforeChunk = matcher; - auto match = section.scanForward(currentPage, endPage, matcher); + auto result = section.scanForward(currentPage, endPage, matcher); - if (match == std::nullopt && !sectionCacheRepairAttempted) { + // A transient I/O failure or OOM is not corruption: surface the error without + // deleting a valid cache or forcing a re-layout that would just fail again. + if (result.status == Section::ScanStatus::IoError) { + setFailure(SearchState::Error); + return; + } + + // A structurally corrupt cache can sometimes be repaired by rebuilding once. + if (result.status == Section::ScanStatus::CorruptCache && !sectionCacheRepairAttempted) { sectionCacheRepairAttempted = true; matcher = matcherBeforeChunk; - // Invalidate corrupt cache section.resetForSpine(currentSpineIndex); sectionLoaded = false; section.clearCache(); @@ -239,11 +246,16 @@ void EpubReaderSearchActivity::scanNextPage() { if (!ensureSectionLoaded()) { return; } - match = section.scanForward(currentPage, endPage, matcher); + result = section.scanForward(currentPage, endPage, matcher); + if (result.status == Section::ScanStatus::IoError) { + setFailure(SearchState::Error); + return; + } } - if (match == std::nullopt) { - // Do not leave a version-valid but unreadable cache to fail every future search. + if (result.status == Section::ScanStatus::CorruptCache) { + // Still corrupt after a rebuild: drop the bad cache and surface the error + // rather than entering an unbounded rebuild loop. section.resetForSpine(currentSpineIndex); sectionLoaded = false; section.clearCache(); @@ -251,12 +263,13 @@ void EpubReaderSearchActivity::scanNextPage() { return; } - if (*match >= 0) { - setResult(ProgressChangeResult{currentSpineIndex, *match}); + if (result.status == Section::ScanStatus::Match) { + setResult(ProgressChangeResult{currentSpineIndex, result.page}); finish(); return; } + // NoMatch: advance past the scanned chunk. currentPage = endPage; } From 2776dee904b816d0d2509b26851b4ce3dbad3bad Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:53:08 -0700 Subject: [PATCH 37/91] perf: reuse a member for the per-chunk matcher snapshot scanNextPage() snapshotted the whole SearchMatcher (~0.5 KB) into a stack local on every 50-page chunk only for the rare cache-repair rollback. Move it to a reused member so the cooperative scan loop keeps a small stack frame. --- src/activities/reader/EpubReaderSearchActivity.cpp | 2 +- src/activities/reader/EpubReaderSearchActivity.h | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 3519a2082e..7bfa0027b9 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -224,7 +224,7 @@ void EpubReaderSearchActivity::scanNextPage() { // Chunk scan to 50 pages at a time to yield to the main render/input loop endPage = std::min(endPage, currentPage + 50); - const SearchMatcher matcherBeforeChunk = matcher; + matcherBeforeChunk = matcher; auto result = section.scanForward(currentPage, endPage, matcher); // A transient I/O failure or OOM is not corruption: surface the error without diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h index 40989d311f..9212a99760 100644 --- a/src/activities/reader/EpubReaderSearchActivity.h +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -53,6 +53,11 @@ class EpubReaderSearchActivity final : public Activity { Section section; std::array query{}; SearchMatcher matcher; + // Snapshot of `matcher` taken at the start of each scanned chunk so a + // corrupt-cache rebuild can roll the carried match state back and rescan. + // A reused member rather than a per-chunk stack local (~0.5 KB) to keep the + // cooperative scan loop's stack small. + SearchMatcher matcherBeforeChunk; SearchRoute route; int currentSpineIndex; int currentPage; From 59bc37744301a8d13377206682a6bc096ae98ae9 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:55:20 -0700 Subject: [PATCH 38/91] refactor: share word-highlight geometry between search and clippings Extract EpubReaderUtils::drawWordHighlights() to own the em-space offset and next-word width-extension math that the search and clipping highlighters had copied almost verbatim. Each call site supplies its own match predicate and fill style. Templated to stay allocation-free on the render path. --- src/activities/reader/EpubReaderActivity.cpp | 47 +++--------------- src/activities/reader/EpubReaderUtils.h | 52 ++++++++++++++++++++ src/activities/reader/SearchHighlighter.cpp | 49 +++--------------- 3 files changed, 67 insertions(+), 81 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8c2e64994c..e91c08660c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4395,46 +4395,13 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font return false; }; - EpubReaderUtils::forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - if (!isHighlightedWord(pageWordIndex)) { - return true; - } - - const auto& wordList = block.getWords(); - const auto& xpos = block.getWordXpos(); - const auto& styles = block.getWordStyles(); - if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { - return true; - } - - const std::string& wordText = wordList[i]; - const bool hasEmSpace = EpubReaderUtils::hasEmSpacePrefix(wordText); - const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); - const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); - const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; - const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; - const int wordY = orientedMarginTop + line.yPos; - int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; - const int wordH = renderer.getLineHeight(fontId); - if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { - const std::string& nextWordText = wordList[i + 1]; - const bool nextHasEmSpace = EpubReaderUtils::hasEmSpacePrefix(nextWordText); - const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); - const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; - const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; - if (isHighlightedWord(pageWordIndex + 1) && nextWordX > wordX + wordW) { - wordW = nextWordX - wordX; - } else if (nextWordX > wordX && wordW > nextWordX - wordX) { - wordW = nextWordX - wordX; - } - } - if (wordW > 0) { - renderer.fillRectDither(wordX, wordY, wordW, wordH, Color::LightGray); - renderer.drawText(fontId, wordX, wordY, visibleText, foregroundBlack, textStyle); - } - return true; - }); + EpubReaderUtils::drawWordHighlights(page, renderer, fontId, orientedMarginTop, orientedMarginLeft, isHighlightedWord, + [&](const int wordX, const int wordY, const int wordW, const int wordH, + const char* visibleText, const EpdFontFamily::Style textStyle) { + renderer.fillRectDither(wordX, wordY, wordW, wordH, Color::LightGray); + renderer.drawText(fontId, wordX, wordY, visibleText, foregroundBlack, + textStyle); + }); } void EpubReaderActivity::renderStatusBar() const { diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index c58e362165..bd237c6a02 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -175,4 +176,55 @@ bool forEachVisiblePageWord(const Page& page, Callback&& callback) { return true; } +// Paint a per-word highlight over every visible word for which `isHighlighted` +// returns true. Owns the shared geometry — em-space prefix offset and the +// next-word width extension that closes the gap between adjacent highlighted +// words — so the search and clipping highlighters cannot drift apart. `drawWord` +// receives the computed rectangle plus the visible text/style and performs the +// fill and text draw in its own style. Templated (not std::function) to stay +// allocation-free on the render path. +template +void drawWordHighlights(const Page& page, GfxRenderer& renderer, const int fontId, const int orientedMarginTop, + const int orientedMarginLeft, MatchPred&& isHighlighted, DrawWord&& drawWord) { + forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + if (!isHighlighted(pageWordIndex)) { + return true; + } + + const auto& wordList = block.getWords(); + const auto& xpos = block.getWordXpos(); + const auto& styles = block.getWordStyles(); + if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { + return true; + } + + const std::string& wordText = wordList[i]; + const bool hasEmSpace = hasEmSpacePrefix(wordText); + const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); + const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); + const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; + const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; + const int wordY = orientedMarginTop + line.yPos; + int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; + const int wordH = renderer.getLineHeight(fontId); + if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { + const std::string& nextWordText = wordList[i + 1]; + const bool nextHasEmSpace = hasEmSpacePrefix(nextWordText); + const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); + const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; + const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; + if (isHighlighted(pageWordIndex + 1) && nextWordX > wordX + wordW) { + wordW = nextWordX - wordX; + } else if (nextWordX > wordX && wordW > nextWordX - wordX) { + wordW = nextWordX - wordX; + } + } + if (wordW > 0) { + drawWord(wordX, wordY, wordW, wordH, visibleText, textStyle); + } + return true; + }); +} + } // namespace EpubReaderUtils diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 92b60dfd1a..ba1f164b50 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -69,51 +69,18 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, return; } - // 4. Highlight matched words on page + // 4. Highlight matched words on page using the shared geometry helper, with + // the search style: a solid inverted fill (white-on-black) so matches stand out. const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { return std::any_of( searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), [pageWordIndex](const auto& range) { return pageWordIndex >= range.first && pageWordIndex <= range.second; }); }; - EpubReaderUtils::forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - if (!isSearchMatchWord(pageWordIndex)) { - return true; - } - - const auto& wordList = block.getWords(); - const auto& xpos = block.getWordXpos(); - const auto& styles = block.getWordStyles(); - if (i >= wordList.size() || i >= xpos.size() || i >= styles.size()) { - return true; - } - - const std::string& wordText = wordList[i]; - const bool hasEmSpace = EpubReaderUtils::hasEmSpacePrefix(wordText); - const char* visibleText = wordText.c_str() + (hasEmSpace ? 3 : 0); - const auto textStyle = static_cast(styles[i] & ~EpdFontFamily::UNDERLINE); - const int skipX = hasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", textStyle) : 0; - const int wordX = orientedMarginLeft + line.xPos + xpos[i] + skipX; - const int wordY = orientedMarginTop + line.yPos; - int wordW = renderer.getTextAdvanceX(fontId, wordText.c_str(), textStyle) - skipX; - const int wordH = renderer.getLineHeight(fontId); - if (i + 1 < wordList.size() && i + 1 < xpos.size() && i + 1 < styles.size()) { - const std::string& nextWordText = wordList[i + 1]; - const bool nextHasEmSpace = EpubReaderUtils::hasEmSpacePrefix(nextWordText); - const auto nextTextStyle = static_cast(styles[i + 1] & ~EpdFontFamily::UNDERLINE); - const int nextSkipX = nextHasEmSpace ? renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", nextTextStyle) : 0; - const int nextWordX = orientedMarginLeft + line.xPos + xpos[i + 1] + nextSkipX; - if (isSearchMatchWord(pageWordIndex + 1) && nextWordX > wordX + wordW) { - wordW = nextWordX - wordX; - } else if (nextWordX > wordX && wordW > nextWordX - wordX) { - wordW = nextWordX - wordX; - } - } - if (wordW > 0) { - renderer.fillRect(wordX, wordY, wordW, wordH, true); - renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); - } - return true; - }); + EpubReaderUtils::drawWordHighlights(page, renderer, fontId, orientedMarginTop, orientedMarginLeft, isSearchMatchWord, + [&](const int wordX, const int wordY, const int wordW, const int wordH, + const char* visibleText, const EpdFontFamily::Style textStyle) { + renderer.fillRect(wordX, wordY, wordW, wordH, true); + renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); + }); } From 5bf715ecb2b097d8efa0c8d44d91a3ed79146f8a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:57:05 -0700 Subject: [PATCH 39/91] fix: handle the highlighter's cross-page priming scan failure drawSearchHighlights ignored scanForward()'s result when priming the matcher with the previous page. On a cache/IO error that scan aborts mid-feed, leaving the matcher in a partial state that the per-page feed then built spurious ranges from. Reset the matcher on a failed prime so only matches contained on the current page are highlighted. --- src/activities/reader/SearchHighlighter.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index ba1f164b50..c0ff3f14db 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -48,7 +48,15 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state searchHighlightMatchRanges.clear(); if (section->currentPage > 0) { - section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); + // Prime the matcher with the previous page so a match that began there and + // completes on this page still highlights. If that scan fails it leaves the + // matcher mid-feed; reset it so we highlight only matches contained on this + // page rather than feeding indeterminate carried state. + const Section::ScanResult primeResult = + section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); + if (primeResult.status == Section::ScanStatus::CorruptCache || primeResult.status == Section::ScanStatus::IoError) { + matcher.reset(); + } } for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { From 453604749a72afcd0084de393b1dfded4d06ecf8 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 07:58:40 -0700 Subject: [PATCH 40/91] perf: allocate search highlight buffers lazily and release them SearchHighlighter reserved ~12.5 KB in its constructor, held for the whole reader session (and allocated during book-open's heavy-allocation window) even when search was never used. Reserve lazily on first highlight and release the buffers when leaving the search-result page. --- src/activities/reader/EpubReaderActivity.cpp | 5 +++++ src/activities/reader/SearchHighlighter.cpp | 20 ++++++++++++++++++++ src/activities/reader/SearchHighlighter.h | 14 +++++++++----- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e91c08660c..5162860fee 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4136,6 +4136,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { if (section && (currentSpineIndex != lastSearchResultSpine || section->currentPage != lastSearchResultPage)) { + if (lastSearchResultSpine != -1 || lastSearchResultPage != -1) { + // Left the search-result page: the highlight is no longer drawn, so free + // the highlighter's scratch buffers until the next search. + searchHighlighter.release(); + } lastSearchResultSpine = -1; lastSearchResultPage = -1; } diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index c0ff3f14db..57cecbe724 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -24,6 +24,8 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, return; } + ensureBuffersReserved(); + // 2. Normalize the page text and map characters to word indices searchHighlightPageText.clear(); searchHighlightCharToWordIndex.clear(); @@ -92,3 +94,21 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); }); } + +void SearchHighlighter::ensureBuffersReserved() const { + if (searchHighlightPageText.capacity() > 0) { + return; + } + searchHighlightPageText.reserve(4096); + searchHighlightCharToWordIndex.reserve(4096); + searchHighlightMatchRanges.reserve(128); +} + +void SearchHighlighter::release() { + searchHighlightPageText.clear(); + searchHighlightPageText.shrink_to_fit(); + searchHighlightCharToWordIndex.clear(); + searchHighlightCharToWordIndex.shrink_to_fit(); + searchHighlightMatchRanges.clear(); + searchHighlightMatchRanges.shrink_to_fit(); +} diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h index 157b28ec5b..50573fc809 100644 --- a/src/activities/reader/SearchHighlighter.h +++ b/src/activities/reader/SearchHighlighter.h @@ -11,17 +11,21 @@ class Section; class SearchHighlighter { public: - SearchHighlighter() { - searchHighlightPageText.reserve(4096); - searchHighlightCharToWordIndex.reserve(4096); - searchHighlightMatchRanges.reserve(128); - } + SearchHighlighter() = default; void drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, const int orientedMarginLeft, Section* section, const char* lastSearchQuery, GfxRenderer& renderer) const; + // Free the scratch buffers (~12 KB). Called when the on-page highlight is no + // longer active so a reader who is not viewing a search result does not hold + // the footprint; drawSearchHighlights re-reserves lazily on the next use. + void release(); + private: + // Reserve the scratch buffers on first use; no-op once reserved. + void ensureBuffersReserved() const; + mutable std::string searchHighlightPageText; mutable std::vector searchHighlightCharToWordIndex; mutable std::vector> searchHighlightMatchRanges; From 645976aa03226c4f29209e5f5e50c9a48ebe5120 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:00:36 -0700 Subject: [PATCH 41/91] perf: memoize search highlight ranges per page+query drawSearchHighlights recompiled the matcher, re-read the previous page from SD, and re-walked the page on every render while a highlight was active. Cache the computed ranges keyed on (page, query) and recompute only when either changes, so repeated renders of the result page just repaint. Invalidated by release(). --- src/activities/reader/SearchHighlighter.cpp | 107 ++++++++++++-------- src/activities/reader/SearchHighlighter.h | 8 ++ 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 57cecbe724..026f9396b3 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -18,58 +18,71 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, return; } - // 1. Compile the search query once using KMP - SearchMatcher matcher; - if (!matcher.compile(lastSearchQuery)) { - return; - } + // Recompute the match ranges only when the visible page or query changed. + // While the reader sits on the search-result page (status-bar refreshes, etc.) + // this avoids re-reading the previous page from SD and recompiling the matcher + // on every frame; we just repaint the cached ranges. + const bool cacheHit = searchHighlightComputed && section->currentPage == searchHighlightCachedPage && + searchHighlightCachedQuery == lastSearchQuery; + if (!cacheHit) { + searchHighlightComputed = true; + searchHighlightCachedPage = section->currentPage; + searchHighlightCachedQuery = lastSearchQuery; + searchHighlightMatchRanges.clear(); - ensureBuffersReserved(); + // 1. Compile the search query once using KMP + SearchMatcher matcher; + if (!matcher.compile(lastSearchQuery)) { + return; + } - // 2. Normalize the page text and map characters to word indices - searchHighlightPageText.clear(); - searchHighlightCharToWordIndex.clear(); + ensureBuffersReserved(); - EpubReaderUtils::forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - const std::string& wordText = block.getWords()[i]; - for (char c : wordText) { - if (epub::isSearchSeparator(static_cast(c))) { - continue; - } - if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || - searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { - return false; + // 2. Normalize the page text and map characters to word indices + searchHighlightPageText.clear(); + searchHighlightCharToWordIndex.clear(); + + EpubReaderUtils::forEachVisiblePageWord( + page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { + const std::string& wordText = block.getWords()[i]; + for (char c : wordText) { + if (epub::isSearchSeparator(static_cast(c))) { + continue; + } + if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || + searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { + return false; + } + searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); + searchHighlightCharToWordIndex.push_back(pageWordIndex); } - searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); - searchHighlightCharToWordIndex.push_back(pageWordIndex); - } - return true; - }); + return true; + }); - // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state - searchHighlightMatchRanges.clear(); - if (section->currentPage > 0) { - // Prime the matcher with the previous page so a match that began there and - // completes on this page still highlights. If that scan fails it leaves the - // matcher mid-feed; reset it so we highlight only matches contained on this - // page rather than feeding indeterminate carried state. - const Section::ScanResult primeResult = - section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); - if (primeResult.status == Section::ScanStatus::CorruptCache || primeResult.status == Section::ScanStatus::IoError) { - matcher.reset(); + // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state + if (section->currentPage > 0) { + // Prime the matcher with the previous page so a match that began there and + // completes on this page still highlights. If that scan fails it leaves the + // matcher mid-feed; reset it so we highlight only matches contained on this + // page rather than feeding indeterminate carried state. + const Section::ScanResult primeResult = + section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); + if (primeResult.status == Section::ScanStatus::CorruptCache || + primeResult.status == Section::ScanStatus::IoError) { + matcher.reset(); + } } - } - for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { - int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); - if (matchBytes > 0) { - size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; - size_t endIdx = charIndex; - if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { - if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { - searchHighlightMatchRanges.push_back( - {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); + for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { + int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); + if (matchBytes > 0) { + size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; + size_t endIdx = charIndex; + if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { + if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { + searchHighlightMatchRanges.push_back( + {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); + } } } } @@ -111,4 +124,8 @@ void SearchHighlighter::release() { searchHighlightCharToWordIndex.shrink_to_fit(); searchHighlightMatchRanges.clear(); searchHighlightMatchRanges.shrink_to_fit(); + searchHighlightComputed = false; + searchHighlightCachedPage = -1; + searchHighlightCachedQuery.clear(); + searchHighlightCachedQuery.shrink_to_fit(); } diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h index 50573fc809..af11ebf061 100644 --- a/src/activities/reader/SearchHighlighter.h +++ b/src/activities/reader/SearchHighlighter.h @@ -29,4 +29,12 @@ class SearchHighlighter { mutable std::string searchHighlightPageText; mutable std::vector searchHighlightCharToWordIndex; mutable std::vector> searchHighlightMatchRanges; + + // Memo of the (page, query) the cached match ranges were computed for, so + // repeated renders of the same search-result page (status-bar refreshes, etc.) + // reuse the ranges instead of re-reading the previous page from SD and + // recompiling the matcher each frame. Invalidated by release(). + mutable bool searchHighlightComputed = false; + mutable int searchHighlightCachedPage = -1; + mutable std::string searchHighlightCachedQuery; }; From f51f609e9e59ae84c6e7b61e0de2a180605dbd05 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:12:06 -0700 Subject: [PATCH 42/91] refactor: merge reader toasts into one shared slot The render-mode, safe-mode, and search transient toasts each had their own message/timer/expiry-check/draw path (two separate expiry blocks in loop(), two draw branches). Replace them with a single Toast{message,showTime, durationMs} slot driven by showToast(); the once-per-book render/safe latches that decide whether to trigger are kept. Removes the duplicated state machine and the case where a transient and a mode toast could both draw at once. --- src/activities/reader/EpubReaderActivity.cpp | 45 +++++++------------- src/activities/reader/EpubReaderActivity.h | 24 ++++++----- 2 files changed, 28 insertions(+), 41 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5162860fee..eea9825af5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1819,10 +1819,8 @@ void EpubReaderActivity::loop() { return; } } - if ((pendingRenderModeToast || pendingSafeModeToast) && - (millis() - renderModeToastShowTime) >= RENDER_MODE_TOAST_MS) { - pendingRenderModeToast = false; - pendingSafeModeToast = false; + if (toast.message && (millis() - toast.showTime) >= toast.durationMs) { + toast.message = nullptr; requestUpdate(); return; } @@ -1878,11 +1876,6 @@ void EpubReaderActivity::loop() { } } - if (transientMessage && (millis() - transientMessageTime) >= ReaderUtils::READER_MESSAGE_DURATION_MS) { - transientMessage = nullptr; - requestUpdate(); - } - // Long-press Confirm: execute the configured reader action without opening the menu if (longPressMenuHandled) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || @@ -3320,7 +3313,7 @@ void EpubReaderActivity::launchSearchInput() { if (!SearchMatcher::isValidSearchQuery(query)) { // Surface why the search was not accepted (e.g. whitespace/hyphen-only // input) instead of silently repainting, which reads as a no-op. - showTransientMessage(tr(STR_INVALID_SEARCH_QUERY)); + showToast(tr(STR_INVALID_SEARCH_QUERY), ReaderUtils::READER_MESSAGE_DURATION_MS); resumeReadingPaceTimer("search_invalid"); requestUpdate(); return; @@ -3397,15 +3390,16 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { cachedChapterTotalPageCount = 0; lastSearchResultSpine = match.spineIndex; lastSearchResultPage = match.page; - showTransientMessage(tr(STR_SEARCH_MATCH_FOUND)); + showToast(tr(STR_SEARCH_MATCH_FOUND), ReaderUtils::READER_MESSAGE_DURATION_MS); } resumeReadingPaceTimer("search_return"); }); } -void EpubReaderActivity::showTransientMessage(const char* message) { - transientMessage = message; - transientMessageTime = millis(); +void EpubReaderActivity::showToast(const char* message, const unsigned long durationMs) { + toast.message = message; + toast.showTime = millis(); + toast.durationMs = durationMs; } void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { @@ -3421,22 +3415,18 @@ void EpubReaderActivity::showTiltPageTurnFeedback(bool enabled) { } void EpubReaderActivity::showRenderModeToast(const uint8_t renderMode) { - if (normalizeRenderMode(renderMode) == EpubRenderMode::CrossInkDefault) { + const EpubRenderMode mode = normalizeRenderMode(renderMode); + if (mode == EpubRenderMode::CrossInkDefault) { return; } - renderModeToastMode = normalizeRenderModeRaw(renderMode); - pendingRenderModeToast = true; - pendingSafeModeToast = false; renderModeToastShown = true; - renderModeToastShowTime = millis(); + showToast(labelForRenderModeToast(mode), RENDER_MODE_TOAST_MS); } void EpubReaderActivity::showSafeModeToast() { - pendingSafeModeToast = true; - pendingRenderModeToast = false; safeModeToastShown = true; - renderModeToastShown = true; - renderModeToastShowTime = millis(); + renderModeToastShown = true; // safe mode supersedes the auto render-mode toast + showToast(tr(STR_SAFE_MODE), RENDER_MODE_TOAST_MS); } void EpubReaderActivity::applyOrientation(const uint8_t orientation) { @@ -4221,13 +4211,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const char* msg = tiltPageTurnFeedbackEnabled ? tr(STR_TILT_TO_TURN_ON) : tr(STR_TILT_TO_TURN_OFF); drawToastBuffer(renderer, msg); } - if (pendingSafeModeToast) { - drawToastBuffer(renderer, tr(STR_SAFE_MODE)); - } else if (pendingRenderModeToast) { - drawToastBuffer(renderer, labelForRenderModeToast(normalizeRenderMode(renderModeToastMode))); - } - if (transientMessage) { - drawToastBuffer(renderer, transientMessage); + if (toast.message) { + drawToastBuffer(renderer, toast.message); } fcm->logStats("bw_render"); const auto tBwRender = millis(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index d8835b1949..8b7c2ad84d 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -108,12 +108,10 @@ class EpubReaderActivity final : public Activity { bool pendingTiltPageTurnFeedback = false; bool tiltPageTurnFeedbackEnabled = false; unsigned long tiltPageTurnFeedbackShowTime = 0UL; - bool pendingRenderModeToast = false; + // Once-per-book latches deciding whether the auto render-mode / safe-mode + // toast should appear. The visible toast itself is the shared `toast` slot. bool renderModeToastShown = false; - bool pendingSafeModeToast = false; bool safeModeToastShown = false; - uint8_t renderModeToastMode = 0; - unsigned long renderModeToastShowTime = 0UL; int completionTriggerSpineIndex = -1; float completionTriggerSpineProgress = 1.0f; bool completionPromptQueued = false; @@ -122,11 +120,15 @@ class EpubReaderActivity final : public Activity { bool completionTriggerCrossed = false; bool lastAtOrPastCompletionTrigger = false; - // Transient toast used by the search feature: the text to show (null when hidden) - // and when it was shown. The pointer is from tr(), which returns stable storage - // in the static i18n string table. - const char* transientMessage = nullptr; - unsigned long transientMessageTime = 0UL; + // Single transient on-screen toast slot, shared by the render-mode, safe-mode, + // and search messages. `message` points at tr() storage (stable) or is null + // when hidden; auto-dismissed `durationMs` after `showTime` in loop(). + struct Toast { + const char* message = nullptr; + unsigned long showTime = 0UL; + unsigned long durationMs = 0UL; + }; + Toast toast; std::array lastSearchQuery{}; int lastSearchResultSpine = -1; int lastSearchResultPage = -1; @@ -206,8 +208,8 @@ class EpubReaderActivity final : public Activity { void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void launchSearchInput(); void launchBookSearch(const std::string& query); - // Show a transient search toast for READER_MESSAGE_DURATION_MS. - void showTransientMessage(const char* message); + // Show `message` in the shared transient toast slot for `durationMs`. + void showToast(const char* message, unsigned long durationMs); void applyOrientation(uint8_t orientation); void pageTurn(bool isForwardTurn, const char* source = "unknown"); float getCurrentBookProgressPercent() const; From 046af123eef20f1a643322561cbd7443c506cea1 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:23:36 -0700 Subject: [PATCH 43/91] refactor: name section-header trailer offsets The five header readers/patchers each open-coded their seek as HEADER_SIZE - sizeof(uint32_t) * N (- sizeof(uint16_t)), forcing the reader to map N back to a trailer field and inviting an off-by-one. Define PAGE_COUNT_POS / PAGE_LUT_OFFSET_POS / ANCHOR_MAP_OFFSET_POS / PARAGRAPH_LUT_OFFSET_POS / LI_LUT_OFFSET_POS once next to HEADER_SIZE and seek by name. Values are identical; no cache format change. --- lib/Epub/Epub/Section.cpp | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index f28aedcd4c..5269df70f9 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -29,6 +29,17 @@ constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t); +// The header ends with a fixed trailer written last and patched after layout +// (see writeSectionFileHeader): a uint16_t pageCount followed by four uint32_t +// offsets in this order — page LUT, anchor map, paragraph LUT, list-item LUT. +// Name each field's absolute seek position so readers/patchers don't open-code +// `HEADER_SIZE - sizeof(uint32_t) * N` arithmetic (and risk an off-by-one). +constexpr size_t LI_LUT_OFFSET_POS = HEADER_SIZE - sizeof(uint32_t); +constexpr size_t PARAGRAPH_LUT_OFFSET_POS = HEADER_SIZE - sizeof(uint32_t) * 2; +constexpr size_t ANCHOR_MAP_OFFSET_POS = HEADER_SIZE - sizeof(uint32_t) * 3; +constexpr size_t PAGE_LUT_OFFSET_POS = HEADER_SIZE - sizeof(uint32_t) * 4; +constexpr size_t PAGE_COUNT_POS = PAGE_LUT_OFFSET_POS - sizeof(uint16_t); + struct PageLutEntry { uint32_t fileOffset; uint32_t searchTextOffset; @@ -507,10 +518,10 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } // Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset. - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount)) || - !serialization::tryWritePod(file, pageCount) || !serialization::tryWritePod(file, lutOffset) || - !serialization::tryWritePod(file, anchorMapOffset) || !serialization::tryWritePod(file, paragraphLutOffset) || - !serialization::tryWritePod(file, liLutFileOffset) || !file.sync()) { + if (!file.seek(PAGE_COUNT_POS) || !serialization::tryWritePod(file, pageCount) || + !serialization::tryWritePod(file, lutOffset) || !serialization::tryWritePod(file, anchorMapOffset) || + !serialization::tryWritePod(file, paragraphLutOffset) || !serialization::tryWritePod(file, liLutFileOffset) || + !file.sync()) { LOG_ERR("SCT", "Failed to finalize section cache"); file.close(); Storage.remove(tmpSectionPath.c_str()); @@ -541,7 +552,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } bool Section::readPageLutOffset(uint32_t& lutOffset) { - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { + if (!file.seek(PAGE_LUT_OFFSET_POS)) { return false; } return file.read(reinterpret_cast(&lutOffset), sizeof(lutOffset)) == sizeof(lutOffset); @@ -565,7 +576,7 @@ std::unique_ptr Section::loadPageFromSectionFile() { return nullptr; } - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + if (!file.seek(PAGE_COUNT_POS)) { LOG_ERR("SCT", "Failed to seek to page count"); return nullptr; } @@ -785,7 +796,7 @@ std::optional Section::getCachedPageCount() { return std::nullopt; } - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + if (!file.seek(PAGE_COUNT_POS)) { return std::nullopt; } uint16_t count; @@ -802,7 +813,7 @@ std::optional Section::getPageForAnchor(const std::string& anchor) { } const uint32_t fileSize = file.size(); - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 3)) { + if (!file.seek(ANCHOR_MAP_OFFSET_POS)) { return std::nullopt; } uint32_t anchorMapOffset; @@ -841,7 +852,7 @@ std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) } const uint32_t fileSize = file.size(); - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + if (!file.seek(PARAGRAPH_LUT_OFFSET_POS)) { return std::nullopt; } uint32_t paragraphLutOffset; @@ -890,7 +901,7 @@ std::optional Section::getParagraphIndexForPage(const uint16_t page) { } const uint32_t fileSize = file.size(); - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + if (!file.seek(PARAGRAPH_LUT_OFFSET_POS)) { return std::nullopt; } uint32_t paragraphLutOffset; @@ -934,7 +945,7 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) } const uint32_t fileSize = file.size(); - if (!file.seek(HEADER_SIZE - sizeof(uint32_t))) { + if (!file.seek(LI_LUT_OFFSET_POS)) { return std::nullopt; } uint32_t liLutOffset; @@ -946,7 +957,7 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) } // The li LUT shares count with the paragraph LUT; read count from paragraphLutOffset - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + if (!file.seek(PARAGRAPH_LUT_OFFSET_POS)) { return std::nullopt; } uint32_t paragraphLutOffset; From f5d97d07140d17d47c164697150593ea9023c735 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:28:50 -0700 Subject: [PATCH 44/91] docs: update stale pageContainsText references to scanForward The per-page search method was renamed to Section::scanForward(); update the remaining references in the search-architecture doc, the Section.h search-header comment, and a Section.cpp comment to the current symbol. --- docs/search-architecture.md | 4 ++-- lib/Epub/Epub/Section.cpp | 2 +- lib/Epub/Epub/Section.h | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index fb0928cb0d..0f48f8787f 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -81,7 +81,7 @@ The responsibilities are split as follows: before yielding, and distinguishes `Searching`, `NotFound`, and `Error`. - `Page::serializeSearchText()` writes compact searchable text while the page already exists during layout. -- `Section::pageContainsText()` searches one record without deserializing a +- `Section::scanForward()` scans page text records without deserializing a `Page` or allocating word vectors. All SD access continues through `HalStorage` and `HalFile`; search does not @@ -165,7 +165,7 @@ remeasure them when the implementation or toolchain changes. ## Matching algorithm -`Section::pageContainsText()` uses Knuth-Morris-Pratt matching because it: +`Section::scanForward()` uses Knuth-Morris-Pratt matching because it: - scans the SD record once - handles matches that cross 64-byte read-buffer boundaries diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 5269df70f9..3217b5113a 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -587,7 +587,7 @@ std::unique_ptr Section::loadPageFromSectionFile() { } // Validate LUT-derived offsets against the file before trusting them (mirrors - // pageContainsText). Compute in 64-bit so a corrupt (huge) lutOffset cannot + // scanForward). Compute in 64-bit so a corrupt (huge) lutOffset cannot // wrap the uint32 sum into a small in-bounds value. if (lutOffset == 0 || currentPage < 0 || static_cast(currentPage) >= headerPageCount) { LOG_ERR("SCT", "Invalid page LUT request"); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 6198f35874..db3a942e2e 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -37,7 +37,7 @@ class Section { // Cached section-header state for the search scan: the file size and page-LUT // offset are invariant per section, so they are read once when the scan file - // is lazily opened and reused for every pageContainsText() call. Invalidated + // is lazily opened and reused for every scanForward() call. Invalidated // by resetForSpine() (which also closes the file). bool searchHeaderReady = false; uint32_t searchFileSize = 0; From 087c8d539961f412a63326fb74c4e1cdf38d6a18 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:28:52 -0700 Subject: [PATCH 45/91] fix: don't carry separator width into a restarted search match When a partial match accumulated pending separator bytes and the next codepoint mismatched (matched falls to 0) but began a new match, currentCodepointWidth still included those stale separators, over-counting the reported match width. Drop the carried width when matched resets so the highlight span counts only the codepoint's own bytes. (Match detection is unaffected; only the width magnitude the highlighter consumes.) --- lib/Epub/Epub/SearchMatcher.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 27b2d4a7b7..ee5ffe0d69 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -174,7 +174,8 @@ int SearchMatcher::feed(uint8_t c) { } currentCodepointId++; - uint16_t currentCodepointWidth = utf8BytesConsumed + pendingSeparatorBytes; + const uint16_t ownBytes = utf8BytesConsumed; + uint16_t currentCodepointWidth = ownBytes + pendingSeparatorBytes; utf8BytesConsumed = 0; pendingSeparatorBytes = 0; @@ -198,7 +199,13 @@ int SearchMatcher::feed(uint8_t c) { } if (matched == 0) { + // This codepoint restarts (or never started) a match, so separators + // accumulated during the previous partial match are not part of this + // match's span: drop the carried width and count only this codepoint's + // own bytes. Without this, currentCodepointWidth still includes the stale + // pendingSeparatorBytes captured above, over-counting the highlight span. pendingSeparatorBytes = 0; + currentCodepointWidth = ownBytes; } matchByteWidths[widthBufferHead] = currentCodepointWidth; From 6d510d0cb3b683666ae6f5d40a947425d83da11e Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:57:05 -0700 Subject: [PATCH 46/91] refactor: split search scan/serialize into their own translation units Move Section::scanForward()/ensureSearchHeader() into SectionSearch.cpp and Page::serializeSearchText() into PageSearch.cpp, leaving the much larger Section.cpp/Page.cpp cache writer/reader free of search logic. A class may define its methods across several .cpp files, so an upstream edit to those files can no longer textually collide with the search bodies. Share the on-disk layout constants (magic, version, header size, page-LUT stride) via the new SectionCacheFormat.h so the two translation units cannot drift apart on the binary format. --- docs/search-architecture.md | 6 +- lib/Epub/Epub/Page.cpp | 62 +---------- lib/Epub/Epub/PageSearch.cpp | 69 +++++++++++++ lib/Epub/Epub/Section.cpp | 159 ++--------------------------- lib/Epub/Epub/SectionCacheFormat.h | 26 +++++ lib/Epub/Epub/SectionSearch.cpp | 156 ++++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 213 deletions(-) create mode 100644 lib/Epub/Epub/PageSearch.cpp create mode 100644 lib/Epub/Epub/SectionCacheFormat.h create mode 100644 lib/Epub/Epub/SectionSearch.cpp diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 0f48f8787f..fd9444541d 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -92,8 +92,10 @@ Implementation entry points: - reader orchestration: [`EpubReaderActivity.cpp`](../../src/activities/reader/EpubReaderActivity.cpp) - on-page highlighting: [`SearchHighlighter.cpp`](../../src/activities/reader/SearchHighlighter.cpp) - cooperative scan activity: [`EpubReaderSearchActivity.cpp`](../../src/activities/reader/EpubReaderSearchActivity.cpp) -- cache creation and streaming matcher: [`Section.cpp`](../../lib/Epub/Epub/Section.cpp) -- per-page text serialization: [`Page.cpp`](../../lib/Epub/Epub/Page.cpp) +- cache creation: [`Section.cpp`](../../lib/Epub/Epub/Section.cpp) +- forward scan over the cache: [`SectionSearch.cpp`](../../lib/Epub/Epub/SectionSearch.cpp) (`Section::scanForward`/`ensureSearchHeader`, split out of `Section.cpp`) +- per-page text serialization: [`PageSearch.cpp`](../../lib/Epub/Epub/PageSearch.cpp) (`Page::serializeSearchText`, split out of `Page.cpp`) +- shared on-disk cache layout constants: [`SectionCacheFormat.h`](../../lib/Epub/Epub/SectionCacheFormat.h) ## Section cache format diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index cb2f01ae08..79ecaf5e1d 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -419,67 +419,7 @@ bool Page::serialize(FsFile& file) const { return true; } -bool Page::serializeSearchText(FsFile& file) const { - // Single pass: write a placeholder length, stream the words while counting - // the bytes emitted, then seek back and back-patch the real length. Keeping - // one walk of the elements/words means the recorded length can never diverge - // from the bytes actually written (the file is O_RDWR and seekable). A page - // cannot hold anywhere near 4 GB of text, so a uint32 byte count cannot wrap. - const uint32_t lengthPos = file.position(); - uint32_t textLength = 0; - if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { - LOG_ERR("PGE", "Failed to write search text length"); - return false; - } - - bool hasWord = false; - static constexpr uint8_t WORD_SEPARATOR = ' '; - for (const auto& element : elements) { - if (element->getTag() != TAG_PageLine) { - continue; - } - - const auto& line = static_cast(*element); - if (!line.getBlock()) { - continue; - } - - for (const auto& word : line.getBlock()->getWords()) { - if (hasWord) { - if (file.write(&WORD_SEPARATOR, sizeof(WORD_SEPARATOR)) != sizeof(WORD_SEPARATOR)) { - LOG_ERR("PGE", "Failed to write search text separator"); - return false; - } - ++textLength; - } - if (!word.empty()) { - if (file.write(reinterpret_cast(word.data()), word.size()) != word.size()) { - LOG_ERR("PGE", "Failed to write search text word"); - return false; - } - textLength += static_cast(word.size()); - } - hasWord = true; - } - } - - const uint32_t endPos = file.position(); - if (!file.seek(lengthPos)) { - LOG_ERR("PGE", "Failed to seek for search text length back-patch"); - return false; - } - if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { - LOG_ERR("PGE", "Failed to back-patch search text length"); - return false; - } - // Restore the write position to the record end so the next page appends - // correctly; failing this would corrupt the following page's data. - if (!file.seek(endPos)) { - LOG_ERR("PGE", "Failed to restore position after search text back-patch"); - return false; - } - return true; -} +// Page::serializeSearchText() is defined in PageSearch.cpp. std::unique_ptr Page::deserialize(FsFile& file) { auto* rawPage = new (std::nothrow) Page(); diff --git a/lib/Epub/Epub/PageSearch.cpp b/lib/Epub/Epub/PageSearch.cpp new file mode 100644 index 0000000000..1ee8534c39 --- /dev/null +++ b/lib/Epub/Epub/PageSearch.cpp @@ -0,0 +1,69 @@ +// Serialization of a page's flattened search text into the section cache. +// Kept in its own translation unit (rather than Page.cpp) so the search +// feature's cache contribution can evolve without colliding with unrelated +// edits to the much larger page (de)serialization code. +#include + +#include "Page.h" + +bool Page::serializeSearchText(FsFile& file) const { + // Single pass: write a placeholder length, stream the words while counting + // the bytes emitted, then seek back and back-patch the real length. Keeping + // one walk of the elements/words means the recorded length can never diverge + // from the bytes actually written (the file is O_RDWR and seekable). A page + // cannot hold anywhere near 4 GB of text, so a uint32 byte count cannot wrap. + const uint32_t lengthPos = file.position(); + uint32_t textLength = 0; + if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { + LOG_ERR("PGE", "Failed to write search text length"); + return false; + } + + bool hasWord = false; + static constexpr uint8_t WORD_SEPARATOR = ' '; + for (const auto& element : elements) { + if (element->getTag() != TAG_PageLine) { + continue; + } + + const auto& line = static_cast(*element); + if (!line.getBlock()) { + continue; + } + + for (const auto& word : line.getBlock()->getWords()) { + if (hasWord) { + if (file.write(&WORD_SEPARATOR, sizeof(WORD_SEPARATOR)) != sizeof(WORD_SEPARATOR)) { + LOG_ERR("PGE", "Failed to write search text separator"); + return false; + } + ++textLength; + } + if (!word.empty()) { + if (file.write(reinterpret_cast(word.data()), word.size()) != word.size()) { + LOG_ERR("PGE", "Failed to write search text word"); + return false; + } + textLength += static_cast(word.size()); + } + hasWord = true; + } + } + + const uint32_t endPos = file.position(); + if (!file.seek(lengthPos)) { + LOG_ERR("PGE", "Failed to seek for search text length back-patch"); + return false; + } + if (file.write(reinterpret_cast(&textLength), sizeof(textLength)) != sizeof(textLength)) { + LOG_ERR("PGE", "Failed to back-patch search text length"); + return false; + } + // Restore the write position to the record end so the next page appends + // correctly; failing this would corrupt the following page's data. + if (!file.seek(endPos)) { + LOG_ERR("PGE", "Failed to restore position after search text back-patch"); + return false; + } + return true; +} diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 3217b5113a..c0905a5f3f 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -15,19 +15,16 @@ #include "AsciiCase.h" #include "Epub/css/CssParser.h" #include "Page.h" +#include "SectionCacheFormat.h" #include "hyphenation/Hyphenator.h" #include "parsers/ChapterHtmlSlimParser.h" +// The on-disk format constants (magic, version, header size, page-LUT stride) +// live in SectionCacheFormat.h so the search scanner shares one definition. +using namespace epub; + namespace { -constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" -// v42: page LUT entries include offsets to compact text records used by search. -constexpr uint8_t SECTION_FILE_VERSION = 42; constexpr uint16_t INITIAL_SECTION_PAGE_LUT_ENTRIES = 1024; -constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(int) + sizeof(float) + - sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + - sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + - sizeof(bool) + sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + - sizeof(uint32_t) + sizeof(uint32_t); // The header ends with a fixed trailer written last and patched after layout // (see writeSectionFileHeader): a uint16_t pageCount followed by four uint32_t @@ -86,11 +83,9 @@ struct ScopedSectionFile { bool ok() const { return static_cast(file); } }; -// On-disk page LUT stride: only pageOffset and searchTextOffset are stored -// inline; paragraphIndex and listItemIndex are written to separate LUTs. -constexpr size_t PAGE_LUT_ENTRY_SIZE = sizeof(uint32_t) * 2; -// Bind the stride to the two inline offset fields so adding or resizing an -// inline LUT field can't silently desync it from the write/read sites. +// Bind the on-disk page-LUT stride (PAGE_LUT_ENTRY_SIZE, in SectionCacheFormat.h) +// to the two inline offset fields so adding or resizing an inline LUT field +// can't silently desync it from the write/read sites. static_assert(PAGE_LUT_ENTRY_SIZE == sizeof(PageLutEntry::fileOffset) + sizeof(PageLutEntry::searchTextOffset), "On-disk page-LUT stride must match the inline offset fields"); } // namespace @@ -646,144 +641,8 @@ void Section::resetForSpine(const int newSpineIndex) { pageCount = 0; currentPage = 0; } -bool Section::ensureSearchHeader() { - if (searchHeaderReady) { - return true; - } - - // Open the member file handle lazily on the first call. It stays open for - // all pages in this section; resetForSpine() closes it when advancing. - if (!file) { - if (!Storage.openFileForRead("SCT", filePath, file)) { - return false; - } - } - const uint32_t fileSize = file.size(); - if (fileSize < HEADER_SIZE) { - LOG_ERR("SCT", "Search failed: section cache header is truncated"); - // Release the handle so the corrupt cache can be invalidated/rebuilt; the - // next call reopens lazily (searchHeaderReady stays false). - closeSearchState(); - return false; - } - - uint32_t lutOffset = 0; - if (!readPageLutOffset(lutOffset)) { - LOG_ERR("SCT", "Search failed: could not read page LUT offset"); - closeSearchState(); - return false; - } - - searchFileSize = fileSize; - searchLutOffset = lutOffset; - searchHeaderReady = true; - return true; -} - -Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher) { - if (startPage >= pageCount || startPage >= endPage) { - return {ScanStatus::NoMatch, -1}; - } - if (endPage > pageCount) { - endPage = pageCount; - } - - // File size and page-LUT offset are invariant per section; read them once. - if (!ensureSearchHeader()) { - return {ScanStatus::CorruptCache, -1}; - } - const uint32_t fileSize = searchFileSize; - const uint32_t lutOffset = searchLutOffset; - - const uint16_t count = endPage - startPage; - const uint64_t entryOffset = - static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * startPage; - if (lutOffset == 0 || entryOffset > fileSize || - fileSize - entryOffset < static_cast(PAGE_LUT_ENTRY_SIZE) * count) { - LOG_ERR("SCT", "Search failed: invalid page LUT entry range"); - closeSearchState(); - return {ScanStatus::CorruptCache, -1}; - } - - // Batch read the LUT entries for the requested page range into a reused - // buffer, allocated (or grown) once with nothrow ownership so repeated chunked - // scans do not churn the heap and an allocation failure is a recoverable - // search error rather than an abort. - const size_t lutBytes = static_cast(count) * PAGE_LUT_ENTRY_SIZE; - if (searchLutBufCapacity < lutBytes) { - searchLutBuf = makeUniqueNoThrow(lutBytes); - if (!searchLutBuf) { - searchLutBufCapacity = 0; - LOG_ERR("SCT", "Search failed: OOM for page LUT buffer (%u bytes)", static_cast(lutBytes)); - closeSearchState(); - return {ScanStatus::IoError, -1}; - } - searchLutBufCapacity = lutBytes; - } - if (!file.seek(static_cast(entryOffset))) { - LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); - closeSearchState(); - return {ScanStatus::IoError, -1}; - } - if (file.read(searchLutBuf.get(), lutBytes) != lutBytes) { - LOG_ERR("SCT", "Search failed: could not read page LUT entries"); - closeSearchState(); - return {ScanStatus::CorruptCache, -1}; - } - - // Sequentially read the text records - std::array buffer; - for (uint16_t i = 0; i < count; i++) { - uint32_t searchTextOffset = 0; - // searchTextOffset is the 2nd uint32_t in the LUT entry - memcpy(&searchTextOffset, searchLutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); - if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { - LOG_ERR("SCT", "Search failed: invalid text record offset"); - closeSearchState(); - return {ScanStatus::CorruptCache, -1}; - } - - if (!file.seek(searchTextOffset)) { - LOG_ERR("SCT", "Search failed: could not seek to text record"); - closeSearchState(); - return {ScanStatus::IoError, -1}; - } - - uint32_t remaining = 0; - if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || - remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { - LOG_ERR("SCT", "Search failed: invalid text record length"); - closeSearchState(); - return {ScanStatus::CorruptCache, -1}; - } - - // A page with no searchable text (e.g. image-only) is a content discontinuity, - // so drop any carried partial match rather than bridging across it. - if (remaining == 0) { - matcher.reset(); - continue; - } - - while (remaining > 0) { - const size_t chunkSize = std::min(buffer.size(), remaining); - if (file.read(buffer.data(), chunkSize) != chunkSize) { - LOG_ERR("SCT", "Search failed: truncated text record"); - closeSearchState(); - return {ScanStatus::CorruptCache, -1}; - } - remaining -= chunkSize; - - for (size_t j = 0; j < chunkSize; ++j) { - if (matcher.feed(buffer[j]) > 0) { - return {ScanStatus::Match, static_cast(startPage + i)}; - } - } - } - } - - return {ScanStatus::NoMatch, -1}; -} +// ensureSearchHeader() and scanForward() are defined in SectionSearch.cpp. std::optional Section::getCachedPageCount() { ScopedSectionFile sf(file, filePath); diff --git a/lib/Epub/Epub/SectionCacheFormat.h b/lib/Epub/Epub/SectionCacheFormat.h new file mode 100644 index 0000000000..56b1b0e796 --- /dev/null +++ b/lib/Epub/Epub/SectionCacheFormat.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +// On-disk section cache format constants, shared between the cache +// writer/reader (Section.cpp) and the forward search scanner +// (SectionSearch.cpp). Keeping the header size and page-LUT stride in one place +// means the two translation units cannot drift apart on the binary layout. +namespace epub { + +constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" +// v42: page LUT entries include offsets to compact text records used by search. +constexpr uint8_t SECTION_FILE_VERSION = 42; + +constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(int) + sizeof(float) + + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + + sizeof(bool) + sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + + sizeof(uint32_t) + sizeof(uint32_t); + +// On-disk page LUT stride: only pageOffset and searchTextOffset are stored +// inline; paragraphIndex and listItemIndex are written to separate LUTs. +constexpr size_t PAGE_LUT_ENTRY_SIZE = sizeof(uint32_t) * 2; + +} // namespace epub diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp new file mode 100644 index 0000000000..030a8ee611 --- /dev/null +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -0,0 +1,156 @@ +// Forward text-search scanning over a section's cache file. These Section +// methods live in their own translation unit (rather than Section.cpp) so the +// search feature can evolve without colliding with unrelated edits to the much +// larger section cache writer/reader. They share the on-disk layout constants +// via SectionCacheFormat.h. +#include +#include +#include + +#include +#include +#include + +#include "Section.h" +#include "SectionCacheFormat.h" + +using namespace epub; + +bool Section::ensureSearchHeader() { + if (searchHeaderReady) { + return true; + } + + // Open the member file handle lazily on the first call. It stays open for + // all pages in this section; resetForSpine() closes it when advancing. + if (!file) { + if (!Storage.openFileForRead("SCT", filePath, file)) { + return false; + } + } + + const uint32_t fileSize = file.size(); + if (fileSize < HEADER_SIZE) { + LOG_ERR("SCT", "Search failed: section cache header is truncated"); + // Release the handle so the corrupt cache can be invalidated/rebuilt; the + // next call reopens lazily (searchHeaderReady stays false). + closeSearchState(); + return false; + } + + uint32_t lutOffset = 0; + if (!readPageLutOffset(lutOffset)) { + LOG_ERR("SCT", "Search failed: could not read page LUT offset"); + closeSearchState(); + return false; + } + + searchFileSize = fileSize; + searchLutOffset = lutOffset; + searchHeaderReady = true; + return true; +} + +Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, SearchMatcher& matcher) { + if (startPage >= pageCount || startPage >= endPage) { + return {ScanStatus::NoMatch, -1}; + } + if (endPage > pageCount) { + endPage = pageCount; + } + + // File size and page-LUT offset are invariant per section; read them once. + if (!ensureSearchHeader()) { + return {ScanStatus::CorruptCache, -1}; + } + const uint32_t fileSize = searchFileSize; + const uint32_t lutOffset = searchLutOffset; + + const uint16_t count = endPage - startPage; + const uint64_t entryOffset = + static_cast(lutOffset) + static_cast(PAGE_LUT_ENTRY_SIZE) * startPage; + if (lutOffset == 0 || entryOffset > fileSize || + fileSize - entryOffset < static_cast(PAGE_LUT_ENTRY_SIZE) * count) { + LOG_ERR("SCT", "Search failed: invalid page LUT entry range"); + closeSearchState(); + return {ScanStatus::CorruptCache, -1}; + } + + // Batch read the LUT entries for the requested page range into a reused + // buffer, allocated (or grown) once with nothrow ownership so repeated chunked + // scans do not churn the heap and an allocation failure is a recoverable + // search error rather than an abort. + const size_t lutBytes = static_cast(count) * PAGE_LUT_ENTRY_SIZE; + if (searchLutBufCapacity < lutBytes) { + searchLutBuf = makeUniqueNoThrow(lutBytes); + if (!searchLutBuf) { + searchLutBufCapacity = 0; + LOG_ERR("SCT", "Search failed: OOM for page LUT buffer (%u bytes)", static_cast(lutBytes)); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + searchLutBufCapacity = lutBytes; + } + if (!file.seek(static_cast(entryOffset))) { + LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + if (file.read(searchLutBuf.get(), lutBytes) != lutBytes) { + LOG_ERR("SCT", "Search failed: could not read page LUT entries"); + closeSearchState(); + return {ScanStatus::CorruptCache, -1}; + } + + // Sequentially read the text records + std::array buffer; + for (uint16_t i = 0; i < count; i++) { + uint32_t searchTextOffset = 0; + // searchTextOffset is the 2nd uint32_t in the LUT entry + memcpy(&searchTextOffset, searchLutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); + if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record offset"); + closeSearchState(); + return {ScanStatus::CorruptCache, -1}; + } + + if (!file.seek(searchTextOffset)) { + LOG_ERR("SCT", "Search failed: could not seek to text record"); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + + uint32_t remaining = 0; + if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || + remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { + LOG_ERR("SCT", "Search failed: invalid text record length"); + closeSearchState(); + return {ScanStatus::CorruptCache, -1}; + } + + // A page with no searchable text (e.g. image-only) is a content discontinuity, + // so drop any carried partial match rather than bridging across it. + if (remaining == 0) { + matcher.reset(); + continue; + } + + while (remaining > 0) { + const size_t chunkSize = std::min(buffer.size(), remaining); + if (file.read(buffer.data(), chunkSize) != chunkSize) { + LOG_ERR("SCT", "Search failed: truncated text record"); + closeSearchState(); + return {ScanStatus::CorruptCache, -1}; + } + remaining -= chunkSize; + + for (size_t j = 0; j < chunkSize; ++j) { + if (matcher.feed(buffer[j]) > 0) { + return {ScanStatus::Match, static_cast(startPage + i)}; + } + } + } + } + + return {ScanStatus::NoMatch, -1}; +} From 62d38d3863f073f79c37e7c8400dfb0e5dfc7e61 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:57:56 -0700 Subject: [PATCH 47/91] refactor: bundle Section search-scan state into one struct Group the five scattered scan members (header-ready flag, cached file size and LUT offset, reused LUT buffer and its capacity) into a single SearchScanState member. The search feature now adds one member to Section rather than several interleaved fields, shrinking the diff against upstream's header. --- lib/Epub/Epub/Section.cpp | 2 +- lib/Epub/Epub/Section.h | 33 +++++++++++++++++++-------------- lib/Epub/Epub/SectionSearch.cpp | 28 ++++++++++++++-------------- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index c0905a5f3f..af774ba632 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -631,7 +631,7 @@ void Section::closeSearchState() { if (file) { file.close(); } - searchHeaderReady = false; + searchScan.headerReady = false; } void Section::resetForSpine(const int newSpineIndex) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index db3a942e2e..eceb8aefdc 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -35,20 +35,25 @@ class Section { std::string cacheSuffix; - // Cached section-header state for the search scan: the file size and page-LUT - // offset are invariant per section, so they are read once when the scan file - // is lazily opened and reused for every scanForward() call. Invalidated - // by resetForSpine() (which also closes the file). - bool searchHeaderReady = false; - uint32_t searchFileSize = 0; - uint32_t searchLutOffset = 0; - - // Reused scratch buffer for batched page-LUT reads in scanForward(). Allocated - // once on first use (nothrow) and grown only if a larger page range appears, - // so repeated chunked scans do not churn the heap; an OOM is a recoverable - // search failure rather than an abort. Freed when the Section is destroyed. - std::unique_ptr searchLutBuf; - size_t searchLutBufCapacity = 0; + // Per-section state for the forward search scan (scanForward(), defined in + // SectionSearch.cpp), grouped so the feature adds one member to this class + // rather than several interleaved fields. Invalidated by resetForSpine(), + // which closes the file via closeSearchState(). + struct SearchScanState { + // The file size and page-LUT offset are invariant per section, so they are + // read once when the scan file is lazily opened and reused for every scan. + bool headerReady = false; + uint32_t fileSize = 0; + uint32_t lutOffset = 0; + + // Reused scratch buffer for batched page-LUT reads. Allocated once on first + // use (nothrow) and grown only if a larger page range appears, so repeated + // chunked scans do not churn the heap; an OOM is a recoverable search + // failure rather than an abort. Freed when the Section is destroyed. + std::unique_ptr lutBuf; + size_t lutBufCapacity = 0; + }; + SearchScanState searchScan; bool writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 030a8ee611..9a32fa277f 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -17,7 +17,7 @@ using namespace epub; bool Section::ensureSearchHeader() { - if (searchHeaderReady) { + if (searchScan.headerReady) { return true; } @@ -33,7 +33,7 @@ bool Section::ensureSearchHeader() { if (fileSize < HEADER_SIZE) { LOG_ERR("SCT", "Search failed: section cache header is truncated"); // Release the handle so the corrupt cache can be invalidated/rebuilt; the - // next call reopens lazily (searchHeaderReady stays false). + // next call reopens lazily (headerReady stays false). closeSearchState(); return false; } @@ -45,9 +45,9 @@ bool Section::ensureSearchHeader() { return false; } - searchFileSize = fileSize; - searchLutOffset = lutOffset; - searchHeaderReady = true; + searchScan.fileSize = fileSize; + searchScan.lutOffset = lutOffset; + searchScan.headerReady = true; return true; } @@ -63,8 +63,8 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S if (!ensureSearchHeader()) { return {ScanStatus::CorruptCache, -1}; } - const uint32_t fileSize = searchFileSize; - const uint32_t lutOffset = searchLutOffset; + const uint32_t fileSize = searchScan.fileSize; + const uint32_t lutOffset = searchScan.lutOffset; const uint16_t count = endPage - startPage; const uint64_t entryOffset = @@ -81,22 +81,22 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S // scans do not churn the heap and an allocation failure is a recoverable // search error rather than an abort. const size_t lutBytes = static_cast(count) * PAGE_LUT_ENTRY_SIZE; - if (searchLutBufCapacity < lutBytes) { - searchLutBuf = makeUniqueNoThrow(lutBytes); - if (!searchLutBuf) { - searchLutBufCapacity = 0; + if (searchScan.lutBufCapacity < lutBytes) { + searchScan.lutBuf = makeUniqueNoThrow(lutBytes); + if (!searchScan.lutBuf) { + searchScan.lutBufCapacity = 0; LOG_ERR("SCT", "Search failed: OOM for page LUT buffer (%u bytes)", static_cast(lutBytes)); closeSearchState(); return {ScanStatus::IoError, -1}; } - searchLutBufCapacity = lutBytes; + searchScan.lutBufCapacity = lutBytes; } if (!file.seek(static_cast(entryOffset))) { LOG_ERR("SCT", "Search failed: could not seek to page LUT entries"); closeSearchState(); return {ScanStatus::IoError, -1}; } - if (file.read(searchLutBuf.get(), lutBytes) != lutBytes) { + if (file.read(searchScan.lutBuf.get(), lutBytes) != lutBytes) { LOG_ERR("SCT", "Search failed: could not read page LUT entries"); closeSearchState(); return {ScanStatus::CorruptCache, -1}; @@ -107,7 +107,7 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S for (uint16_t i = 0; i < count; i++) { uint32_t searchTextOffset = 0; // searchTextOffset is the 2nd uint32_t in the LUT entry - memcpy(&searchTextOffset, searchLutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); + memcpy(&searchTextOffset, searchScan.lutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); From 9db6f8acc5e50f2c1b17828b3bfcde776f8276ef Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 08:58:05 -0700 Subject: [PATCH 48/91] refactor: extract search route planning into SearchRoute::plan Move the start spine/page resolution and fresh-vs-"find next" decision out of EpubReaderActivity::launchBookSearch and into a pure SearchRoute::plan(Origin) factory next to SearchRoute::make. The activity now just snapshots its launch state into the Origin struct; the branchy policy lives with the search route, out of the 196 KB reader activity and host-testable in isolation. --- src/activities/reader/EpubReaderActivity.cpp | 18 +++++++---------- .../reader/EpubReaderSearchActivity.cpp | 16 +++++++++++++++ .../reader/EpubReaderSearchActivity.h | 20 +++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index eea9825af5..6dca02ce1a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3335,18 +3335,14 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { const int realPage = (activeFootnotePreview && footnoteDepth > 0) ? savedPositions[footnoteDepth - 1].pageNumber : resumePage; - const int searchStartSpine = (realSpine >= 0 && realSpine < epub->getSpineItemsCount()) ? realSpine : 0; - const bool hasPendingPageRemap = !section && cachedChapterTotalPageCount > 0 && cachedSpineIndex == searchStartSpine; - // The page the search is initiated from (the wrap normally stops before - // re-examining it). A fresh search may revisit it only to complete a match - // begun on the preceding page. "Find next" begins one page past it so a wrap - // cannot re-return it; SearchRoute owns that start/stop relationship. - const int initiatedFromPage = searchStartSpine == realSpine ? std::max(0, realPage) : 0; const bool sameQuery = strcmp(lastSearchQuery.data(), query.c_str()) == 0; - const bool isFindNext = - !hasPendingPageRemap && sameQuery && lastSearchResultSpine == realSpine && lastSearchResultPage == realPage; - const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::make( - searchStartSpine, initiatedFromPage, isFindNext, hasPendingPageRemap ? cachedChapterTotalPageCount : 0); + // Resolve the start spine/page and the fresh-vs-"find next" decision in one + // pure, host-testable place rather than inline here. The route owns the + // start/stop relationship (a wrap stops before re-examining the originating + // page; find-next begins one page past it). + const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::plan( + {realSpine, realPage, epub->getSpineItemsCount(), section != nullptr, cachedChapterTotalPageCount, + cachedSpineIndex, sameQuery, lastSearchResultSpine, lastSearchResultPage}); const ReaderViewportLayout viewport = computeReaderViewportLayout(renderer, automaticPageTurnActive); diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 7bfa0027b9..b31f54e760 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -20,6 +20,22 @@ namespace { constexpr int PROGRESS_REPAINT_STEP_PERCENT = 1; } // namespace +EpubReaderSearchActivity::SearchRoute EpubReaderSearchActivity::SearchRoute::plan(const Origin& origin) { + const int startSpine = (origin.spineIndex >= 0 && origin.spineIndex < origin.spineItemsCount) ? origin.spineIndex : 0; + // A pending page remap means the section was reflowed but not yet reloaded, so + // the cached page count belongs to this start spine and must drive the remap. + const bool hasPendingPageRemap = + !origin.sectionLoaded && origin.cachedPageCount > 0 && origin.cachedSpineIndex == startSpine; + // The page the search is initiated from (the wrap normally stops before + // re-examining it). Only meaningful when the start spine is the reader's spine. + const int initiatedFromPage = startSpine == origin.spineIndex ? std::max(0, origin.page) : 0; + // "Find next" only when repeating the same query from the exact previous match + // and no remap is pending; it then begins one page past the originating page. + const bool isFindNext = !hasPendingPageRemap && origin.sameQuery && + origin.lastResultSpineIndex == origin.spineIndex && origin.lastResultPage == origin.page; + return make(startSpine, initiatedFromPage, isFindNext, hasPendingPageRemap ? origin.cachedPageCount : 0); +} + void EpubReaderSearchActivity::SearchRoute::resolvePageCount(const int targetPageCount) { if (sourcePageCount <= 0) { return; diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h index 9212a99760..01baa9e614 100644 --- a/src/activities/reader/EpubReaderSearchActivity.h +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -30,6 +30,26 @@ class EpubReaderSearchActivity final : public Activity { return SearchRoute{spineIndex, initiatedFromPage + (findNext ? 1 : 0), initiatedFromPage, sourcePageCount}; } + // The reader state a search is launched from. plan() turns this into a route + // without touching reader internals, so the start/stop and fresh-vs-find-next + // policy is one pure, host-testable function instead of inline activity code. + struct Origin { + int spineIndex; // the spine the reader is on (may be out of range) + int page; // the page the reader is on + int spineItemsCount; // total spine items, to clamp spineIndex + bool sectionLoaded; // whether a Section is currently loaded + int cachedPageCount; // pending-remap source page count, 0 if none + int cachedSpineIndex; // spine the cached page count belongs to + bool sameQuery; // query matches the previous search + int lastResultSpineIndex; // spine of the previous match, -1 if none + int lastResultPage; // page of the previous match, -1 if none + }; + + // Resolve the start spine/page and decide fresh search vs "find next" from + // the reader's launch state. A repeated find-next on the same match begins + // one page past it; everything else begins at the originating page. + static SearchRoute plan(const Origin& origin); + // Translate page coordinates captured before a viewport reflow into the // loaded section's pagination. A zero source count means no remap is pending. void resolvePageCount(int targetPageCount); From 39c74558e0f100266d523795e44e13e64ea61343 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 09:23:41 -0700 Subject: [PATCH 49/91] fix: include spine in search highlight cache key The on-page search highlight cache keyed only on page and query, so a 'find next' that lands on the same page number in a different spine hit renderContents with both spine and page matching the last result (so release() never fired) and repainted the previous spine's match ranges. Track currentSpineIndex alongside the cached page/query, pass it into drawSearchHighlights, and reset it in release(). --- CHANGELOG.md | 5 +++++ src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/reader/SearchHighlighter.cpp | 8 ++++++-- src/activities/reader/SearchHighlighter.h | 16 ++++++++++------ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cc302aa4..4c0c891976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [Unreleased] + +### Fixed +- Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. + ## [v1.3.4] - 2026-06-24 ### Added diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6dca02ce1a..625747cf41 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4164,7 +4164,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const char* activeSearchQuery = (lastSearchResultSpine != -1 && lastSearchResultPage != -1) ? lastSearchQuery.data() : nullptr; searchHighlighter.drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft, section.get(), - activeSearchQuery, renderer); + currentSpineIndex, activeSearchQuery, renderer); drawPublisherPageMarkers(renderer, *page, orientedMarginTop, contentBottom, foregroundBlack); }; diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 026f9396b3..7541196892 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -13,7 +13,8 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, const int orientedMarginLeft, Section* section, - const char* lastSearchQuery, GfxRenderer& renderer) const { + const int currentSpineIndex, const char* lastSearchQuery, + GfxRenderer& renderer) const { if (lastSearchQuery == nullptr || lastSearchQuery[0] == '\0' || !section) { return; } @@ -22,10 +23,12 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, // While the reader sits on the search-result page (status-bar refreshes, etc.) // this avoids re-reading the previous page from SD and recompiling the matcher // on every frame; we just repaint the cached ranges. - const bool cacheHit = searchHighlightComputed && section->currentPage == searchHighlightCachedPage && + const bool cacheHit = searchHighlightComputed && searchHighlightCachedSpine == currentSpineIndex && + section->currentPage == searchHighlightCachedPage && searchHighlightCachedQuery == lastSearchQuery; if (!cacheHit) { searchHighlightComputed = true; + searchHighlightCachedSpine = currentSpineIndex; searchHighlightCachedPage = section->currentPage; searchHighlightCachedQuery = lastSearchQuery; searchHighlightMatchRanges.clear(); @@ -125,6 +128,7 @@ void SearchHighlighter::release() { searchHighlightMatchRanges.clear(); searchHighlightMatchRanges.shrink_to_fit(); searchHighlightComputed = false; + searchHighlightCachedSpine = -1; searchHighlightCachedPage = -1; searchHighlightCachedQuery.clear(); searchHighlightCachedQuery.shrink_to_fit(); diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h index af11ebf061..7a19054d21 100644 --- a/src/activities/reader/SearchHighlighter.h +++ b/src/activities/reader/SearchHighlighter.h @@ -14,8 +14,8 @@ class SearchHighlighter { SearchHighlighter() = default; void drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, - const int orientedMarginLeft, Section* section, const char* lastSearchQuery, - GfxRenderer& renderer) const; + const int orientedMarginLeft, Section* section, const int currentSpineIndex, + const char* lastSearchQuery, GfxRenderer& renderer) const; // Free the scratch buffers (~12 KB). Called when the on-page highlight is no // longer active so a reader who is not viewing a search result does not hold @@ -30,11 +30,15 @@ class SearchHighlighter { mutable std::vector searchHighlightCharToWordIndex; mutable std::vector> searchHighlightMatchRanges; - // Memo of the (page, query) the cached match ranges were computed for, so - // repeated renders of the same search-result page (status-bar refreshes, etc.) - // reuse the ranges instead of re-reading the previous page from SD and - // recompiling the matcher each frame. Invalidated by release(). + // Memo of the (spine, page, query) the cached match ranges were computed for, + // so repeated renders of the same search-result page (status-bar refreshes, + // etc.) reuse the ranges instead of re-reading the previous page from SD and + // recompiling the matcher each frame. The spine must be part of the key: the + // same page number can recur in a different spine (e.g. "find next" landing on + // the same page index of another chapter), and keying on page+query alone + // would repaint the prior spine's ranges. Invalidated by release(). mutable bool searchHighlightComputed = false; + mutable int searchHighlightCachedSpine = -1; mutable int searchHighlightCachedPage = -1; mutable std::string searchHighlightCachedQuery; }; From d1795c04f84b6a9c52be7f6f644d7c02e8e95d88 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 09:24:13 -0700 Subject: [PATCH 50/91] fix: derive search highlight colors from reader theme The on-page search highlight hard-coded a black fill with white text. In dark mode the page is white-on-black, so the black fill was invisible against the background and the white text was indistinguishable from body text, making matches disappear. Derive the inverted fill/text pair from ReaderUtils::readerForegroundBlack() so it stays readable in both themes. --- CHANGELOG.md | 1 + src/activities/reader/SearchHighlighter.cpp | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0c891976..4c7a699c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Fixed +- Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. - Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. ## [v1.3.4] - 2026-06-24 diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 7541196892..960f1d690e 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -10,6 +10,7 @@ #include #include "EpubReaderUtils.h" +#include "ReaderUtils.h" void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, const int orientedMarginLeft, Section* section, @@ -96,7 +97,12 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, } // 4. Highlight matched words on page using the shared geometry helper, with - // the search style: a solid inverted fill (white-on-black) so matches stand out. + // the search style: a solid inverted fill so matches stand out. "Inverted" + // means foreground-on-background swapped relative to body text, so it must + // track the theme: black fill + white text in light mode, white fill + black + // text in dark mode. Hard-coding black/white made the highlight vanish in dark + // mode (black fill on a black page, white text identical to body text). + const bool foregroundBlack = ReaderUtils::readerForegroundBlack(); const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { return std::any_of( searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), @@ -106,8 +112,9 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, EpubReaderUtils::drawWordHighlights(page, renderer, fontId, orientedMarginTop, orientedMarginLeft, isSearchMatchWord, [&](const int wordX, const int wordY, const int wordW, const int wordH, const char* visibleText, const EpdFontFamily::Style textStyle) { - renderer.fillRect(wordX, wordY, wordW, wordH, true); - renderer.drawText(fontId, wordX, wordY, visibleText, false, textStyle); + renderer.fillRect(wordX, wordY, wordW, wordH, foregroundBlack); + renderer.drawText(fontId, wordX, wordY, visibleText, !foregroundBlack, + textStyle); }); } From 7d85ffb74d270adcae1a88020ac98af75153bf6a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 09:24:20 -0700 Subject: [PATCH 51/91] fix: widen paragraph/li LUT bounds checks to 64-bit The paragraph and li LUT end calculations summed a 32-bit offset with the entry-count span in 32-bit arithmetic before comparing against fileSize, so a corrupt offset could wrap into a small in-bounds value. Compute the end in 64-bit before the bounds check, matching the page and search LUT paths that were already hardened this way. --- lib/Epub/Epub/Section.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index af774ba632..3d183bc7cd 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -733,7 +733,10 @@ std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) return std::nullopt; } - const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t); + // Compute in 64-bit so a corrupt (huge) offset cannot wrap the sum into a + // small in-bounds value before the bounds check (mirrors the page/search LUT). + const uint64_t lutEnd = + static_cast(paragraphLutOffset) + sizeof(uint16_t) + static_cast(count) * sizeof(uint16_t); if (lutEnd > fileSize) { return std::nullopt; } @@ -782,7 +785,9 @@ std::optional Section::getParagraphIndexForPage(const uint16_t page) { return std::nullopt; } - const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t); + // 64-bit arithmetic so a corrupt offset cannot wrap before the bounds check. + const uint64_t entryEnd = + static_cast(paragraphLutOffset) + sizeof(uint16_t) + static_cast(page + 1) * sizeof(uint16_t); if (entryEnd > fileSize) { return std::nullopt; } @@ -838,7 +843,8 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) return std::nullopt; } - const uint32_t lutEnd = liLutOffset + count * sizeof(uint16_t); + // 64-bit arithmetic so a corrupt offset cannot wrap before the bounds check. + const uint64_t lutEnd = static_cast(liLutOffset) + static_cast(count) * sizeof(uint16_t); if (lutEnd > fileSize) { return std::nullopt; } From e4fed12eb673580863943bfd4a0b630526bdf27e Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 09:24:29 -0700 Subject: [PATCH 52/91] docs: correct scanForward contract and document codepoint dropping Update the scanForward() contract to the actual Section::ScanResult / ScanStatus{Match,NoMatch,CorruptCache,IoError} return type, including the repairable-corruption vs transient-I/O distinction, replacing the stale std::optional description. Also document that codepoints with no ASCII/Latin folding are dropped on both sides like spaces and hyphens, and why this fuzzy behavior is intentional given the ASCII-only needle. --- docs/search-architecture.md | 43 ++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index fd9444541d..16df6bcc74 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -195,6 +195,18 @@ existing substring behavior (a query already matches inside a longer word) and favors finding a half-remembered passage — e.g. a location last read on another device or in print — over exact-span precision. +Codepoints with no ASCII or Latin folding (CJK, Cyrillic, Greek, unmapped +symbols, etc.) normalize to nothing and are dropped on **both** sides, exactly +like spaces and hyphens. So `"a你b"` is treated as `"ab"` on the query side and +the page side alike, and a search for `"ab"` will match it. This is the same +fuzzy class as the space/hyphen bridging above, not a separate behavior, and it +is intentional rather than a missing boundary check. The needle is an ASCII-only +`uint8_t` array, so an unsupported codepoint can never appear *in* a pattern; +treating it as a hard boundary instead of dropping it would not make `"a你b"` +matchable — it would only stop the text `"a你b"` from matching its own exact +query, regressing search over any non-Latin book. Dropping is therefore the +least-surprising option available within the ASCII-needle constraint. + The matcher's KMP partial-match length is carried across consecutive pages of the same spine, so a query split across a *page* boundary still matches — for example a word the layout hyphenated at the foot of one page (`"…inter-"`) and @@ -205,18 +217,25 @@ page with an empty text record), so it never bridges non-contiguous text. A cross-page match is reported on the page where it *completes* (the second page), which is where the reader opens. -The return type is `std::optional`: - -- `true`: the page contains the query -- `false`: the cache record is valid and does not contain the query -- `std::nullopt`: invalid input, I/O failure, or corrupt/truncated cache data - -This distinction lets an ordinary miss advance to the next page while a cache -failure moves the activity to its translated error state. -On the first cache failure in a spine, the activity closes and removes that -section cache, rebuilds it, restores the matcher state from the start of the -failed page, and retries once. A second failure removes the cache again and -surfaces the error rather than entering an unbounded rebuild loop. +The return type is `Section::ScanResult`, a `Section::ScanStatus` plus a `page` +index (valid only on `Match`): + +- `ScanStatus::Match`: a match was found; `page` holds the page index. +- `ScanStatus::NoMatch`: the requested range was scanned (or was empty) with no + match. The cache is valid. +- `ScanStatus::CorruptCache`: structurally invalid cache data (bad LUT offset, + truncated record, etc.). A rebuild may repair it. +- `ScanStatus::IoError`: a seek/open failure or OOM. Rebuilding will not help. + +This three-way distinction lets an ordinary `NoMatch` advance to the next page +while a failure moves the activity to its translated error state, and — crucially +— it separates *repairable* cache corruption from *transient* I/O failures so the +caller does not delete a valid cache over a momentary glitch. On the first +`CorruptCache` in a spine, the activity closes and removes that section cache, +rebuilds it, restores the matcher state from the start of the failed page, and +retries once; a second `CorruptCache` removes the cache again and surfaces the +error rather than entering an unbounded rebuild loop. An `IoError` is surfaced +without deleting the cache, since a rebuild cannot fix it. ### Alternatives considered From aa4fbc5646b785cf9a69783f21221506474e89d3 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:33:09 -0700 Subject: [PATCH 53/91] fix: don't treat footnote preview as the real loaded section in search When launching book search from a footnote preview, `section` holds the preview's section, not the real chapter at realSpine/realPage. Passing `section != nullptr` as the loaded-section flag falsely reported the real chapter as loaded, so SearchRoute::plan suppressed the pending page remap for realSpine. Pass `section != nullptr && !previewingFootnote` instead. --- src/activities/reader/EpubReaderActivity.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 625747cf41..397fb94d1a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3330,10 +3330,14 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { } const int resumePage = section ? section->currentPage : nextPageNumber; - const int realSpine = - (activeFootnotePreview && footnoteDepth > 0) ? savedPositions[footnoteDepth - 1].spineIndex : currentSpineIndex; - const int realPage = - (activeFootnotePreview && footnoteDepth > 0) ? savedPositions[footnoteDepth - 1].pageNumber : resumePage; + const bool previewingFootnote = activeFootnotePreview && footnoteDepth > 0; + const int realSpine = previewingFootnote ? savedPositions[footnoteDepth - 1].spineIndex : currentSpineIndex; + const int realPage = previewingFootnote ? savedPositions[footnoteDepth - 1].pageNumber : resumePage; + // While previewing a footnote, `section` holds the preview's section, not the + // real chapter at realSpine/realPage. It must not count as that chapter being + // loaded, or plan() treats the cached page count as stale and skips the + // pending page remap for realSpine. + const bool realSectionLoaded = section != nullptr && !previewingFootnote; const bool sameQuery = strcmp(lastSearchQuery.data(), query.c_str()) == 0; // Resolve the start spine/page and the fresh-vs-"find next" decision in one @@ -3341,7 +3345,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { // start/stop relationship (a wrap stops before re-examining the originating // page; find-next begins one page past it). const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::plan( - {realSpine, realPage, epub->getSpineItemsCount(), section != nullptr, cachedChapterTotalPageCount, + {realSpine, realPage, epub->getSpineItemsCount(), realSectionLoaded, cachedChapterTotalPageCount, cachedSpineIndex, sameQuery, lastSearchResultSpine, lastSearchResultPage}); const ReaderViewportLayout viewport = computeReaderViewportLayout(renderer, automaticPageTurnActive); From d6516fede378e3124056258f1b57da0f0eb54e65 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:33:15 -0700 Subject: [PATCH 54/91] docs: fix relative library/source links in search-architecture The implementation-entry-point links climbed one level too far (../../lib, ../../src), resolving outside the repo from docs/. docs/ sits at the repo root, so correct them to ../lib and ../src. --- docs/search-architecture.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 16df6bcc74..a9eb61b2c5 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -89,13 +89,13 @@ reach into SdFat directly. Implementation entry points: -- reader orchestration: [`EpubReaderActivity.cpp`](../../src/activities/reader/EpubReaderActivity.cpp) -- on-page highlighting: [`SearchHighlighter.cpp`](../../src/activities/reader/SearchHighlighter.cpp) -- cooperative scan activity: [`EpubReaderSearchActivity.cpp`](../../src/activities/reader/EpubReaderSearchActivity.cpp) -- cache creation: [`Section.cpp`](../../lib/Epub/Epub/Section.cpp) -- forward scan over the cache: [`SectionSearch.cpp`](../../lib/Epub/Epub/SectionSearch.cpp) (`Section::scanForward`/`ensureSearchHeader`, split out of `Section.cpp`) -- per-page text serialization: [`PageSearch.cpp`](../../lib/Epub/Epub/PageSearch.cpp) (`Page::serializeSearchText`, split out of `Page.cpp`) -- shared on-disk cache layout constants: [`SectionCacheFormat.h`](../../lib/Epub/Epub/SectionCacheFormat.h) +- reader orchestration: [`EpubReaderActivity.cpp`](../src/activities/reader/EpubReaderActivity.cpp) +- on-page highlighting: [`SearchHighlighter.cpp`](../src/activities/reader/SearchHighlighter.cpp) +- cooperative scan activity: [`EpubReaderSearchActivity.cpp`](../src/activities/reader/EpubReaderSearchActivity.cpp) +- cache creation: [`Section.cpp`](../lib/Epub/Epub/Section.cpp) +- forward scan over the cache: [`SectionSearch.cpp`](../lib/Epub/Epub/SectionSearch.cpp) (`Section::scanForward`/`ensureSearchHeader`, split out of `Section.cpp`) +- per-page text serialization: [`PageSearch.cpp`](../lib/Epub/Epub/PageSearch.cpp) (`Page::serializeSearchText`, split out of `Page.cpp`) +- shared on-disk cache layout constants: [`SectionCacheFormat.h`](../lib/Epub/Epub/SectionCacheFormat.h) ## Section cache format From 6c88dbc546e20f1d3619852df32ba2a7de087dec Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:33:23 -0700 Subject: [PATCH 55/91] docs: add blank lines around CHANGELOG Unreleased headings Surround the [Unreleased] heading and the Fixed list with blank lines so the entry satisfies markdownlint MD022/MD032. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7a699c24..9375cb1890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog + ## [Unreleased] ### Fixed + - Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. - Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. From f97d107d19f61d5a0f19c0a38e62abdc36b29bf7 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:42:02 -0700 Subject: [PATCH 56/91] fix: guard lastSearchQuery copy against oversized query lastSearchQuery is a fixed MAX_QUERY_BYTES+1 buffer written by memcpy. The sole caller pre-validates the size via isValidSearchQuery(), so this is not currently reachable with an oversized query, but the write site trusted the caller unconditionally. Clamp the copy to MAX_QUERY_BYTES so the fixed-array write can never overrun. --- src/activities/reader/EpubReaderActivity.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 397fb94d1a..bac0f4b799 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3373,8 +3373,13 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { return; } - memcpy(lastSearchQuery.data(), query.data(), query.size()); - lastSearchQuery[query.size()] = '\0'; + // lastSearchQuery is a fixed MAX_QUERY_BYTES+1 buffer. The sole caller + // validates the size via isValidSearchQuery(), but guard the copy here so this + // fixed-array write can never overrun if reached with an oversized query. + if (query.size() <= SearchMatcher::MAX_QUERY_BYTES) { + memcpy(lastSearchQuery.data(), query.data(), query.size()); + lastSearchQuery[query.size()] = '\0'; + } if (!sameQuery) { lastSearchResultSpine = -1; lastSearchResultPage = -1; From 04a89b4bbc6584bd27c954c496226bc8a84def5d Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:42:30 -0700 Subject: [PATCH 57/91] fix: classify search header failures as IoError vs CorruptCache scanForward() mapped any ensureSearchHeader() failure to CorruptCache, so a file-open or header seek/read failure would wrongly delete and rebuild a valid cache. ensureSearchHeader() now reports the failure source via an out status: open/seek/read failures map to IoError while only a truncated header maps to CorruptCache. Moved ScanStatus/ScanResult to the top of the class so the private helper can reference them. --- lib/Epub/Epub/Section.h | 37 ++++++++++++++++++--------------- lib/Epub/Epub/SectionSearch.cpp | 19 +++++++++++++---- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index eceb8aefdc..a34527a7e6 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -22,6 +22,23 @@ struct SectionBuildOptions { }; class Section { + public: + // Why a forward search scan stopped. Distinguishes a structurally corrupt + // cache (which rebuilding can repair) from a transient I/O failure or OOM + // (which it cannot), so the caller does not delete a valid cache over a + // momentary glitch. + enum class ScanStatus : uint8_t { + Match, // a match was found; `page` holds the page index + NoMatch, // the requested range was scanned with no match (or was empty) + CorruptCache, // structurally invalid cache data; a rebuild may help + IoError, // seek/open failure or OOM; rebuilding will not help + }; + struct ScanResult { + ScanStatus status = ScanStatus::NoMatch; + int page = -1; // valid only when status == Match + }; + + private: std::shared_ptr epub; int spineIndex; GfxRenderer& renderer; @@ -65,8 +82,9 @@ class Section { // open; returns false on seek/read failure. bool readPageLutOffset(uint32_t& lutOffset); // Lazily open the scan file and cache its size and page-LUT offset. Returns - // false on open failure or a truncated/corrupt header. - bool ensureSearchHeader(); + // false on failure, setting failureStatus to IoError for an open/seek/read + // failure or CorruptCache for a truncated/malformed header. + bool ensureSearchHeader(ScanStatus& failureStatus); void closeSearchState(); // Rewrite filePath's numeric suffix in place for the current spineIndex, // reusing the buffer (no per-spine string allocation, no std::to_string). @@ -109,21 +127,6 @@ class Section { // allocation. Intended for sequential, book-wide operations such as search. void resetForSpine(int newSpineIndex); - // Why a forward search scan stopped. Distinguishes a structurally corrupt - // cache (which rebuilding can repair) from a transient I/O failure or OOM - // (which it cannot), so the caller does not delete a valid cache over a - // momentary glitch. - enum class ScanStatus : uint8_t { - Match, // a match was found; `page` holds the page index - NoMatch, // the requested range was scanned with no match (or was empty) - CorruptCache, // structurally invalid cache data; a rebuild may help - IoError, // seek/open failure or OOM; rebuilding will not help - }; - struct ScanResult { - ScanStatus status = ScanStatus::NoMatch; - int page = -1; // valid only when status == Match - }; - // Search forward through cached section pages from `startPage` up to `endPage`, // batching LUT reads and streaming text records sequentially. Returns Match // with the first matching page index, NoMatch when the range is exhausted, or diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 9a32fa277f..2ae3b6c434 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -16,15 +16,19 @@ using namespace epub; -bool Section::ensureSearchHeader() { +bool Section::ensureSearchHeader(ScanStatus& failureStatus) { if (searchScan.headerReady) { return true; } // Open the member file handle lazily on the first call. It stays open for - // all pages in this section; resetForSpine() closes it when advancing. + // all pages in this section; resetForSpine() closes it when advancing. An + // open failure is transient I/O, not cache corruption, so a rebuild cannot + // fix it. if (!file) { if (!Storage.openFileForRead("SCT", filePath, file)) { + LOG_ERR("SCT", "Search failed: could not open section cache file"); + failureStatus = ScanStatus::IoError; return false; } } @@ -35,13 +39,17 @@ bool Section::ensureSearchHeader() { // Release the handle so the corrupt cache can be invalidated/rebuilt; the // next call reopens lazily (headerReady stays false). closeSearchState(); + failureStatus = ScanStatus::CorruptCache; return false; } + // The header fits within the validated fileSize, so a seek/read failure here + // is an I/O problem rather than malformed data. uint32_t lutOffset = 0; if (!readPageLutOffset(lutOffset)) { LOG_ERR("SCT", "Search failed: could not read page LUT offset"); closeSearchState(); + failureStatus = ScanStatus::IoError; return false; } @@ -60,8 +68,11 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } // File size and page-LUT offset are invariant per section; read them once. - if (!ensureSearchHeader()) { - return {ScanStatus::CorruptCache, -1}; + // ensureSearchHeader distinguishes a transient I/O failure from a corrupt + // header so we do not delete a valid cache over a momentary glitch. + ScanStatus headerFailure = ScanStatus::CorruptCache; + if (!ensureSearchHeader(headerFailure)) { + return {headerFailure, -1}; } const uint32_t fileSize = searchScan.fileSize; const uint32_t lutOffset = searchScan.lutOffset; From cd1d45e9a7e653fef2265b1583b409df9c9fb0c2 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 10:42:45 -0700 Subject: [PATCH 58/91] fix: bound search text records against the page LUT offset The text-record offset/length checks only validated against fileSize, so a corrupt LUT entry pointing into the page-LUT or trailer region was read as text. Bound both checks against lutOffset (the end of the page-record region) instead, keeping the existing CorruptCache handling. --- lib/Epub/Epub/SectionSearch.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 2ae3b6c434..1d62ec105e 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -119,7 +119,11 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S uint32_t searchTextOffset = 0; // searchTextOffset is the 2nd uint32_t in the LUT entry memcpy(&searchTextOffset, searchScan.lutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); - if (searchTextOffset > fileSize || fileSize - searchTextOffset < sizeof(uint32_t)) { + // Text records (each a u32 length prefix + bytes) live in the page-record + // region, which ends where the page LUT begins. Bound against lutOffset, not + // just fileSize, so a corrupt offset pointing into the LUT or trailer is + // rejected rather than read as text. (lutOffset <= fileSize, validated above.) + if (searchTextOffset > lutOffset || lutOffset - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); return {ScanStatus::CorruptCache, -1}; @@ -133,7 +137,7 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S uint32_t remaining = 0; if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || - remaining > fileSize - searchTextOffset - sizeof(uint32_t)) { + remaining > lutOffset - searchTextOffset - sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record length"); closeSearchState(); return {ScanStatus::CorruptCache, -1}; From 21deac37b84fd4780e8ecdbb042d996b4e55c5fd Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 11:42:36 -0700 Subject: [PATCH 59/91] fix: clear footnote preview when jumping to a search match --- CHANGELOG.md | 1 + src/activities/reader/EpubReaderActivity.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9375cb1890..091cfe35f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. - Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. +- Jumping to a search match while a footnote preview is open now shows the matched page instead of reopening the footnote preview. ## [v1.3.4] - 2026-06-24 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index bac0f4b799..6380b9c89a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3389,6 +3389,10 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { if (!result.isCancelled) { const auto& match = std::get(result.data); RenderLock lock(*this); + // A search can be launched while a footnote preview is open. Clear the + // preview state before applying the destination so render() shows the + // matched EPUB page instead of reopening the footnote preview. + clearFootnotePreviewState(); currentSpineIndex = match.spineIndex; nextPageNumber = match.page; section.reset(); From b7a02de459bb1f6d112a0edf16113a4b2fc01ba8 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 11:42:41 -0700 Subject: [PATCH 60/91] fix: harden search text-record offset bounds and LUT read error class --- lib/Epub/Epub/SectionSearch.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 1d62ec105e..c06e953966 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -108,9 +108,11 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S return {ScanStatus::IoError, -1}; } if (file.read(searchScan.lutBuf.get(), lutBytes) != lutBytes) { + // The LUT range was already validated against fileSize above, so a short + // read here is an I/O failure, not a corrupt cache. LOG_ERR("SCT", "Search failed: could not read page LUT entries"); closeSearchState(); - return {ScanStatus::CorruptCache, -1}; + return {ScanStatus::IoError, -1}; } // Sequentially read the text records @@ -120,10 +122,13 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S // searchTextOffset is the 2nd uint32_t in the LUT entry memcpy(&searchTextOffset, searchScan.lutBuf.get() + i * PAGE_LUT_ENTRY_SIZE + sizeof(uint32_t), sizeof(uint32_t)); // Text records (each a u32 length prefix + bytes) live in the page-record - // region, which ends where the page LUT begins. Bound against lutOffset, not - // just fileSize, so a corrupt offset pointing into the LUT or trailer is - // rejected rather than read as text. (lutOffset <= fileSize, validated above.) - if (searchTextOffset > lutOffset || lutOffset - searchTextOffset < sizeof(uint32_t)) { + // region, which starts after the fixed header and ends where the page LUT + // begins. Bound below by HEADER_SIZE and above by lutOffset (not just + // fileSize) so a corrupt offset pointing into the header, the LUT, or the + // trailer is rejected rather than read as text. (lutOffset <= fileSize, + // validated above.) + if (searchTextOffset < HEADER_SIZE || searchTextOffset > lutOffset || + lutOffset - searchTextOffset < sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record offset"); closeSearchState(); return {ScanStatus::CorruptCache, -1}; From caf8335310f7a15f8bcae4baf27cdceeb7b40e8d Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 14:20:09 -0700 Subject: [PATCH 61/91] fix: reserve search highlight buffers despite std::string SSO capacity --- CHANGELOG.md | 1 + src/activities/reader/SearchHighlighter.cpp | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091cfe35f3..2ae682d539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. - Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. - Jumping to a search match while a footnote preview is open now shows the matched page instead of reopening the footnote preview. +- Search results now highlight the matched words on the page again; a buffer-allocation guard was skipping the highlighter's setup so no matches were ever marked. ## [v1.3.4] - 2026-06-24 diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 960f1d690e..4acc1ed647 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -119,12 +119,20 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, } void SearchHighlighter::ensureBuffersReserved() const { - if (searchHighlightPageText.capacity() > 0) { + constexpr size_t PAGE_TEXT_CAPACITY = 4096; + constexpr size_t MATCH_RANGES_CAPACITY = 128; + // Gate on the char->word vector, not searchHighlightPageText: the latter is a + // std::string whose capacity() is never 0 (small-string optimization keeps ~15 + // bytes inline), so using it as the "already reserved" sentinel would skip the + // reserve on the first call, leaving the vectors at capacity 0 and tripping the + // build loop's capacity guard on the very first character (empty page text -> + // no highlight). The vector's capacity is genuinely 0 until reserved. + if (searchHighlightCharToWordIndex.capacity() >= PAGE_TEXT_CAPACITY) { return; } - searchHighlightPageText.reserve(4096); - searchHighlightCharToWordIndex.reserve(4096); - searchHighlightMatchRanges.reserve(128); + searchHighlightPageText.reserve(PAGE_TEXT_CAPACITY); + searchHighlightCharToWordIndex.reserve(PAGE_TEXT_CAPACITY); + searchHighlightMatchRanges.reserve(MATCH_RANGES_CAPACITY); } void SearchHighlighter::release() { From b0a7a73469e6df560f0f7751de3bfea63d937547 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 14:53:54 -0700 Subject: [PATCH 62/91] fix: release section cache file and reset matcher after search-highlight priming The search highlighter primes the KMP matcher with the previous page (via Section::scanForward on the reader's live Section) so a match spanning the page boundary still highlights. Two problems with that priming: 1. scanForward leaves the section cache file open on its success paths, and the reader's per-page loads reuse (rather than close) that handle, so the reader sat on an open SD file while parked on a result page. On hardware only one file may be open at a time. Close the search state right after priming, since the rest of the highlighter only reads in-memory buffers. closeSearchState() is made public for this one-shot caller. 2. The matcher was only reset on CorruptCache/IoError. When the previous page also contains the query, scanForward returns Match and stops mid-page, leaving stale carried KMP state that mis-highlighted words on the current page. Reset on any non-NoMatch status so only a clean full previous-page scan carries boundary state forward. --- CHANGELOG.md | 2 ++ lib/Epub/Epub/Section.h | 7 ++++++- src/activities/reader/SearchHighlighter.cpp | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae682d539..8ebddf6567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. - Jumping to a search match while a footnote preview is open now shows the matched page instead of reopening the footnote preview. - Search results now highlight the matched words on the page again; a buffer-allocation guard was skipping the highlighter's setup so no matches were ever marked. +- Search-result highlights no longer mark the wrong words when the searched term also appears on the previous page. +- The reader no longer keeps the chapter's cache file open after highlighting a search result, which could block other reads on hardware that allows only one open file at a time. ## [v1.3.4] - 2026-06-24 diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index a34527a7e6..ca86b876fc 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -85,13 +85,18 @@ class Section { // false on failure, setting failureStatus to IoError for an open/seek/read // failure or CorruptCache for a truncated/malformed header. bool ensureSearchHeader(ScanStatus& failureStatus); - void closeSearchState(); // Rewrite filePath's numeric suffix in place for the current spineIndex, // reusing the buffer (no per-spine string allocation, no std::to_string). void rebuildFilePathForSpine(); public: uint16_t pageCount = 0; + // Close the lazily-opened scan file and invalidate the cached header. scanForward() + // intentionally leaves the file open between chunked scans (resetForSpine() closes + // it when advancing spines); a one-shot caller such as the search highlighter, which + // primes the matcher on the reader's live Section, must call this afterwards so the + // reader does not sit on an open SD handle (only one file may be open at a time on HW). + void closeSearchState(); int currentPage = 0; explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer, diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 4acc1ed647..02a658d1e0 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -66,15 +66,22 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state if (section->currentPage > 0) { // Prime the matcher with the previous page so a match that began there and - // completes on this page still highlights. If that scan fails it leaves the - // matcher mid-feed; reset it so we highlight only matches contained on this - // page rather than feeding indeterminate carried state. + // completes on this page still highlights. Only NoMatch means we fed the + // entire previous page cleanly and the carried partial state is valid at the + // page boundary. A Match means scanForward stopped mid-previous-page (its + // carried KMP state is not the boundary state and would mis-highlight this + // page); CorruptCache/IoError leave the feed indeterminate. Reset in all of + // those cases so we highlight only matches contained on this page. const Section::ScanResult primeResult = section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); - if (primeResult.status == Section::ScanStatus::CorruptCache || - primeResult.status == Section::ScanStatus::IoError) { + if (primeResult.status != Section::ScanStatus::NoMatch) { matcher.reset(); } + // scanForward leaves the section's cache file open on success; the rest of + // this function only reads in-memory buffers, so release the handle now and + // do not leave the reader's live section holding an open SD file while it + // sits on the result page. + section->closeSearchState(); } for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { From ae9686281cbd6c60b6892788aa14c14238a1b28b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 14:54:16 -0700 Subject: [PATCH 63/91] refactor: dedup search/reader helpers and drop dead code Consolidate duplication surfaced while reviewing the in-book search feature; no behavior change: - Promote normalizeRenderMode() into Epub/EpubRenderMode.h so the reader and the search activity fold SETTINGS.epubRenderMode the same way (was duplicated as an inline ternary in three places across two files). - Route EpubReaderActivity through the shared ReaderUtils::clampPercent() and EpubReaderUtils::hasEmSpacePrefix() instead of file-local copies. - Normalize page text in SearchHighlighter via epub::asciiToLower() (already used one line above for isSearchSeparator) instead of an open-coded A-Z fold. - Extract EpubReaderSearchActivity::dropSectionCache() for the cache-repair and give-up paths that previously duplicated the reset/unload/clear sequence. - Remove the unused Section::getCachedPageCount() (no callers). --- lib/Epub/Epub/EpubRenderMode.h | 8 ++++++ lib/Epub/Epub/Section.cpp | 21 --------------- lib/Epub/Epub/Section.h | 3 --- src/activities/reader/EpubReaderActivity.cpp | 26 ++++--------------- .../reader/EpubReaderSearchActivity.cpp | 18 ++++++------- .../reader/EpubReaderSearchActivity.h | 4 +++ src/activities/reader/SearchHighlighter.cpp | 2 +- 7 files changed, 27 insertions(+), 55 deletions(-) diff --git a/lib/Epub/Epub/EpubRenderMode.h b/lib/Epub/Epub/EpubRenderMode.h index 927a7b4108..a031f500c9 100644 --- a/lib/Epub/Epub/EpubRenderMode.h +++ b/lib/Epub/Epub/EpubRenderMode.h @@ -11,3 +11,11 @@ enum class EpubRenderMode : uint8_t { constexpr uint8_t EPUB_RENDER_MODE_COUNT = 3; inline bool isValidEpubRenderMode(const uint8_t mode) { return mode < EPUB_RENDER_MODE_COUNT; } + +// Coerce a raw stored render-mode byte into a valid EpubRenderMode, falling back +// to the default for out-of-range values. Single definition so every reader of +// SETTINGS.epubRenderMode (the reader and the in-book search activity) folds the +// same way and computes the same section-cache suffix. +inline EpubRenderMode normalizeRenderMode(const uint8_t rawMode) { + return isValidEpubRenderMode(rawMode) ? static_cast(rawMode) : EpubRenderMode::CrossInkDefault; +} diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 3d183bc7cd..18e726f2b6 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -644,27 +644,6 @@ void Section::resetForSpine(const int newSpineIndex) { // ensureSearchHeader() and scanForward() are defined in SectionSearch.cpp. -std::optional Section::getCachedPageCount() { - ScopedSectionFile sf(file, filePath); - if (!sf.ok()) { - return std::nullopt; - } - - const uint32_t fileSize = file.size(); - if (fileSize < HEADER_SIZE) { - return std::nullopt; - } - - if (!file.seek(PAGE_COUNT_POS)) { - return std::nullopt; - } - uint16_t count; - if (!serialization::tryReadPod(file, count)) { - return std::nullopt; - } - return count; -} - std::optional Section::getPageForAnchor(const std::string& anchor) { ScopedSectionFile sf(file, filePath); if (!sf.ok()) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index ca86b876fc..1ec530b9e4 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -125,9 +125,6 @@ class Section { SectionBuildOptions buildOptions = {}); std::unique_ptr loadPageFromSectionFile(); - // Get the page count from the section cache file without fully loading it. - std::optional getCachedPageCount(); - // Reuse this Section object for another spine item without another heap // allocation. Intended for sequential, book-wide operations such as search. void resetForSpine(int newSpineIndex); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6380b9c89a..ffdafdceb7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -103,10 +103,7 @@ std::string confirmationHeading(const StrId actionLabelId) { return std::string(tr(STR_CONFIRM)) + ": " + std::string(I18N.get(actionLabelId)); } -EpubRenderMode normalizeRenderMode(const uint8_t rawMode) { - return isValidEpubRenderMode(rawMode) ? static_cast(rawMode) : EpubRenderMode::CrossInkDefault; -} - +// normalizeRenderMode() is shared via Epub/EpubRenderMode.h. uint8_t normalizeRenderModeRaw(const uint8_t rawMode) { return static_cast(normalizeRenderMode(rawMode)); } uint64_t hashFootnotePreviewAnchor(const std::string& anchor) { @@ -190,13 +187,10 @@ void applySafeModeReaderSettings() { SETTINGS.guideReadingEnabled = 0; } -bool hasEmSpacePrefix(const std::string& text) { - return text.size() >= 3 && static_cast(text[0]) == 0xE2 && - static_cast(text[1]) == 0x80 && static_cast(text[2]) == 0x83; +std::string stripEmSpacePrefix(const std::string& text) { + return EpubReaderUtils::hasEmSpacePrefix(text) ? text.substr(3) : text; } -std::string stripEmSpacePrefix(const std::string& text) { return hasEmSpacePrefix(text) ? text.substr(3) : text; } - uint8_t largestBlockPercent(const MemoryBudget::HeapSnapshot& heap) { if (heap.freeHeap == 0) { return 0; @@ -699,16 +693,6 @@ bool releaseReaderSdFontCachesForLowMemory(const GfxRenderer& renderer, const ch return true; } -int clampPercent(int percent) { - if (percent < 0) { - return 0; - } - if (percent > 100) { - return 100; - } - return percent; -} - bool isSnippetWhitespace(const std::string& word) { if (word.empty()) return true; return std::all_of(word.begin(), word.end(), @@ -1927,7 +1911,7 @@ void EpubReaderActivity::loop() { isBookCompleted = stats.isCompleted; bookProgress = getCurrentBookProgressPercent(); } - const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); + const int bookProgressPercent = ReaderUtils::clampPercent(static_cast(bookProgress + 0.5f)); pauseReadingPaceTimer("reader_menu"); startActivityForResult( @@ -2357,7 +2341,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction RenderLock lock(*this); bookProgress = getCurrentBookProgressPercent(); } - const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); + const int initialPercent = ReaderUtils::clampPercent(static_cast(bookProgress + 0.5f)); pauseReadingPaceTimer("percent_selection"); startActivityForResult( std::make_unique(renderer, mappedInput, initialPercent), diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index b31f54e760..f226db62e8 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -62,9 +62,7 @@ EpubReaderSearchActivity::EpubReaderSearchActivity(GfxRenderer& renderer, Mapped : Activity("EpubReaderSearch", renderer, mappedInput), epub(epub), section(this->epub, route.startSpineIndex, renderer, - ReaderUtils::sectionCacheSuffixForRenderMode(isValidEpubRenderMode(SETTINGS.epubRenderMode) - ? static_cast(SETTINGS.epubRenderMode) - : EpubRenderMode::CrossInkDefault)), + ReaderUtils::sectionCacheSuffixForRenderMode(normalizeRenderMode(SETTINGS.epubRenderMode))), route(route), currentSpineIndex(route.startSpineIndex), currentPage(route.startPage), @@ -139,6 +137,12 @@ void EpubReaderSearchActivity::advanceSpine() { matcher.reset(); // spine boundary: don't carry a partial match across chapters } +void EpubReaderSearchActivity::dropSectionCache() { + section.resetForSpine(currentSpineIndex); + sectionLoaded = false; + section.clearCache(); +} + bool EpubReaderSearchActivity::ensureSectionLoaded() { if (sectionLoaded) { return true; @@ -255,9 +259,7 @@ void EpubReaderSearchActivity::scanNextPage() { sectionCacheRepairAttempted = true; matcher = matcherBeforeChunk; - section.resetForSpine(currentSpineIndex); - sectionLoaded = false; - section.clearCache(); + dropSectionCache(); if (!ensureSectionLoaded()) { return; @@ -272,9 +274,7 @@ void EpubReaderSearchActivity::scanNextPage() { if (result.status == Section::ScanStatus::CorruptCache) { // Still corrupt after a rebuild: drop the bad cache and surface the error // rather than entering an unbounded rebuild loop. - section.resetForSpine(currentSpineIndex); - sectionLoaded = false; - section.clearCache(); + dropSectionCache(); setFailure(SearchState::Error); return; } diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h index 01baa9e614..70e0d7e2cf 100644 --- a/src/activities/reader/EpubReaderSearchActivity.h +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -105,6 +105,10 @@ class EpubReaderSearchActivity final : public Activity { bool reachedWrappedStop() const; bool shouldScanWrappedStopContinuation() const; void advanceSpine(); + // Invalidate the current spine's section so the next scan rebuilds it: reset the + // section for this spine, mark it unloaded, and delete the on-disk cache. Used by + // the corrupt-cache repair and give-up paths so both teardown sequences stay in step. + void dropSectionCache(); void scanNextPage(); // Approximate 0-100 fraction of the scan route completed: the byte-weighted // distance travelled since the search began, over the route length (forward to diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 02a658d1e0..56b0316f4e 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -57,7 +57,7 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { return false; } - searchHighlightPageText.push_back((c >= 'A' && c <= 'Z') ? (c + 32) : c); + searchHighlightPageText.push_back(static_cast(epub::asciiToLower(static_cast(c)))); searchHighlightCharToWordIndex.push_back(pageWordIndex); } return true; From 1b5feec737de61171eb0068c0c1f2efc91383a3a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 15:19:28 -0700 Subject: [PATCH 64/91] refactor: highlight search matches from the scan's reported byte span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search highlighter was a second matching pipeline: on every result-page render it recompiled the query, re-normalized the page text through its own parallel logic (which only held for ASCII and was fragile for diacritics/ multibyte), and re-read the previous page from SD to rebuild cross-page KMP carry — all to re-derive a match the scan had already found. Invert the data flow so the producer reports the match location and the highlighter just paints it: - Section::scanForward tracks the byte position within each page's search-text record and, on a match, returns its byte span (matchStartByte/matchEndByte on ScanResult). A match begun on an earlier page reports a span clamped to 0, so the cross-page tail highlights without re-reading the previous page. - ProgressChangeResult carries the span; EpubReaderActivity stores it next to the search-result spine/page and hands it to the highlighter. - EpubReaderUtils::searchByteSpanToWordRange maps the span to visible word indices, mirroring Page::serializeSearchText's byte layout and reusing forEachVisiblePageWord's visibility filter (the one coordinate bridge). - SearchHighlighter becomes stateless: no matcher, no normalization, no scan, no SD read, and no ~12 KB scratch buffers. Behavior: a result now highlights the specific match the scan found rather than every occurrence of the term on the page, matching find-next semantics. --- docs/search-architecture.md | 16 +- lib/Epub/Epub/Section.h | 7 + lib/Epub/Epub/SectionSearch.cpp | 17 +- src/activities/ActivityResult.h | 5 + src/activities/reader/EpubReaderActivity.cpp | 20 +-- src/activities/reader/EpubReaderActivity.h | 5 + .../reader/EpubReaderSearchActivity.cpp | 2 +- src/activities/reader/EpubReaderUtils.h | 68 ++++++++ src/activities/reader/SearchHighlighter.cpp | 149 +++--------------- src/activities/reader/SearchHighlighter.h | 48 ++---- 10 files changed, 151 insertions(+), 186 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index a9eb61b2c5..0a85bcf108 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -74,8 +74,10 @@ The responsibilities are split as follows: - `EpubReaderMenuActivity` exposes the existing translated `Search` command. - `EpubReaderActivity` owns query history and coordinates the keyboard, search activity, reader position, and result popup. -- `SearchHighlighter` encapsulates the transient on-page text highlighting logic - and manages its own reusable memory buffers to avoid rendering-path allocations. +- `SearchHighlighter` encapsulates the transient on-page text highlighting logic. + It is a pure consumer of the byte span the scan reports for the matched page: + it maps that span to the page's words and paints them, without re-running the + matcher, re-normalizing text, or re-reading the cache, so it holds no buffers. - `EpubReaderSearchActivity` is a small state machine whose `scanNextPage()` scan loop scans a bounded chunk of up to 50 pages per main-loop iteration before yielding, and distinguishes `Searching`, `NotFound`, and `Error`. @@ -150,9 +152,9 @@ changes its spine and cache path in place, avoiding a new/delete cycle for each chapter. Before the activity is allocated, the reader releases its current `Section` and deserialized page graph. This avoids keeping the normal reader working set and the search working set live together. Result highlighting is -delegated to `SearchHighlighter`, which pre-allocates its own reusable vectors -during `EpubReaderActivity` initialization to avoid heap fragmentation in the -render loop. +delegated to `SearchHighlighter`, which is stateless: the scan reports the +matched byte span, and the highlighter maps it to words at render time, so it +needs no buffers of its own. The 12,288-byte LUT reservation is not a new steady-state index. Section layout already needs a data-dependent page LUT; reserving 1,024 entries once avoids @@ -321,10 +323,10 @@ prevents automatic sleep while searching. page. - Matches are page-level. Repeating a query skips the rest of the current page, so multiple occurrences on one page are not individually navigable. -- There is no match result list, but the matching page highlights all occurrences of the query. +- There is no match result list. The matching page highlights the specific match the scan found (the one the result navigates to), not every occurrence of the query on that page. - Search match highlighting uses a high-contrast inverted style (solid black background with white/light text) to make matches immediately stand out on the screen. - Highlighting is transient and scoped: it is only rendered on the initial search-match result page. Turning the page or navigating away automatically clears the highlight state so it does not persist on subsequent reads. -- Highlight detection runs at render time by normalizing the current page's visible words (lowercase, hyphens/spaces stripped) and matching them against the normalized query. This guarantees alignment with KMP indexing but adds a minor, one-off CPU and temporary RAM cost during page composition. +- Highlight placement is producer-driven: `Section::scanForward()` reports the match's byte span within the page's search-text record, and `SearchHighlighter` maps that span to the page's words at render time. The match is located once by the scan rather than re-derived by a second matcher, so the highlighter never re-normalizes text or re-reads the cache. A match that began on the previous page reports a span clamped to the page start, so its visible tail still highlights without re-scanning the previous page. - Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Characters outside the supported Latin set must match exactly. - Search text is reconstructed from rendered word tokens with single spaces, so it can differ from the EPUB source in spacing and in words split by layout-time diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 1ec530b9e4..8c25a2c3a3 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -36,6 +36,13 @@ class Section { struct ScanResult { ScanStatus status = ScanStatus::NoMatch; int page = -1; // valid only when status == Match + // Byte span of the match within `page`'s serialized search-text record + // (inclusive), valid only when status == Match. startByte is clamped to 0 + // when the match began on an earlier page, so [matchStartByte, matchEndByte] + // always covers the portion that lies on `page`. SearchHighlighter maps this + // span to the page's words without re-running the matcher. + int matchStartByte = -1; + int matchEndByte = -1; }; private: diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index c06e953966..234a28245b 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -155,6 +155,10 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S continue; } + // Byte offset of the next fed byte within this page's text record content + // (the bytes after the u32 length prefix). Used to report where a completed + // match lies so the highlighter can map it to words without re-scanning. + uint32_t pageBytePos = 0; while (remaining > 0) { const size_t chunkSize = std::min(buffer.size(), remaining); if (file.read(buffer.data(), chunkSize) != chunkSize) { @@ -165,9 +169,18 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S remaining -= chunkSize; for (size_t j = 0; j < chunkSize; ++j) { - if (matcher.feed(buffer[j]) > 0) { - return {ScanStatus::Match, static_cast(startPage + i)}; + const int matchWidth = matcher.feed(buffer[j]); + if (matchWidth > 0) { + // buffer[j] is the match's last byte; the match spans the preceding + // matchWidth bytes. Clamp the start to 0 when the match began on an + // earlier page so the span covers only this page's portion. + const int endByte = static_cast(pageBytePos); + const int startByte = (pageBytePos + 1 >= static_cast(matchWidth)) + ? static_cast(pageBytePos + 1 - static_cast(matchWidth)) + : 0; + return {ScanStatus::Match, static_cast(startPage + i), startByte, endByte}; } + ++pageBytePos; } } } diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 060e880f95..ee998492d4 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -48,6 +48,11 @@ struct PageResult { struct ProgressChangeResult { int spineIndex = 0; int page = 0; + // Byte span of the search match within the landed page's search-text record + // (inclusive), so the reader can highlight the matched words without re-running + // the matcher. -1 when the result does not carry a match span. + int matchStartByte = -1; + int matchEndByte = -1; }; struct SyncResult { diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ffdafdceb7..0067a6a92f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3367,6 +3367,8 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { if (!sameQuery) { lastSearchResultSpine = -1; lastSearchResultPage = -1; + lastSearchMatchStartByte = -1; + lastSearchMatchEndByte = -1; } startActivityForResult(std::move(searchActivity), [this](const ActivityResult& result) { @@ -3383,6 +3385,8 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { cachedChapterTotalPageCount = 0; lastSearchResultSpine = match.spineIndex; lastSearchResultPage = match.page; + lastSearchMatchStartByte = match.matchStartByte; + lastSearchMatchEndByte = match.matchEndByte; showToast(tr(STR_SEARCH_MATCH_FOUND), ReaderUtils::READER_MESSAGE_DURATION_MS); } resumeReadingPaceTimer("search_return"); @@ -4119,13 +4123,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { if (section && (currentSpineIndex != lastSearchResultSpine || section->currentPage != lastSearchResultPage)) { - if (lastSearchResultSpine != -1 || lastSearchResultPage != -1) { - // Left the search-result page: the highlight is no longer drawn, so free - // the highlighter's scratch buffers until the next search. - searchHighlighter.release(); - } + // Left the search-result page: the highlight is no longer active. lastSearchResultSpine = -1; lastSearchResultPage = -1; + lastSearchMatchStartByte = -1; + lastSearchMatchEndByte = -1; } const auto t0 = millis(); @@ -4158,10 +4160,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const auto finalizeBufferComposition = [&]() { drawClippingHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); - const char* activeSearchQuery = - (lastSearchResultSpine != -1 && lastSearchResultPage != -1) ? lastSearchQuery.data() : nullptr; - searchHighlighter.drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft, section.get(), - currentSpineIndex, activeSearchQuery, renderer); + const bool onSearchResultPage = lastSearchResultSpine != -1 && lastSearchResultPage != -1; + searchHighlighter.drawSearchHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft, + onSearchResultPage ? lastSearchMatchStartByte : -1, + onSearchResultPage ? lastSearchMatchEndByte : -1, renderer); drawPublisherPageMarkers(renderer, *page, orientedMarginTop, contentBottom, foregroundBlack); }; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8b7c2ad84d..efc53f40b5 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -132,6 +132,11 @@ class EpubReaderActivity final : public Activity { std::array lastSearchQuery{}; int lastSearchResultSpine = -1; int lastSearchResultPage = -1; + // Byte span of the active search match within (lastSearchResultSpine, + // lastSearchResultPage)'s search-text record, handed up by the search scan so + // the highlighter can paint it without re-matching. -1 when not on a result page. + int lastSearchMatchStartByte = -1; + int lastSearchMatchEndByte = -1; SearchHighlighter searchHighlighter; // Tracks whether this book is currently removed from Recent Books by the // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index f226db62e8..df25196abc 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -280,7 +280,7 @@ void EpubReaderSearchActivity::scanNextPage() { } if (result.status == Section::ScanStatus::Match) { - setResult(ProgressChangeResult{currentSpineIndex, result.page}); + setResult(ProgressChangeResult{currentSpineIndex, result.page, result.matchStartByte, result.matchEndByte}); finish(); return; } diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index bd237c6a02..458abc801a 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -176,6 +176,74 @@ bool forEachVisiblePageWord(const Page& page, Callback&& callback) { return true; } +// Map an inclusive [startByte, endByte] span in a page's serialized search-text +// record (see Page::serializeSearchText) to the inclusive range of visible page +// word indices it covers — the same indices forEachVisiblePageWord and +// drawWordHighlights use. Returns false if the span touches no visible word. +// +// This is the inverse of Page::serializeSearchText: that record is the page's +// words in element/line/block order, each word preceded by a single-byte +// separator except the first word emitted on the page, every word contributing +// its raw word.size() bytes (including any em-space prefix). The walk reproduces +// that byte layout exactly so offsets stay aligned with the matcher's, while +// applying forEachVisiblePageWord's visible-word filter for the returned indices. +// Letting the matcher report byte offsets and mapping them here means the +// highlighter never re-runs search or re-reads the cache to place a match. +inline bool searchByteSpanToWordRange(const Page& page, const uint32_t startByte, const uint32_t endByte, + uint16_t& outFirstWord, uint16_t& outLastWord) { + uint32_t recordPos = 0; // byte offset of the next word in the record + bool emittedAnyWord = false; // mirrors serializeSearchText's page-wide separator flag + uint16_t visibleWordIndex = 0; + bool found = false; + for (const auto& element : page.elements) { + if (element->getTag() != TAG_PageLine) continue; + const auto& line = static_cast(*element); + if (!line.getBlock()) continue; + + const auto& block = *line.getBlock(); + const auto& wordList = block.getWords(); + const auto& xpos = block.getWordXpos(); + const auto& styles = block.getWordStyles(); + // serializeSearchText writes every word in the block; forEachVisiblePageWord + // only indexes those within this min() guard, so cap the visible-index space + // the same way while still counting every word's bytes. + const size_t visibleCount = std::min({wordList.size(), xpos.size(), styles.size()}); + for (size_t i = 0; i < wordList.size(); ++i) { + const std::string& word = wordList[i]; + if (emittedAnyWord) ++recordPos; // WORD_SEPARATOR + emittedAnyWord = true; + const uint32_t wordStart = recordPos; + recordPos += static_cast(word.size()); + const uint32_t wordEnd = recordPos; // exclusive + + bool visible = i < visibleCount; + if (visible) { + const char* vw = word.c_str() + (hasEmSpacePrefix(word) ? 3 : 0); + bool hasVisibleText = false; + for (const char* p = vw; *p != '\0'; ++p) { + if (*p != ' ' && *p != '\t' && *p != '\r' && *p != '\n') { + hasVisibleText = true; + break; + } + } + visible = hasVisibleText; + } + if (!visible) continue; + + // Inclusive match span overlaps the half-open word byte range. + if (word.size() > 0 && wordStart <= endByte && wordEnd > startByte) { + if (!found) { + outFirstWord = visibleWordIndex; + found = true; + } + outLastWord = visibleWordIndex; + } + ++visibleWordIndex; + } + } + return found; +} + // Paint a per-word highlight over every visible word for which `isHighlighted` // returns true. Owns the shared geometry — em-space prefix offset and the // next-word width extension that closes the gap between adjacent highlighted diff --git a/src/activities/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp index 56b0316f4e..9fe103ea5c 100644 --- a/src/activities/reader/SearchHighlighter.cpp +++ b/src/activities/reader/SearchHighlighter.cpp @@ -1,119 +1,37 @@ #include "SearchHighlighter.h" -#include #include -#include -#include #include -#include -#include - #include "EpubReaderUtils.h" #include "ReaderUtils.h" void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, - const int orientedMarginLeft, Section* section, - const int currentSpineIndex, const char* lastSearchQuery, - GfxRenderer& renderer) const { - if (lastSearchQuery == nullptr || lastSearchQuery[0] == '\0' || !section) { + const int orientedMarginLeft, const int matchStartByte, + const int matchEndByte, GfxRenderer& renderer) const { + if (matchStartByte < 0 || matchEndByte < matchStartByte) { return; } - // Recompute the match ranges only when the visible page or query changed. - // While the reader sits on the search-result page (status-bar refreshes, etc.) - // this avoids re-reading the previous page from SD and recompiling the matcher - // on every frame; we just repaint the cached ranges. - const bool cacheHit = searchHighlightComputed && searchHighlightCachedSpine == currentSpineIndex && - section->currentPage == searchHighlightCachedPage && - searchHighlightCachedQuery == lastSearchQuery; - if (!cacheHit) { - searchHighlightComputed = true; - searchHighlightCachedSpine = currentSpineIndex; - searchHighlightCachedPage = section->currentPage; - searchHighlightCachedQuery = lastSearchQuery; - searchHighlightMatchRanges.clear(); - - // 1. Compile the search query once using KMP - SearchMatcher matcher; - if (!matcher.compile(lastSearchQuery)) { - return; - } - - ensureBuffersReserved(); - - // 2. Normalize the page text and map characters to word indices - searchHighlightPageText.clear(); - searchHighlightCharToWordIndex.clear(); - - EpubReaderUtils::forEachVisiblePageWord( - page, [&](const uint16_t pageWordIndex, const PageLine& line, const TextBlock& block, const size_t i) { - const std::string& wordText = block.getWords()[i]; - for (char c : wordText) { - if (epub::isSearchSeparator(static_cast(c))) { - continue; - } - if (searchHighlightPageText.size() >= searchHighlightPageText.capacity() || - searchHighlightCharToWordIndex.size() >= searchHighlightCharToWordIndex.capacity()) { - return false; - } - searchHighlightPageText.push_back(static_cast(epub::asciiToLower(static_cast(c)))); - searchHighlightCharToWordIndex.push_back(pageWordIndex); - } - return true; - }); - - // 3. Find matches of compiledQuery in normalizedPageText incorporating prior page state - if (section->currentPage > 0) { - // Prime the matcher with the previous page so a match that began there and - // completes on this page still highlights. Only NoMatch means we fed the - // entire previous page cleanly and the carried partial state is valid at the - // page boundary. A Match means scanForward stopped mid-previous-page (its - // carried KMP state is not the boundary state and would mis-highlight this - // page); CorruptCache/IoError leave the feed indeterminate. Reset in all of - // those cases so we highlight only matches contained on this page. - const Section::ScanResult primeResult = - section->scanForward(std::max(0, section->currentPage - 1), section->currentPage, matcher); - if (primeResult.status != Section::ScanStatus::NoMatch) { - matcher.reset(); - } - // scanForward leaves the section's cache file open on success; the rest of - // this function only reads in-memory buffers, so release the handle now and - // do not leave the reader's live section holding an open SD file while it - // sits on the result page. - section->closeSearchState(); - } - - for (size_t charIndex = 0; charIndex < searchHighlightPageText.size(); ++charIndex) { - int matchBytes = matcher.feed(searchHighlightPageText[charIndex]); - if (matchBytes > 0) { - size_t startIdx = (charIndex + 1 >= static_cast(matchBytes)) ? (charIndex + 1 - matchBytes) : 0; - size_t endIdx = charIndex; - if (startIdx < searchHighlightCharToWordIndex.size() && endIdx < searchHighlightCharToWordIndex.size()) { - if (searchHighlightMatchRanges.size() < searchHighlightMatchRanges.capacity()) { - searchHighlightMatchRanges.push_back( - {searchHighlightCharToWordIndex[startIdx], searchHighlightCharToWordIndex[endIdx]}); - } - } - } - } - } - - if (searchHighlightMatchRanges.empty()) { + // The search scan already located the match; map its byte span to the page's + // word indices (the inverse of Page::serializeSearchText) rather than + // re-matching the text or re-reading the previous page from SD. + uint16_t firstWord = 0; + uint16_t lastWord = 0; + if (!EpubReaderUtils::searchByteSpanToWordRange(page, static_cast(matchStartByte), + static_cast(matchEndByte), firstWord, lastWord)) { return; } - // 4. Highlight matched words on page using the shared geometry helper, with - // the search style: a solid inverted fill so matches stand out. "Inverted" - // means foreground-on-background swapped relative to body text, so it must - // track the theme: black fill + white text in light mode, white fill + black - // text in dark mode. Hard-coding black/white made the highlight vanish in dark - // mode (black fill on a black page, white text identical to body text). + // Highlight the matched words using the shared geometry helper, with the search + // style: a solid inverted fill so matches stand out. "Inverted" means + // foreground-on-background swapped relative to body text, so it must track the + // theme: black fill + white text in light mode, white fill + black text in dark + // mode. Hard-coding black/white made the highlight vanish in dark mode (black + // fill on a black page, white text identical to body text). const bool foregroundBlack = ReaderUtils::readerForegroundBlack(); - const auto isSearchMatchWord = [this](const uint16_t pageWordIndex) { - return std::any_of( - searchHighlightMatchRanges.begin(), searchHighlightMatchRanges.end(), - [pageWordIndex](const auto& range) { return pageWordIndex >= range.first && pageWordIndex <= range.second; }); + const auto isSearchMatchWord = [firstWord, lastWord](const uint16_t pageWordIndex) { + return pageWordIndex >= firstWord && pageWordIndex <= lastWord; }; EpubReaderUtils::drawWordHighlights(page, renderer, fontId, orientedMarginTop, orientedMarginLeft, isSearchMatchWord, @@ -124,34 +42,3 @@ void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, textStyle); }); } - -void SearchHighlighter::ensureBuffersReserved() const { - constexpr size_t PAGE_TEXT_CAPACITY = 4096; - constexpr size_t MATCH_RANGES_CAPACITY = 128; - // Gate on the char->word vector, not searchHighlightPageText: the latter is a - // std::string whose capacity() is never 0 (small-string optimization keeps ~15 - // bytes inline), so using it as the "already reserved" sentinel would skip the - // reserve on the first call, leaving the vectors at capacity 0 and tripping the - // build loop's capacity guard on the very first character (empty page text -> - // no highlight). The vector's capacity is genuinely 0 until reserved. - if (searchHighlightCharToWordIndex.capacity() >= PAGE_TEXT_CAPACITY) { - return; - } - searchHighlightPageText.reserve(PAGE_TEXT_CAPACITY); - searchHighlightCharToWordIndex.reserve(PAGE_TEXT_CAPACITY); - searchHighlightMatchRanges.reserve(MATCH_RANGES_CAPACITY); -} - -void SearchHighlighter::release() { - searchHighlightPageText.clear(); - searchHighlightPageText.shrink_to_fit(); - searchHighlightCharToWordIndex.clear(); - searchHighlightCharToWordIndex.shrink_to_fit(); - searchHighlightMatchRanges.clear(); - searchHighlightMatchRanges.shrink_to_fit(); - searchHighlightComputed = false; - searchHighlightCachedSpine = -1; - searchHighlightCachedPage = -1; - searchHighlightCachedQuery.clear(); - searchHighlightCachedQuery.shrink_to_fit(); -} diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h index 7a19054d21..992fa9dd03 100644 --- a/src/activities/reader/SearchHighlighter.h +++ b/src/activities/reader/SearchHighlighter.h @@ -1,44 +1,20 @@ #pragma once -#include -#include -#include -#include - class GfxRenderer; class Page; -class Section; +// Draws the in-book search highlight on the page the search landed on. The match +// location is computed once by the search scan (Section::scanForward) and handed +// down as a byte span; this class is a pure consumer that maps that span to the +// page's words and paints them. It does not run the matcher, normalize text, or +// read the cache, so there is a single matching pipeline (the scan's) rather than +// a second one re-derived at render time. class SearchHighlighter { public: - SearchHighlighter() = default; - - void drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, - const int orientedMarginLeft, Section* section, const int currentSpineIndex, - const char* lastSearchQuery, GfxRenderer& renderer) const; - - // Free the scratch buffers (~12 KB). Called when the on-page highlight is no - // longer active so a reader who is not viewing a search result does not hold - // the footprint; drawSearchHighlights re-reserves lazily on the next use. - void release(); - - private: - // Reserve the scratch buffers on first use; no-op once reserved. - void ensureBuffersReserved() const; - - mutable std::string searchHighlightPageText; - mutable std::vector searchHighlightCharToWordIndex; - mutable std::vector> searchHighlightMatchRanges; - - // Memo of the (spine, page, query) the cached match ranges were computed for, - // so repeated renders of the same search-result page (status-bar refreshes, - // etc.) reuse the ranges instead of re-reading the previous page from SD and - // recompiling the matcher each frame. The spine must be part of the key: the - // same page number can recur in a different spine (e.g. "find next" landing on - // the same page index of another chapter), and keying on page+query alone - // would repaint the prior spine's ranges. Invalidated by release(). - mutable bool searchHighlightComputed = false; - mutable int searchHighlightCachedSpine = -1; - mutable int searchHighlightCachedPage = -1; - mutable std::string searchHighlightCachedQuery; + // Highlight the matched words on `page`. [matchStartByte, matchEndByte] is the + // inclusive byte span of the match within the page's serialized search-text + // record (as reported by Section::scanForward). A negative or inverted span + // means "nothing to highlight" and draws nothing. + void drawSearchHighlights(const Page& page, int fontId, int orientedMarginTop, int orientedMarginLeft, + int matchStartByte, int matchEndByte, GfxRenderer& renderer) const; }; From 7c6e6e279216ae32152a69015bd51c212ae96104 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 17:50:18 -0700 Subject: [PATCH 65/91] fix: respect spaces in book search while keeping hyphenation fuzzy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-book search stripped both spaces and hyphens, so a query could match across a word boundary it did not contain — starting in the middle of one word and ending in the middle of the next (e.g. "heran" matched "the rang"). Treat the two separators differently in SearchMatcher: - Hyphens stay fuzzy (dropped on both the query and text sides) so a hyphenated word still matches its unhyphenated query, including hard hyphens and the line-break hyphen the layout stores as "- ". - Spaces become significant matched characters, so a query without a space can no longer cross a word boundary. The one exception is a space immediately following a hyphen — the separator line-break hyphenation leaves between the halves — which is dropped so they rejoin. Runs of spaces collapse and leading/trailing query spaces are trimmed to line up with the record. serializeSearchText writes no separator between pages, so scanForward now feeds an explicit word boundary before each page's content. This keeps page boundaries consistent with in-page word boundaries and lets a page-final line-break hyphen rejoin its continuation; the injected byte is not counted in the reported match offsets. Drops the now-unused epub::isSearchSeparator helper. --- CHANGELOG.md | 4 ++ docs/search-architecture.md | 102 +++++++++++++++++--------------- lib/Epub/Epub/AsciiCase.h | 5 -- lib/Epub/Epub/SearchMatcher.cpp | 49 ++++++++++++++- lib/Epub/Epub/SearchMatcher.h | 13 +++- lib/Epub/Epub/SectionSearch.cpp | 10 ++++ 6 files changed, 128 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebddf6567..cd198c3e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- In-book search now respects spaces: a search term no longer matches across a word boundary it does not contain (so it can't start in the middle of one word and end in the middle of the next), while still matching words broken by hyphenation, including across a line or page break. + ### Fixed - Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 0a85bcf108..a5a8316a76 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -183,26 +183,27 @@ typographic ligatures. This is implemented as a sequence of packed logic gates i flash, requiring zero RAM overhead. Rendered EPUB words are already NFC-composed by the layout pipeline. General full-Unicode normalization is not performed. -ASCII spaces and hyphens are treated as insignificant on both sides: -`normalizeSearchQuery()` drops them from the query (and the KMP prefix table is -built over that normalized form), and the page scan skips the same bytes in the -record. This lets a query match across the artifacts the rendered text -introduces — most importantly a word the layout split across a line break, -which is stored as `"-"` plus a space plus `""` — and makes spacing -differences between the query and the rendered tokens irrelevant. The -consequence is that matching is space- and hyphen-agnostic: `"the cat"`, -`"thecat"`, and `"the-cat"` are equivalent, which can occasionally match across -an unrelated word boundary. This is a deliberate extension of the matcher's -existing substring behavior (a query already matches inside a longer word) and -favors finding a half-remembered passage — e.g. a location last read on another -device or in print — over exact-span precision. +Hyphens are insignificant on both sides, but spaces are significant. Hyphens are +dropped from the query (the KMP prefix table is built over that normalized form) +and skipped in the record, so a hyphenated word matches its unhyphenated query — +both a hard hyphen (`"mother-in-law"` matches `"motherinlaw"`) and a layout +line-break hyphen, which is stored as `"-"` plus a space plus `""`. +Spaces, by contrast, are matched: a query without a space cannot run two words +together, so it can no longer start in the middle of one word and end in the +middle of the next (`"heran"` does not match `"the rang"`). The one exception is +a space immediately following a hyphen — the separator a line-break hyphenation +leaves between the two halves — which is dropped so the halves rejoin +(`"international"` matches the stored `"inter- national"`). Runs of spaces collapse +and leading/trailing spaces are trimmed so query spacing lines up with the +single-space record. A query may still match a substring inside a longer word +(`"cat"` matches `"category"`); that within-word substring behavior is unchanged. Codepoints with no ASCII or Latin folding (CJK, Cyrillic, Greek, unmapped -symbols, etc.) normalize to nothing and are dropped on **both** sides, exactly -like spaces and hyphens. So `"a你b"` is treated as `"ab"` on the query side and -the page side alike, and a search for `"ab"` will match it. This is the same -fuzzy class as the space/hyphen bridging above, not a separate behavior, and it -is intentional rather than a missing boundary check. The needle is an ASCII-only +symbols, etc.) normalize to nothing and are dropped on **both** sides, like +hyphens. So `"a你b"` is treated as `"ab"` on the query side and the page side +alike, and a search for `"ab"` will match it. This is the same fuzzy class as the +hyphen bridging above, not a separate behavior, and it is intentional rather than +a missing boundary check. The needle is an ASCII-only `uint8_t` array, so an unsupported codepoint can never appear *in* a pattern; treating it as a hard boundary instead of dropping it would not make `"a你b"` matchable — it would only stop the text `"a你b"` from matching its own exact @@ -213,14 +214,19 @@ The matcher's KMP partial-match length is carried across consecutive pages of the same spine, so a query split across a *page* boundary still matches — for example a word the layout hyphenated at the foot of one page (`"…inter-"`) and continued at the top of the next (`"national…"`), or any phrase that straddles -the break. The carried state is reset at every reading-order discontinuity (the -scan's first page, a spine/chapter change, the single wrap, and any image-only -page with an empty text record), so it never bridges non-contiguous text. A -cross-page match is reported on the page where it *completes* (the second page), -which is where the reader opens. +the break. Because the record stores no separator between pages, the scan feeds +an explicit word-boundary space before each page's content; this makes a page +boundary behave like an in-page word boundary (a spaceless query cannot run two +pages' words together) while still letting a page-final line-break hyphen rejoin +its continuation (the space after the hyphen is dropped). The injected space is +not part of the record, so it is not counted in the reported match offsets. The +carried state is reset at every reading-order discontinuity (a spine/chapter +change, the single wrap, and any image-only page with an empty text record), so +it never bridges non-contiguous text. A cross-page match is reported on the page +where it *completes* (the second page), which is where the reader opens. The return type is `Section::ScanResult`, a `Section::ScanStatus` plus a `page` -index (valid only on `Match`): +index and the match's byte span (all valid only on `Match`): - `ScanStatus::Match`: a match was found; `page` holds the page index. - `ScanStatus::NoMatch`: the requested range was scanned (or was empty) with no @@ -330,12 +336,13 @@ prevents automatic sleep while searching. - Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Characters outside the supported Latin set must match exactly. - Search text is reconstructed from rendered word tokens with single spaces, so it can differ from the EPUB source in spacing and in words split by layout-time - hyphenation. Matching ignores ASCII spaces and hyphens and carries match state - across adjacent same-spine pages to absorb these — including hyphenation and - phrases split across a page boundary (see Matching algorithm). Other - punctuation-glyph differences (curly vs straight quotes, em dash, the ellipsis - character vs three dots) are not normalized and can still cause a miss, and a - match split across a chapter (spine) boundary is not joined. + hyphenation. Matching ignores hyphens but respects spaces, and carries match + state across adjacent same-spine pages, so it absorbs hyphenation (hard and + line-break, including across a page boundary) while still treating spaces as + word boundaries (see Matching algorithm). Other punctuation-glyph differences + (curly vs straight quotes, em dash, the ellipsis character vs three dots) are + not normalized and can still cause a miss, and a match split across a chapter + (spine) boundary is not joined. - Search results depend on the current layout settings. Font, viewport, orientation, margins, paragraph settings, hyphenation, embedded CSS, image mode, or Focus Reading changes can invalidate and rebuild section caches. @@ -398,29 +405,30 @@ heap alone is insufficient to detect fragmentation. across page boundaries with page attribution. This targets SD seek latency, the likely dominant cost, more directly than any change to the matching algorithm. -- Store a source-faithful (de-hyphenated) search text. Matching already ignores - ASCII spaces and hyphens and carries state across adjacent same-spine pages - (see Matching algorithm), which absorbs layout-time hyphenation and spacing - differences — including across page boundaries — at no extra storage cost. The - remaining gap is *exact-spacing* search: because spaces and hyphens are - insignificant, the matcher cannot distinguish `"the cat"` from `"thecat"`, and - a query can occasionally match across an unrelated word boundary. Closing that - would require the record to store the actual source token stream with correct - join/no-join boundaries instead of the rendered tokens. The cost is the reason - this is deferred: +- Store a source-faithful (de-hyphenated) search text. Matching now respects + spaces (a spaceless query cannot cross a word boundary) while still ignoring + hyphens and carrying state across adjacent same-spine pages, so layout-time + hyphenation — hard, line-break, and across page boundaries — is absorbed without + the old fuzzy whole-stream straddle (see Matching algorithm). Two residual gaps + remain, both minor: the line-break-hyphen rejoin is a heuristic (a space + immediately after a hyphen is treated as a soft line break), so a source hyphen + at the very end of a word followed by a space would also be rejoined; and a + multi-word query can still match mid-word at its own ends if its internal space + aligns with the text. Closing these fully would require the record to store the + actual source token stream with correct join/no-join boundaries instead of the + rendered tokens. The cost is the reason it is deferred: - The metadata needed (`ParsedText::wordContinues` / `wordNoSpaceBefore`, and - where `hyphenateWordAtIndex()` split a word) exists during layout but is - discarded at the `TextBlock` boundary — `Page::serializeSearchText()` only - sees the rendered tokens, with a visible `-` already pushed onto a - line-broken fragment, so it cannot tell `"well-"`+`"known"` (rejoin as - `well-known`) from `"inter-"`+`"national"` (rejoin as `international`). + where `hyphenateWordAtIndex()` split a word, flagged `WORD_FLAG_INSERTED_HYPHEN`) + exists during layout but is discarded at the `TextBlock` boundary — + `Page::serializeSearchText()` only sees the rendered tokens, with a visible + `-` already pushed onto a line-broken fragment. - Fixing it means threading per-token join information from the line breaker to the search-text writer — either by adding a per-word "joins previous without space" flag to `TextBlock` (which **bumps the section cache version and rebuilds all caches**) or by surfacing per-page continuation flags through the page-emit callback. Either touches the layout pipeline, the most performance- and stability-sensitive code in the project. - Defer until exact-spacing search is actually wanted; the current normalized - matching is the better trade for finding a half-remembered passage. + Defer until the residual gaps actually bite; the current matching already + respects word boundaries while staying tolerant of layout hyphenation. - Normalize punctuation for cross-medium search. Even with space/hyphen folding, curly vs straight quotes and em dash vs hyphen can still cause a miss. diff --git a/lib/Epub/Epub/AsciiCase.h b/lib/Epub/Epub/AsciiCase.h index 45e736838b..e0c9ab0a85 100644 --- a/lib/Epub/Epub/AsciiCase.h +++ b/lib/Epub/Epub/AsciiCase.h @@ -12,9 +12,4 @@ constexpr uint8_t asciiToLower(const uint8_t value) { return (value >= 'A' && value <= 'Z') ? static_cast(value + ('a' - 'A')) : value; } -// Bytes treated as insignificant by in-book search on both the query and the -// page/record sides: ASCII space and hyphen. Single definition so the query -// normalizer, the streaming matcher, and the result highlighter agree. -constexpr bool isSearchSeparator(const uint8_t value) { return value == ' ' || value == '-'; } - } // namespace epub diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index ee5ffe0d69..98b6cb37ad 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -51,6 +51,7 @@ size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::ar size_t len = 0; uint32_t utf8State = 0; uint32_t utf8Codepoint = 0; + bool prevWasHyphen = false; for (const char ch : query) { const uint8_t c = static_cast(ch); @@ -90,15 +91,42 @@ size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::ar uint8_t b = (norm >> shift) & 0xFF; if (b == 0) break; - if (epub::isSearchSeparator(b)) { + if (b == '-') { + // Hyphens are fuzzy: dropped from the query (and the text) so a + // hyphenated word, including one split across a line, still matches. + prevWasHyphen = true; continue; } + if (b == ' ') { + // A space right after a hyphen is the word separator that line-break + // hyphenation leaves between the two halves; drop it so they rejoin. + // Otherwise a space is a significant word boundary: collapse runs and + // drop a leading space so the pattern lines up with the text record. + if (prevWasHyphen) { + prevWasHyphen = false; + continue; + } + if (len == 0 || out[len - 1] == ' ') { + continue; + } + if (len < out.size()) { + out[len++] = ' '; + } + continue; + } + + prevWasHyphen = false; if (len >= out.size()) { break; } out[len++] = b; } } + // Drop a trailing significant space so the pattern is not forced to end on a + // word boundary that the text record may not provide. + if (len > 0 && out[len - 1] == ' ') { + --len; + } return len; } @@ -185,13 +213,30 @@ int SearchMatcher::feed(uint8_t c) { uint8_t b = (norm >> shift) & 0xFF; if (b == 0) break; - if (epub::isSearchSeparator(b)) { + // Classify the byte as fuzzy (dropped) or a significant character. Hyphens + // are always dropped. A space is dropped only when it directly follows a + // hyphen (the separator a line-break hyphenation leaves between the two + // halves) or another space (run collapse); every other space is significant + // and must be matched, so a query without a space cannot cross a word + // boundary. Dropped bytes still extend the match span via pendingSeparatorBytes. + bool dropAsSeparator = false; + if (b == '-') { + prevWasHyphen = true; + dropAsSeparator = true; + } else if (b == ' ' && (prevWasHyphen || lastEmittedWasSpace)) { + prevWasHyphen = false; + dropAsSeparator = true; + } + if (dropAsSeparator) { if (matched > 0) { pendingSeparatorBytes += currentCodepointWidth; } continue; } + prevWasHyphen = false; + lastEmittedWasSpace = (b == ' '); + const uint8_t value = b; while (matched > 0 && value != pattern[matched]) { diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index f5465b1b80..7fc5cb2d8a 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -20,7 +20,10 @@ class SearchMatcher { bool compile(std::string_view query); // Feed one byte into the matcher. Decodes UTF-8 and maps Latin diacritics. - // Separator characters (spaces and hyphens) are ignored and skipped. + // Hyphens are ignored (fuzzy), as is the word-separator space a line-break + // hyphenation leaves between a split word's halves; every other space is a + // significant character that must be matched, so a query cannot cross a word + // boundary it does not itself contain. // Returns the total raw byte width of the full match if completed, or 0 otherwise. int feed(uint8_t c); @@ -32,6 +35,8 @@ class SearchMatcher { pendingSeparatorBytes = 0; widthBufferHead = 0; currentCodepointId = 0; + prevWasHyphen = false; + lastEmittedWasSpace = false; } bool hasPartialMatch() const { return matched > 0; } @@ -53,6 +58,12 @@ class SearchMatcher { uint16_t pendingSeparatorBytes = 0; uint8_t widthBufferHead = 0; uint32_t currentCodepointId = 0; + // True when the previous codepoint was a dropped hyphen, so the next space is + // treated as a line-break join and dropped. True when the last emitted byte + // was a space, so runs of spaces collapse to one. Both span the byte stream + // fed so far and are cleared by reset(). + bool prevWasHyphen = false; + bool lastEmittedWasSpace = false; static size_t normalizeSearchQuery(std::string_view query, std::array& out); }; diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 234a28245b..ca29349693 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -155,6 +155,16 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S continue; } + // serializeSearchText writes no separator between pages, so feed an explicit + // word boundary before each page's content. This keeps page boundaries + // consistent with in-page word boundaries (a query without a space cannot run + // two words together across a page break) and lets a page-final line-break + // hyphen rejoin with the continuation word (the matcher drops a space right + // after a hyphen). The injected byte is not part of the record, so it is not + // counted in pageBytePos; it can never complete a match because a compiled + // query never ends in a space. + matcher.feed(' '); + // Byte offset of the next fed byte within this page's text record content // (the bytes after the u32 length prefix). Used to report where a completed // match lies so the highlighter can map it to words without re-scanning. From 87955fcd82103615bddf63acd5fb6ae190814642 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 18:23:11 -0700 Subject: [PATCH 66/91] docs: correct search-architecture figures --- docs/search-architecture.md | 88 ++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index a5a8316a76..f4ed92f8f9 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -47,8 +47,8 @@ The user-visible behavior is intentionally narrow: of the scan has completed, measured from where the search began (so it rises from 0% to 100% over the whole scan even when the search starts mid-book) -The result is page-granular. The reader opens the matching page and shows a -short confirmation popup; individual glyphs are not highlighted. +The result is page-granular. The reader opens the matching page, shows a short +confirmation popup, and highlights the matched words on the page. ## Component flow @@ -65,7 +65,7 @@ flowchart TD H --> I{"KMP match?"} I -->|"No"| J["Advance one page; then spine; wrap once"] J --> F - I -->|"Yes"| K["Return ProgressChangeResult (spine, page)"] + I -->|"Yes"| K["Return ProgressChangeResult (spine, page, match byte span)"] K --> L["Reader reloads that page from SD cache"] ``` @@ -109,23 +109,25 @@ u32 searchTextLength u8 searchText[searchTextLength] ``` -The page LUT stores four offsets/indices per page: +The on-disk page LUT stores two offsets per page — an 8-byte stride +(`PAGE_LUT_ENTRY_SIZE`): ```text u32 pageOffset u32 searchTextOffset -u16 paragraphIndex -u16 listItemIndex ``` `pageOffset` preserves normal rendering behavior. `searchTextOffset` lets the matcher seek directly to the bounded text record without decoding page -elements, images, footnotes, styles, or word-position vectors. +elements, images, footnotes, styles, or word-position vectors. (The paragraph +and list-item indices the reader uses for position restore are written to +separate LUTs, not this one.) The text record contains the rendered page's words in page-element order, joined by single ASCII spaces. Images and styling metadata are excluded. The SD cost is therefore approximately one additional copy of rendered UTF-8 text, -plus 8 bytes per page for the search text length and its LUT offset. +plus 8 bytes per page: the record's 4-byte length prefix and the 4-byte +`searchTextOffset` added to each page LUT entry. Additionally, version 42 stores all layout settings (including fonts, line compression, extra paragraph spacing, forced indents, paragraph alignment, bionic reading, guide reading, and the selected `EpubRenderMode`) in the section header. This ensures that the search-time layout accurately matches the reader's layout settings, preventing pagination misalignment. Version 42 invalidates older section caches automatically; they are rebuilt on demand using the normal cache-busting path. The book's EPUB source is never modified. @@ -135,10 +137,10 @@ The steady page-scan path has fixed memory use: | Item | Storage | Size | Lifetime | | --- | --- | ---: | --- | -| Saved query | Inline reader/activity arrays | 65 bytes each | Reader/search activity | -| Compiled query (normalized pattern + KMP table) | Inline search activity arrays | 132 bytes | Search activity (built once) | +| Saved query | Inline reader and activity arrays | 65 bytes each | Reader / search activity | | SD read buffer | Stack | 64 bytes | One page scan | -| Search activity object | Heap, nothrow | 444 bytes in the target build | Search activity | +| Search matcher (KMP pattern + prefix table, plus per-codepoint match-width tracking) | Inline in the activity | 544 bytes | Search activity | +| Search activity object (includes two matchers: the live one and a corrupt-cache rollback snapshot) | Heap, nothrow | 1,544 bytes | Search activity | | Page LUT reservation | Heap | 12,288 bytes | Uncached section layout only | The display's 52,272-byte framebuffer is not a search allocation. The shared @@ -161,10 +163,11 @@ already needs a data-dependent page LUT; reserving 1,024 entries once avoids repeated allocate-copy-free growth. Chapters larger than that remain supported and may grow the vector. -At implementation time, the measured `default` build had unchanged static RAM -usage at 101,220 bytes. Flash usage increased by 8,174 bytes, from 5,225,869 to -5,234,043 bytes, for the search behavior, cache handling, UI, and translated -fallback strings. These are build snapshots rather than permanent budgets; +The search feature adds no static RAM: a `default` build measured 102,516 bytes +of static RAM both with the feature and on its pre-search base (`main`). Flash +grew by about 13,750 bytes in that same comparison (6,344,661 to 6,358,411 +bytes) for the search behavior, cache handling, on-page highlighting, UI, and +translated strings. These are build snapshots rather than permanent budgets; remeasure them when the implementation or toolchain changes. ## Matching algorithm @@ -333,7 +336,7 @@ prevents automatic sleep while searching. - Search match highlighting uses a high-contrast inverted style (solid black background with white/light text) to make matches immediately stand out on the screen. - Highlighting is transient and scoped: it is only rendered on the initial search-match result page. Turning the page or navigating away automatically clears the highlight state so it does not persist on subsequent reads. - Highlight placement is producer-driven: `Section::scanForward()` reports the match's byte span within the page's search-text record, and `SearchHighlighter` maps that span to the page's words at render time. The match is located once by the scan rather than re-derived by a second matcher, so the highlighter never re-normalizes text or re-reads the cache. A match that began on the previous page reports a span clamped to the page start, so its visible tail still highlights without re-scanning the previous page. -- Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Characters outside the supported Latin set must match exactly. +- Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Codepoints outside the supported Latin set (CJK, Cyrillic, Greek, unmapped symbols) normalize to nothing and are ignored on both sides during matching rather than requiring an exact match (see Matching algorithm), so they neither help nor block a match. - Search text is reconstructed from rendered word tokens with single spaces, so it can differ from the EPUB source in spacing and in words split by layout-time hyphenation. Matching ignores hyphens but respects spaces, and carries match @@ -396,39 +399,26 @@ heap alone is insufficient to detect fragmentation. - Make section layout cooperatively cancellable if cold-search latency becomes a usability problem. -- Reduce per-page seeks during a warm scan. The invariant header state (file - size and page-LUT offset) is already cached once per section, so the per-page - cost is now two seek+read pairs (the page's LUT entry and its text record). - Because the text records and the LUT are written physically contiguously at - layout time, a forward whole-section scan could instead read the LUT once into - a small buffer and stream the text records sequentially, running the matcher - across page boundaries with page attribution. This targets SD seek latency, - the likely dominant cost, more directly than any change to the matching - algorithm. +- Reduce per-page seeks during a warm scan. The page LUT for a chunk is already + read once into a reused buffer, and the invariant header state (file size and + page-LUT offset) is cached per section, so the remaining per-page cost is a + seek to that page's text record plus the record read. The records are + interleaved with each page's rendering data rather than stored contiguously, so + the scan must seek over the page graph to reach each one. Writing all per-page + search-text records into a single contiguous region (separate from the page + graphs) would let a whole-section scan stream them without a per-page seek, + targeting SD seek latency — the likely dominant cost — more directly than any + change to the matching algorithm. It would bump the cache version. - Store a source-faithful (de-hyphenated) search text. Matching now respects - spaces (a spaceless query cannot cross a word boundary) while still ignoring - hyphens and carrying state across adjacent same-spine pages, so layout-time - hyphenation — hard, line-break, and across page boundaries — is absorbed without - the old fuzzy whole-stream straddle (see Matching algorithm). Two residual gaps - remain, both minor: the line-break-hyphen rejoin is a heuristic (a space - immediately after a hyphen is treated as a soft line break), so a source hyphen - at the very end of a word followed by a space would also be rejoined; and a - multi-word query can still match mid-word at its own ends if its internal space - aligns with the text. Closing these fully would require the record to store the - actual source token stream with correct join/no-join boundaries instead of the - rendered tokens. The cost is the reason it is deferred: - - The metadata needed (`ParsedText::wordContinues` / `wordNoSpaceBefore`, and - where `hyphenateWordAtIndex()` split a word, flagged `WORD_FLAG_INSERTED_HYPHEN`) - exists during layout but is discarded at the `TextBlock` boundary — - `Page::serializeSearchText()` only sees the rendered tokens, with a visible - `-` already pushed onto a line-broken fragment. - - Fixing it means threading per-token join information from the line breaker - to the search-text writer — either by adding a per-word "joins previous - without space" flag to `TextBlock` (which **bumps the section cache version - and rebuilds all caches**) or by surfacing per-page continuation flags - through the page-emit callback. Either touches the layout pipeline, the most - performance- and stability-sensitive code in the project. - Defer until the residual gaps actually bite; the current matching already - respects word boundaries while staying tolerant of layout hyphenation. + spaces and only fuzzes hyphens (see Matching algorithm), so the common + cross-word straddle is gone. Two minor gaps remain: the line-break-hyphen + rejoin is a heuristic (any space directly after a hyphen is dropped), and a + multi-word query can still match mid-word at its own ends. Closing them fully + means storing the source token stream with correct join/no-join boundaries + instead of the rendered tokens. The join metadata (`WORD_FLAG_INSERTED_HYPHEN`, + `ParsedText` continuation flags) exists during layout but is dropped before + `Page::serializeSearchText()`, which sees only rendered tokens with the + line-break `-` already appended. Threading it through touches the layout + pipeline and bumps the cache version, so defer until the residual gaps bite. - Normalize punctuation for cross-medium search. Even with space/hyphen folding, curly vs straight quotes and em dash vs hyphen can still cause a miss. From 984f8c58e9578f568198d7c61629df2a075e544a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 18:32:47 -0700 Subject: [PATCH 67/91] fix: carry dropped non-Latin codepoint width into search match span --- lib/Epub/Epub/SearchMatcher.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index 98b6cb37ad..b7343d542c 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -198,6 +198,15 @@ int SearchMatcher::feed(uint8_t c) { uint32_t norm = stripLatinDiacritics(utf8Codepoint); if (norm == 0) { + // A codepoint outside the supported fold set (CJK, Cyrillic, etc.) is + // dropped before matching, exactly like a fuzzy hyphen/space. Carry its raw + // UTF-8 width so a match that straddles it still spans the full text, e.g. + // "a你b" matching "ab" highlights all five bytes. Reset the byte counter so + // the next codepoint's width starts clean. + if (matched > 0) { + pendingSeparatorBytes += utf8BytesConsumed; + } + utf8BytesConsumed = 0; return 0; } From 15c10c04f813088ee134d34f50f0f51fc4e05b9b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 18:33:00 -0700 Subject: [PATCH 68/91] fix: invalidate cached search highlight span on section re-layout --- src/activities/reader/EpubReaderActivity.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 0067a6a92f..b8a0482fea 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -4106,6 +4106,16 @@ void EpubReaderActivity::cacheCurrentSectionPosition() { if (activeFootnotePreview) { return; } + // This runs only before a live re-layout (font, margin, orientation, or other + // settings change) that rebuilds the section in place. The match byte span is + // tied to the old layout's per-page text record, so it is meaningless once the + // page is re-laid-out even if the page number lands on the same value. + // Invalidate it here so a stale highlight can never be reused. Leaving the + // search-result page is handled separately by renderContents(). + lastSearchResultSpine = -1; + lastSearchResultPage = -1; + lastSearchMatchStartByte = -1; + lastSearchMatchEndByte = -1; cachedSpineIndex = currentSpineIndex; cachedChapterPageNumber = section->currentPage; cachedChapterTotalPageCount = section->pageCount; From 2f5f09b8afdfae807b837dff8df640fa3b73f9ed Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 18:35:22 -0700 Subject: [PATCH 69/91] fix: treat case-only query differences as the same book search --- src/activities/reader/EpubReaderActivity.cpp | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b8a0482fea..92133db25c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,6 +1,7 @@ #include "EpubReaderActivity.h" #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include "../settings/KOReaderSettingsActivity.h" #include "BookStatsActivity.h" @@ -58,6 +60,23 @@ #include "util/ScreenshotUtil.h" namespace { +// True when two queries differ only by ASCII letter case, matching how the +// search matcher folds A-Z. Keeps relaunching "Foo" after "foo" a "same query" +// so find-next continues from the last result instead of restarting the scan. +// Non-ASCII bytes are compared verbatim (the matcher's deeper folding is not +// re-applied here; case is the only difference that matters for this gate). +bool searchQueriesEquivalent(const std::string_view a, const std::string_view b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); ++i) { + if (epub::asciiToLower(static_cast(a[i])) != epub::asciiToLower(static_cast(b[i]))) { + return false; + } + } + return true; +} + // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long longPressMenuMs = 600; constexpr uint16_t DEFAULT_AUTO_PAGE_TURN_INTERVAL_S = 30; @@ -3323,7 +3342,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { // pending page remap for realSpine. const bool realSectionLoaded = section != nullptr && !previewingFootnote; - const bool sameQuery = strcmp(lastSearchQuery.data(), query.c_str()) == 0; + const bool sameQuery = searchQueriesEquivalent(lastSearchQuery.data(), query); // Resolve the start spine/page and the fresh-vs-"find next" decision in one // pure, host-testable place rather than inline here. The route owns the // start/stop relationship (a wrap stops before re-examining the originating From 90399c109a05a4abbefa18bbb85704a09a48f7d9 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 18:02:11 -0700 Subject: [PATCH 70/91] docs: collapse unreleased search changelog into one feature entry --- CHANGELOG.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd198c3e61..f3ff072674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,9 @@ ## [Unreleased] -### Changed - -- In-book search now respects spaces: a search term no longer matches across a word boundary it does not contain (so it can't start in the middle of one word and end in the middle of the next), while still matching words broken by hyphenation, including across a line or page break. - -### Fixed +### Added -- Search-result highlights are now readable in dark mode; previously the inverted highlight was drawn black-on-black and disappeared. -- Search-result highlights no longer briefly paint the wrong words when jumping to a match that lands on the same page number in a different chapter. -- Jumping to a search match while a footnote preview is open now shows the matched page instead of reopening the footnote preview. -- Search results now highlight the matched words on the page again; a buffer-allocation guard was skipping the highlighter's setup so no matches were ever marked. -- Search-result highlights no longer mark the wrong words when the searched term also appears on the previous page. -- The reader no longer keeps the chapter's cache file open after highlighting a search result, which could block other reads on hardware that allows only one open file at a time. +- In-book search: find a word or phrase within the current EPUB and jump to a match, with the matched words highlighted on the page. Matching respects word boundaries while still finding words broken by hyphenation across a line or page. ## [v1.3.4] - 2026-06-24 From 97bea7de28b73268683c1d394211a10dced32545 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 19:25:50 -0700 Subject: [PATCH 71/91] fix: gate repeat search on full query normalization, not ASCII case The same-query check only folded ASCII case, so queries the matcher treats as identical (Latin diacritics, ligatures, hyphen/space fuzzing) were judged different and find-next restarted the scan instead of continuing. Expose SearchMatcher::queriesEquivalent() which compares queries by the same normalizeSearchQuery() path compile() uses, and call it from the reader so the gate can no longer drift from match behavior. --- lib/Epub/Epub/SearchMatcher.cpp | 8 ++++++++ lib/Epub/Epub/SearchMatcher.h | 7 +++++++ src/activities/reader/EpubReaderActivity.cpp | 20 +------------------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index b7343d542c..f33831888a 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -47,6 +47,14 @@ bool SearchMatcher::isValidSearchQuery(const std::string_view query) { return normalizeSearchQuery(query, dummy) > 0; } +bool SearchMatcher::queriesEquivalent(const std::string_view a, const std::string_view b) { + std::array normA{}; + std::array normB{}; + const size_t lenA = normalizeSearchQuery(a, normA); + const size_t lenB = normalizeSearchQuery(b, normB); + return lenA == lenB && std::equal(normA.begin(), normA.begin() + lenA, normB.begin()); +} + size_t SearchMatcher::normalizeSearchQuery(const std::string_view query, std::array& out) { size_t len = 0; uint32_t utf8State = 0; diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index 7fc5cb2d8a..5c699ecbb4 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -13,6 +13,13 @@ class SearchMatcher { // before launching a search. static bool isValidSearchQuery(std::string_view query); + // True when two queries normalize to the same byte sequence under the exact + // folding compile() applies (case, Latin diacritics, hyphen/space fuzzing). + // Lets a caller decide whether a relaunched search is "the same query" — so + // find-next continues from the last result instead of restarting the scan — + // using the matcher's full normalization rather than a partial ASCII compare. + static bool queriesEquivalent(std::string_view a, std::string_view b); + // Compile a query once for a book-wide search: normalize it and build the KMP // failure table over the result, so every page scan reuses one consistent // pattern + table. Returns false for an empty/oversized query or one that diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 92133db25c..c44b0f496a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,7 +1,6 @@ #include "EpubReaderActivity.h" #include -#include #include #include #include @@ -60,23 +59,6 @@ #include "util/ScreenshotUtil.h" namespace { -// True when two queries differ only by ASCII letter case, matching how the -// search matcher folds A-Z. Keeps relaunching "Foo" after "foo" a "same query" -// so find-next continues from the last result instead of restarting the scan. -// Non-ASCII bytes are compared verbatim (the matcher's deeper folding is not -// re-applied here; case is the only difference that matters for this gate). -bool searchQueriesEquivalent(const std::string_view a, const std::string_view b) { - if (a.size() != b.size()) { - return false; - } - for (size_t i = 0; i < a.size(); ++i) { - if (epub::asciiToLower(static_cast(a[i])) != epub::asciiToLower(static_cast(b[i]))) { - return false; - } - } - return true; -} - // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long longPressMenuMs = 600; constexpr uint16_t DEFAULT_AUTO_PAGE_TURN_INTERVAL_S = 30; @@ -3342,7 +3324,7 @@ void EpubReaderActivity::launchBookSearch(const std::string& query) { // pending page remap for realSpine. const bool realSectionLoaded = section != nullptr && !previewingFootnote; - const bool sameQuery = searchQueriesEquivalent(lastSearchQuery.data(), query); + const bool sameQuery = SearchMatcher::queriesEquivalent(lastSearchQuery.data(), query); // Resolve the start spine/page and the fresh-vs-"find next" decision in one // pure, host-testable place rather than inline here. The route owns the // start/stop relationship (a wrap stops before re-examining the originating From 7a375e866d591bbc70978326088370f4acfa1aa4 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 20:18:13 -0700 Subject: [PATCH 72/91] fix: enforce whole-word boundaries in book search matcher The matcher ran a plain KMP substring scan, so "cat" matched inside "category" even though the search feature advertises word-boundary matching. Gate matches on word boundaries: the leading boundary is checked at completion via a small per-character ring recording whether each significant byte was preceded by a word char; the trailing boundary is resolved on the next significant byte (a word char rejects, a boundary confirms) or at the end of a record, which is itself a word boundary since records hold whole space-separated words with no trailing separator. A completed match is held as tentative until its trailing boundary is known; the span is carried in the matcher so it survives the matcher copies the chunked scan makes. Hyphenation stays fuzzy: dropped bytes (hyphens, unmapped codepoints, line-break separator spaces) remain transparent for boundary purposes, and a boundary is only required on a pattern edge that is itself a word char (regex \b semantics). --- docs/search-architecture.md | 37 ++++++++++++------ lib/Epub/Epub/SearchMatcher.cpp | 56 ++++++++++++++++++++++++++- lib/Epub/Epub/SearchMatcher.h | 67 ++++++++++++++++++++++++++++++++- lib/Epub/Epub/SectionSearch.cpp | 43 ++++++++++++++++----- 4 files changed, 179 insertions(+), 24 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index f4ed92f8f9..e17ea99589 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -198,8 +198,22 @@ a space immediately following a hyphen — the separator a line-break hyphenatio leaves between the two halves — which is dropped so the halves rejoin (`"international"` matches the stored `"inter- national"`). Runs of spaces collapse and leading/trailing spaces are trimmed so query spacing lines up with the -single-space record. A query may still match a substring inside a longer word -(`"cat"` matches `"category"`); that within-word substring behavior is unchanged. +single-space record. + +Matches are whole-word: a hit must be delimited by non-word characters on both +sides, where a word character is `[a-z0-9]` after folding and everything else +(spaces, punctuation, the record's edges) is a boundary. So `"cat"` no longer +matches inside `"category"` or `"scat"`, but it still matches `"the cat"`, +`"(cat)"`, and `"cat."`. The boundary is only enforced on an edge that is itself +a word character, mirroring a regex `\b`, so a query like `"etc."` is not forced +to sit before a non-word character. Because the trailing boundary can only be +seen on the character *after* a match, a completed match is held as tentative +until the next significant character (a word char rejects it, a boundary +confirms it) or until the record ends — the record stores whole space-separated +words with no trailing separator, so its end is itself a word boundary unless a +line-break hyphen carries the final word onto the next page. Dropped characters +(hyphens, unmapped codepoints) stay transparent for boundary purposes, so the +hyphenation-aware joins above are unaffected. Codepoints with no ASCII or Latin folding (CJK, Cyrillic, Greek, unmapped symbols, etc.) normalize to nothing and are dropped on **both** sides, like @@ -339,10 +353,11 @@ prevents automatic sleep while searching. - Case-insensitive matching and diacritic folding are supported for ASCII and common Latin characters. Codepoints outside the supported Latin set (CJK, Cyrillic, Greek, unmapped symbols) normalize to nothing and are ignored on both sides during matching rather than requiring an exact match (see Matching algorithm), so they neither help nor block a match. - Search text is reconstructed from rendered word tokens with single spaces, so it can differ from the EPUB source in spacing and in words split by layout-time - hyphenation. Matching ignores hyphens but respects spaces, and carries match - state across adjacent same-spine pages, so it absorbs hyphenation (hard and - line-break, including across a page boundary) while still treating spaces as - word boundaries (see Matching algorithm). Other punctuation-glyph differences + hyphenation. Matching is whole-word (a hit must be delimited by non-word + characters) but ignores hyphens, and carries match state across adjacent + same-spine pages, so it absorbs hyphenation (hard and line-break, including + across a page boundary) while still respecting word boundaries — `"cat"` does + not match inside `"category"` (see Matching algorithm). Other punctuation-glyph differences (curly vs straight quotes, em dash, the ellipsis character vs three dots) are not normalized and can still cause a miss, and a match split across a chapter (spine) boundary is not joined. @@ -410,15 +425,15 @@ heap alone is insufficient to detect fragmentation. targeting SD seek latency — the likely dominant cost — more directly than any change to the matching algorithm. It would bump the cache version. - Store a source-faithful (de-hyphenated) search text. Matching now respects - spaces and only fuzzes hyphens (see Matching algorithm), so the common - cross-word straddle is gone. Two minor gaps remain: the line-break-hyphen - rejoin is a heuristic (any space directly after a hyphen is dropped), and a - multi-word query can still match mid-word at its own ends. Closing them fully + spaces, enforces whole-word boundaries, and only fuzzes hyphens (see Matching + algorithm), so the cross-word straddle and the mid-word match at a query's ends + are both gone. One minor gap remains: the line-break-hyphen rejoin is a + heuristic (any space directly after a hyphen is dropped). Closing it fully means storing the source token stream with correct join/no-join boundaries instead of the rendered tokens. The join metadata (`WORD_FLAG_INSERTED_HYPHEN`, `ParsedText` continuation flags) exists during layout but is dropped before `Page::serializeSearchText()`, which sees only rendered tokens with the line-break `-` already appended. Threading it through touches the layout - pipeline and bumps the cache version, so defer until the residual gaps bite. + pipeline and bumps the cache version, so defer until the residual gap bites. - Normalize punctuation for cross-medium search. Even with space/hyphen folding, curly vs straight quotes and em dash vs hyphen can still cause a miss. diff --git a/lib/Epub/Epub/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp index f33831888a..a1fcd0f3f2 100644 --- a/lib/Epub/Epub/SearchMatcher.cpp +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -6,6 +6,11 @@ #include "AsciiCase.h" namespace { +// A "word" byte for boundary purposes. Normalization has already folded every +// matchable letter to ASCII a-z, so word characters are exactly [a-z0-9]; +// spaces, punctuation, and dropped/unmapped codepoints are boundaries. +bool isWordByte(uint8_t b) { return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9'); } + uint32_t stripLatinDiacritics(uint32_t cp) { if (cp >= 'A' && cp <= 'Z') return cp + 32; @@ -153,6 +158,12 @@ bool SearchMatcher::compile(const std::string_view query) { return false; } + // A whole-word boundary is only enforced on an edge that is itself a word + // character, mirroring \b: "cat" requires boundaries on both sides, but a + // query ending in punctuation does not demand a non-word character after it. + patternStartsWithWordChar = isWordByte(pattern[0]); + patternEndsWithWordChar = isWordByte(pattern[length - 1]); + for (size_t i = 1, m = 0; i < length; ++i) { const uint8_t value = pattern[i]; while (m > 0 && value != pattern[m]) { @@ -254,6 +265,20 @@ int SearchMatcher::feed(uint8_t c) { prevWasHyphen = false; lastEmittedWasSpace = (b == ' '); + // This significant byte is the character immediately after any match that + // completed earlier, so it decides that match's trailing boundary. A pattern + // that ends in a word char needs a non-word neighbour here; one ending in a + // non-word char needs no trailing boundary at all and is confirmed by any + // following character. (The record's end is handled by the caller.) + if (pendingActive) { + pendingActive = false; + if (!patternEndsWithWordChar || !isWordByte(b)) { + return -1; // boundary holds: the pending match is a whole word + } + // A word character extends the match into a longer word; reject it and + // keep scanning. KMP state is untouched, so a later occurrence can match. + } + const uint8_t value = b; while (matched > 0 && value != pattern[matched]) { @@ -272,6 +297,10 @@ int SearchMatcher::feed(uint8_t c) { matchByteWidths[widthBufferHead] = currentCodepointWidth; matchCodepointIds[widthBufferHead] = currentCodepointId; + // Record what preceded this byte before updating the running class, so the + // completion check below can read the character just before the match start. + precededByWordChar[widthBufferHead] = prevWasWordChar; + prevWasWordChar = isWordByte(b); widthBufferHead = (widthBufferHead + 1) % MAX_QUERY_BYTES; if (value == pattern[matched]) { @@ -288,7 +317,32 @@ int SearchMatcher::feed(uint8_t c) { } } matched = prefix[matched - 1]; - totalWidthReturn = totalWidth; + + // Leading boundary: the character before the match's first significant + // byte must be a non-word character (unless the pattern itself starts on + // a non-word char, where no boundary is required). The start byte sits + // `length` positions back in the ring, the same index the width sum used. + bool leadingBoundaryOk = !patternStartsWithWordChar; + if (!leadingBoundaryOk) { + const int startIndex = (widthBufferHead + MAX_QUERY_BYTES - length) % MAX_QUERY_BYTES; + leadingBoundaryOk = !precededByWordChar[startIndex]; + } + // A multi-character fold (e.g. ß -> "ss") emits more bytes in this same + // feed, and every such expansion is a run of letters. If the pattern ends + // on a word char, that next letter is its trailing neighbour and rejects + // the match here, before the per-feed pending check below would ever see + // it. (When the pattern ends on a non-word char no trailing boundary is + // required, so the following byte is harmless.) + const bool moreBytesInFold = shift + 8 < 32 && ((norm >> (shift + 8)) & 0xFF) != 0; + if (leadingBoundaryOk && !(patternEndsWithWordChar && moreBytesInFold)) { + // Tentative: the trailing boundary is confirmed by the next byte (or by + // the caller at the record's end). Report the width so the caller can + // record the span; a return here cannot also be a pending confirmation + // because confirmation returns -1 above before reaching this point. + totalWidthReturn = totalWidth; + } + // A failed leading boundary means the match sits inside a longer word; + // ignore this completion and let KMP keep scanning for a real one. } } } diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h index 5c699ecbb4..18daa1e0dc 100644 --- a/lib/Epub/Epub/SearchMatcher.h +++ b/lib/Epub/Epub/SearchMatcher.h @@ -31,7 +31,21 @@ class SearchMatcher { // hyphenation leaves between a split word's halves; every other space is a // significant character that must be matched, so a query cannot cross a word // boundary it does not itself contain. - // Returns the total raw byte width of the full match if completed, or 0 otherwise. + // + // Matches are whole-word: a hit must be delimited by non-word characters + // (spaces, punctuation, the record's edges) on both sides, so "cat" no longer + // matches inside "category". Because the trailing boundary can only be seen on + // the following character, the return value is a small protocol rather than a + // bare width: + // * 0 - nothing to report (also covers a rejected tentative match). + // * >0 - a leading-boundary-valid match just *completed* on this byte; the + // value is its raw byte width. The match is tentative until its + // trailing boundary is confirmed: the caller must record the span via + // setPendingMatchSpan() and keep feeding. A word character next door + // rejects it; a boundary (or the record's end, via the caller) + // confirms it. + // * <0 - the byte just fed is a word boundary that confirms the pending + // tentative match. The caller should report the span it recorded. int feed(uint8_t c); void reset() { @@ -44,9 +58,34 @@ class SearchMatcher { currentCodepointId = 0; prevWasHyphen = false; lastEmittedWasSpace = false; + prevWasWordChar = false; + pendingActive = false; } - bool hasPartialMatch() const { return matched > 0; } + // A partial match exists when KMP is mid-pattern or a completed match is still + // awaiting its trailing-boundary confirmation. Both states want the wrapped + // search to scan one continuation page so the match can finish. + bool hasPartialMatch() const { return matched > 0 || pendingActive; } + + // True while the last significant byte fed was a hyphen, i.e. a line-break + // hyphenation may still rejoin the current word with the next page's text. The + // scan uses this to decide whether a record's end is a real word boundary. + bool isHyphenPending() const { return prevWasHyphen; } + + // A completed-but-unconfirmed whole-word match is held until its trailing + // boundary is seen. The caller owns the span coordinates (page + byte offsets) + // since the matcher knows nothing about page layout; it just carries them so + // they survive the matcher copies the chunked scan makes. + bool hasPendingMatch() const { return pendingActive; } + void setPendingMatchSpan(int page, int startByte, int endByte) { + pendingActive = true; + pendingPage_ = page; + pendingStartByte_ = startByte; + pendingEndByte_ = endByte; + } + int pendingPage() const { return pendingPage_; } + int pendingStartByte() const { return pendingStartByte_; } + int pendingEndByte() const { return pendingEndByte_; } private: std::array pattern{}; @@ -58,6 +97,17 @@ class SearchMatcher { // the reported match width used for highlight offsets. std::array matchByteWidths{}; std::array matchCodepointIds{}; + // Per-significant-byte flag: was the byte emitted just before this one a word + // character? Read back at the match's start position on completion to decide + // the leading word boundary without re-scanning. Same ring layout as the width + // buffers above (indexed by widthBufferHead, wrapped at MAX_QUERY_BYTES). + std::array precededByWordChar{}; + + // Whether the compiled pattern begins / ends on a word character. A boundary + // is only required on an edge that is itself a word char (regex \b semantics), + // so a query like "(cat)" is not forced to sit between non-word characters. + bool patternStartsWithWordChar = false; + bool patternEndsWithWordChar = false; uint32_t utf8State = 0; uint32_t utf8Codepoint = 0; @@ -71,6 +121,19 @@ class SearchMatcher { // fed so far and are cleared by reset(). bool prevWasHyphen = false; bool lastEmittedWasSpace = false; + // Word-class of the most recent significant byte, used to fill + // precededByWordChar for the next one. Reset to false (a non-word boundary) at + // reset() so a fresh page or stream starts at a word boundary. + bool prevWasWordChar = false; + + // A completed match whose trailing boundary has not yet been confirmed. The + // span coordinates are caller-owned (see setPendingMatchSpan); the matcher + // only tracks that one is outstanding so the next significant byte can confirm + // or reject it. Survives the matcher copies the chunked scan makes. + bool pendingActive = false; + int pendingPage_ = -1; + int pendingStartByte_ = 0; + int pendingEndByte_ = 0; static size_t normalizeSearchQuery(std::string_view query, std::array& out); }; diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index ca29349693..d35ca848bb 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -151,6 +151,11 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S // A page with no searchable text (e.g. image-only) is a content discontinuity, // so drop any carried partial match rather than bridging across it. if (remaining == 0) { + // The discontinuity is a hard word boundary, so it confirms a match left + // pending by an earlier page (e.g. one that ended on a line-break hyphen). + if (matcher.hasPendingMatch()) { + return {ScanStatus::Match, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } matcher.reset(); continue; } @@ -162,8 +167,12 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S // hyphen rejoin with the continuation word (the matcher drops a space right // after a hyphen). The injected byte is not part of the record, so it is not // counted in pageBytePos; it can never complete a match because a compiled - // query never ends in a space. - matcher.feed(' '); + // query never ends in a space. It can, however, confirm the trailing boundary + // of a match left pending by the previous page; report it on the page where + // it completed (the span the matcher carries), not this one. + if (matcher.feed(' ') < 0) { + return {ScanStatus::Match, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } // Byte offset of the next fed byte within this page's text record content // (the bytes after the u32 length prefix). Used to report where a completed @@ -179,20 +188,34 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S remaining -= chunkSize; for (size_t j = 0; j < chunkSize; ++j) { - const int matchWidth = matcher.feed(buffer[j]); - if (matchWidth > 0) { - // buffer[j] is the match's last byte; the match spans the preceding - // matchWidth bytes. Clamp the start to 0 when the match began on an - // earlier page so the span covers only this page's portion. + const int signal = matcher.feed(buffer[j]); + if (signal > 0) { + // A whole-word match completed on buffer[j], but its trailing boundary + // is not yet known. Record the span now (buffer[j] is the match's last + // byte; it spans the preceding `signal` bytes, clamped to the page + // start when the match began on an earlier page) and keep feeding so + // the next byte can confirm or reject it. const int endByte = static_cast(pageBytePos); - const int startByte = (pageBytePos + 1 >= static_cast(matchWidth)) - ? static_cast(pageBytePos + 1 - static_cast(matchWidth)) + const int startByte = (pageBytePos + 1 >= static_cast(signal)) + ? static_cast(pageBytePos + 1 - static_cast(signal)) : 0; - return {ScanStatus::Match, static_cast(startPage + i), startByte, endByte}; + matcher.setPendingMatchSpan(static_cast(startPage + i), startByte, endByte); + } else if (signal < 0) { + // buffer[j] is a word boundary that confirms the pending match: report + // the span captured when it completed. + return {ScanStatus::Match, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; } ++pageBytePos; } } + + // The record holds whole, space-separated words with no trailing separator, + // so its end is a word boundary too — unless a line-break hyphen carries the + // final word onto the next page. Confirm a match pending on this page's last + // word here, so it is reported on this page rather than waiting for the next. + if (matcher.hasPendingMatch() && !matcher.isHyphenPending()) { + return {ScanStatus::Match, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } } return {ScanStatus::NoMatch, -1}; From 81bf88c7d215ecc27f63a363ee684954713b0d84 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 20:18:25 -0700 Subject: [PATCH 73/91] test: add whole-word search matcher regression suite Add a GoogleTest suite that drives SearchMatcher through the same feeding contract Section::scanForward uses (page-boundary space, tentative/confirm protocol, end-of-record and empty-record boundaries), so the tests exercise the real scan integration rather than feed() alone. Covers substring rejection, whole-word spans, punctuation/record-edge boundaries, regex-\b query edges, phrases, case/diacritic folding, the ss-expansion boundary guard, hard and line-break hyphenation, cross-page matches, CJK transparency, KMP self-overlap, and first-occurrence selection. --- test/CMakeLists.txt | 1 + test/search_matcher/CMakeLists.txt | 15 ++ test/search_matcher/SearchMatcherTest.cpp | 214 ++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 test/search_matcher/CMakeLists.txt create mode 100644 test/search_matcher/SearchMatcherTest.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 17c466436a..24bb2e2a4e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -43,3 +43,4 @@ add_subdirectory(release_json_parser) add_subdirectory(differential_rounding) add_subdirectory(hyphenation_eval) add_subdirectory(utf8_compose) +add_subdirectory(search_matcher) diff --git a/test/search_matcher/CMakeLists.txt b/test/search_matcher/CMakeLists.txt new file mode 100644 index 0000000000..fbc715e598 --- /dev/null +++ b/test/search_matcher/CMakeLists.txt @@ -0,0 +1,15 @@ +add_executable(SearchMatcherTest + SearchMatcherTest.cpp + ${REPO_ROOT}/lib/Epub/Epub/SearchMatcher.cpp +) + +target_include_directories(SearchMatcherTest PRIVATE + ${REPO_ROOT}/lib/Epub/Epub +) + +target_link_libraries(SearchMatcherTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(SearchMatcherTest) diff --git a/test/search_matcher/SearchMatcherTest.cpp b/test/search_matcher/SearchMatcherTest.cpp new file mode 100644 index 0000000000..63f3bb326a --- /dev/null +++ b/test/search_matcher/SearchMatcherTest.cpp @@ -0,0 +1,214 @@ +#include + +#include +#include + +#include "SearchMatcher.h" + +namespace { + +// Result of a scan, mirroring the fields Section::scanForward reports. +struct ScanResult { + bool matched = false; + int page = -1; + int startByte = -1; + int endByte = -1; +}; + +// Drive the matcher over a book modelled as a list of per-page search-text +// records, reproducing Section::scanForward's feeding contract so these tests +// exercise the exact protocol the real scan relies on: +// * an explicit word-boundary space is fed before each page's content, +// * a positive feed() return is a tentative whole-word match whose span the +// caller records and whose trailing boundary is confirmed later, +// * a negative return confirms the pending match, +// * the end of a record (no trailing separator) is a word boundary unless a +// line-break hyphen is still pending, and +// * an empty record is a discontinuity that also confirms a pending match. +// Keep this in lockstep with lib/Epub/Epub/SectionSearch.cpp; if that loop +// changes, this driver should change with it. +ScanResult scan(SearchMatcher& matcher, const std::vector& pages) { + for (size_t i = 0; i < pages.size(); ++i) { + const std::string& record = pages[i]; + + if (record.empty()) { + if (matcher.hasPendingMatch()) { + return {true, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } + matcher.reset(); + continue; + } + + if (matcher.feed(' ') < 0) { + return {true, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } + + uint32_t pageBytePos = 0; + for (const unsigned char ch : record) { + const int signal = matcher.feed(ch); + if (signal > 0) { + const int endByte = static_cast(pageBytePos); + const int startByte = (pageBytePos + 1 >= static_cast(signal)) + ? static_cast(pageBytePos + 1 - static_cast(signal)) + : 0; + matcher.setPendingMatchSpan(static_cast(i), startByte, endByte); + } else if (signal < 0) { + return {true, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } + ++pageBytePos; + } + + if (matcher.hasPendingMatch() && !matcher.isHyphenPending()) { + return {true, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; + } + } + + return {}; +} + +// Convenience: compile a query and scan a single-page book. +ScanResult scanQuery(const char* query, const std::vector& pages) { + SearchMatcher matcher; + EXPECT_TRUE(matcher.compile(query)) << "query failed to compile: " << query; + return scan(matcher, pages); +} + +} // namespace + +// --- Whole-word boundaries: a substring inside a longer word must not match. --- + +TEST(SearchMatcherWholeWord, RejectsSubstringInsideWord) { + EXPECT_FALSE(scanQuery("cat", {"the category"}).matched); // trailing word char + EXPECT_FALSE(scanQuery("tegory", {"the category"}).matched); // leading word char + EXPECT_FALSE(scanQuery("ego", {"category"}).matched); // both ends mid-word + EXPECT_FALSE(scanQuery("cat", {"scat"}).matched); // leading word char + EXPECT_FALSE(scanQuery("x", {"the x2 fast"}).matched); // single char inside word +} + +TEST(SearchMatcherWholeWord, MatchesWholeWordsWithSpan) { + const ScanResult cat = scanQuery("cat", {"the cat sat"}); + EXPECT_TRUE(cat.matched); + EXPECT_EQ(cat.startByte, 4); + EXPECT_EQ(cat.endByte, 6); + + const ScanResult category = scanQuery("category", {"the category here"}); + EXPECT_TRUE(category.matched); + EXPECT_EQ(category.startByte, 4); + EXPECT_EQ(category.endByte, 11); + + EXPECT_TRUE(scanQuery("the", {"the cat"}).matched); // first word + EXPECT_TRUE(scanQuery("sat", {"the cat sat"}).matched); // last word, confirmed by record end + EXPECT_TRUE(scanQuery("hi", {"hi"}).matched); // whole record is the word + EXPECT_TRUE(scanQuery("x2", {"the x2 fast"}).matched); // digits are word chars +} + +// --- Punctuation, parentheses, and record edges are boundaries. --- + +TEST(SearchMatcherWholeWord, PunctuationIsABoundary) { + EXPECT_TRUE(scanQuery("cat", {"a cat, here"}).matched); + EXPECT_TRUE(scanQuery("cat", {"(cat)"}).matched); + EXPECT_TRUE(scanQuery("cat", {"the cat."}).matched); +} + +// A query that itself ends in a non-word char needs no trailing boundary +// (mirrors a regex \b, which only applies between word and non-word chars). +TEST(SearchMatcherWholeWord, NonWordQueryEdgeNeedsNoBoundary) { + EXPECT_TRUE(scanQuery("etc.", {"and etc. more"}).matched); + EXPECT_TRUE(scanQuery("etc.", {"foo etc.x more"}).matched); +} + +// --- Phrases respect spaces and still require outer boundaries. --- + +TEST(SearchMatcherPhrase, MatchesMultiWordQuery) { + const ScanResult phrase = scanQuery("the cat", {"see the cat run"}); + EXPECT_TRUE(phrase.matched); + EXPECT_EQ(phrase.startByte, 4); + EXPECT_EQ(phrase.endByte, 10); +} + +TEST(SearchMatcherPhrase, DoesNotCrossWordBoundaryWithoutSpace) { + EXPECT_FALSE(scanQuery("heran", {"the rang"}).matched); +} + +// --- Folding: case and Latin diacritics still resolve to whole-word matches. --- + +TEST(SearchMatcherFolding, CaseAndDiacritics) { + EXPECT_TRUE(scanQuery("CAT", {"the Cat sat"}).matched); + EXPECT_TRUE(scanQuery("cafe", {"the caf\xC3\xA9 here"}).matched); // café +} + +// ß expands to "ss"; a prefix of that expanded word must not match, but the +// whole word must. This guards the in-fold trailing-boundary check. +TEST(SearchMatcherFolding, MultiByteExpansionRespectsBoundary) { + EXPECT_FALSE(scanQuery("ma", {"the ma\xC3\x9F x"}).matched); // "maß" -> "mass" + EXPECT_TRUE(scanQuery("mass", {"a ma\xC3\x9F end"}).matched); +} + +// --- Hyphenation stays fuzzy (the carve-out from whole-word matching). --- + +TEST(SearchMatcherHyphenation, HardHyphenJoinsButPrefixStillNotWhole) { + EXPECT_TRUE(scanQuery("motherinlaw", {"my mother-in-law cooks"}).matched); + EXPECT_FALSE(scanQuery("mother", {"my mother-in-law cooks"}).matched); +} + +// Layout line-break hyphen: "inter-" at the foot of one page, "national" at the +// top of the next. The match completes (and is reported) on the second page. +TEST(SearchMatcherHyphenation, LineBreakHyphenAcrossPage) { + const ScanResult result = scanQuery("international", {"go inter-", "national now"}); + EXPECT_TRUE(result.matched); + EXPECT_EQ(result.page, 1); +} + +// --- Carried state across contiguous pages. --- + +TEST(SearchMatcherCrossPage, PhraseStraddlesPageBoundary) { + const ScanResult result = scanQuery("cat run", {"the cat", "run fast"}); + EXPECT_TRUE(result.matched); + EXPECT_EQ(result.page, 1); // reported where it completes +} + +// Unmapped codepoints (e.g. CJK) are dropped on both sides and stay transparent +// for boundary purposes, like a hyphen. +TEST(SearchMatcherCrossPage, UnmappedCodepointIsTransparent) { + // "a你b" -> "ab"; a search for "ab" matches it as a whole token. + EXPECT_TRUE(scanQuery("ab", {"x a\xE4\xBD\xA0\x62 y"}).matched); +} + +// --- Matching internals that the whole-word change must not regress. --- + +// A self-overlapping query must not match inside a longer repetition, but must +// match the whole repeated token. +TEST(SearchMatcherInternals, SelfOverlapRespectsBoundary) { + EXPECT_FALSE(scanQuery("aba", {"the ababa word"}).matched); + EXPECT_TRUE(scanQuery("ababa", {"the ababa word"}).matched); +} + +// The first whole-word occurrence wins, even when an earlier substring is +// rejected first. +TEST(SearchMatcherInternals, ReportsFirstWholeWordOccurrence) { + const ScanResult firstOfTwo = scanQuery("cat", {"cat cat"}); + EXPECT_TRUE(firstOfTwo.matched); + EXPECT_EQ(firstOfTwo.startByte, 0); + EXPECT_EQ(firstOfTwo.endByte, 2); + + // "cat" appears as a prefix of "category" (rejected) before the standalone word. + const ScanResult afterReject = scanQuery("cat", {"category cat"}); + EXPECT_TRUE(afterReject.matched); + EXPECT_EQ(afterReject.startByte, 9); + EXPECT_EQ(afterReject.endByte, 11); +} + +// --- Query validation / equivalence helpers (unchanged behavior, guarded). --- + +TEST(SearchMatcherQuery, ValidatesUsableQueries) { + EXPECT_TRUE(SearchMatcher::isValidSearchQuery("cat")); + EXPECT_FALSE(SearchMatcher::isValidSearchQuery("")); + EXPECT_FALSE(SearchMatcher::isValidSearchQuery(" ")); // all whitespace + EXPECT_FALSE(SearchMatcher::isValidSearchQuery("---")); // normalizes to nothing +} + +TEST(SearchMatcherQuery, EquivalenceIgnoresCaseAndFuzz) { + EXPECT_TRUE(SearchMatcher::queriesEquivalent("Cat", "cat")); + EXPECT_TRUE(SearchMatcher::queriesEquivalent("mother-in-law", "motherinlaw")); + EXPECT_FALSE(SearchMatcher::queriesEquivalent("cat", "dog")); +} From 43566bd2dec72b07936d5099ab7cca54cb5df1a0 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 20:20:17 -0700 Subject: [PATCH 74/91] test: link EpdFontFamily into DifferentialRoundingTest cmake target The cmake target omitted lib/EpdFont/EpdFontFamily.cpp, so it failed to link (undefined EpdFontFamily::getTextDimensions / getFallbackCodepoint) even though run_differential_rounding_test.sh compiles that source. Add it so the whole gtest suite builds, which the new CI job requires. --- test/differential_rounding/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/differential_rounding/CMakeLists.txt b/test/differential_rounding/CMakeLists.txt index b958f6ebfd..84b9ed0b3a 100644 --- a/test/differential_rounding/CMakeLists.txt +++ b/test/differential_rounding/CMakeLists.txt @@ -1,6 +1,7 @@ add_executable(DifferentialRoundingTest DifferentialRoundingTest.cpp ${REPO_ROOT}/lib/EpdFont/EpdFont.cpp + ${REPO_ROOT}/lib/EpdFont/EpdFontFamily.cpp ${REPO_ROOT}/lib/Utf8/Utf8.cpp ) From e7556e3e7abaf6bffb87e0bb8f7c9ac52495b946 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 20:20:18 -0700 Subject: [PATCH 75/91] chore: build and run gtest unit suite in CI Add a unit-tests job that configures, builds, and runs the CMake/ GoogleTest suite under test/ with ctest, and make it a required check via test-status. Nothing ran these suites in CI before, so regressions in the matcher, parsers, and font/rounding logic could land unnoticed. --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580f9cb6be..84c2fffa87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,25 @@ jobs: .pio/build/tiny/firmware-tiny.bin if-no-files-found: error + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Verify CMake + run: cmake --version + + - name: Configure test suite + run: cmake -S test -B test/build -DCMAKE_BUILD_TYPE=Release + + - name: Build test suite + run: cmake --build test/build -j"$(nproc)" + + - name: Run test suite + run: ctest --test-dir test/build --output-on-failure + # This job is used as the PR required actions check, allows for changes to other steps in the future without breaking # PR requirements. test-status: @@ -124,6 +143,7 @@ jobs: - build - clang-format - cppcheck + - unit-tests if: always() runs-on: ubuntu-latest steps: From 42a81019a1057afe6bb7323a8c3372c6a92592ab Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Mon, 29 Jun 2026 20:28:43 -0700 Subject: [PATCH 76/91] revert: drop unrelated /Read destination-collision refinement This search branch had accumulated an unrelated refinement of the existing "/Read folder" move feature, spanning commits 9656a6c6 and 7d9717ce. Restore main's behavior so the branch stays scoped to search: - BookMoveUtils::buildReadFolderDestination no longer returns "" when no free "name (N)" slot exists (reverted to main). - Drop the now-unneeded empty-destination guards in BookActions.cpp and EpubReaderActivity::moveFinishedBookToReadFolder. The dead duplicate of the helper that 9656a6c6 removed from EpubReaderActivity stays removed: it is unused and behavior-neutral, so re-adding it would only restore dead code. --- src/activities/home/BookActions.cpp | 4 +--- src/activities/reader/EpubReaderActivity.cpp | 4 +--- src/util/BookMoveUtils.cpp | 14 +++++--------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/activities/home/BookActions.cpp b/src/activities/home/BookActions.cpp index 81ac275f9b..7cf1b53dd6 100644 --- a/src/activities/home/BookActions.cpp +++ b/src/activities/home/BookActions.cpp @@ -168,9 +168,7 @@ bool toggleEpubCompleted(const std::string& fullPath, const std::string& display const std::string title = epub.getTitle(); const std::string author = epub.getAuthor(); LOG_INF("BookActions", "Moving completed epub: %s -> %s", fullPath.c_str(), dstPath.c_str()); - // buildReadFolderDestination returns "" when no free name is available; never - // hand rename() an empty target (it would fail or clobber). Treat as a move failure. - if (dstPath.empty() || !Storage.rename(fullPath.c_str(), dstPath.c_str())) { + if (!Storage.rename(fullPath.c_str(), dstPath.c_str())) { LOG_ERR("BookActions", "Failed to move book to 'Read' folder"); snprintf(APP_STATE.pendingAlertTitle, sizeof(APP_STATE.pendingAlertTitle), "%s", tr(STR_MOVE_TO_READ_FAILED_TITLE)); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c44b0f496a..7e2a06eaa4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1001,9 +1001,7 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& const std::string& oldCachePath, const std::string& title, const std::string& author) { LOG_INF("ERS", "Moving finished epub: %s -> %s", srcPath.c_str(), dstPath.c_str()); - // buildReadFolderDestination returns "" when no free name is available; never - // hand rename() an empty target (it would fail or clobber). Treat as a move failure. - if (dstPath.empty() || !Storage.rename(srcPath.c_str(), dstPath.c_str())) { + if (!Storage.rename(srcPath.c_str(), dstPath.c_str())) { LOG_ERR("ERS", "Failed to move finished book to '/Read' folder"); snprintf(APP_STATE.pendingAlertTitle, sizeof(APP_STATE.pendingAlertTitle), "%s", tr(STR_MOVE_TO_READ_FAILED_TITLE)); snprintf(APP_STATE.pendingAlertBody, sizeof(APP_STATE.pendingAlertBody), tr(STR_MOVE_TO_READ_FAILED_BODY), diff --git a/src/util/BookMoveUtils.cpp b/src/util/BookMoveUtils.cpp index a33e05638f..c2b86adf6a 100644 --- a/src/util/BookMoveUtils.cpp +++ b/src/util/BookMoveUtils.cpp @@ -28,16 +28,12 @@ std::string buildReadFolderDestination(const std::string& srcPath) { const size_t dotPos = filename.rfind('.'); const std::string base = (dotPos != std::string::npos) ? filename.substr(0, dotPos) : filename; const std::string ext = (dotPos != std::string::npos) ? filename.substr(dotPos) : ""; - for (int suffix = 2; suffix < 100; ++suffix) { + int suffix = 2; + do { dstPath = std::string(READ_FOLDER) + "/" + base + " (" + std::to_string(suffix) + ")" + ext; - if (!Storage.exists(dstPath.c_str())) { - return dstPath; - } - } - // No free "name (N)" slot under the limit: signal failure rather than hand - // rename() a path that already exists (which would clobber another book). - LOG_ERR("BookMove", "No free destination name in %s for %s", READ_FOLDER, filename.c_str()); - return ""; + suffix++; + } while (Storage.exists(dstPath.c_str()) && suffix < 100); + return dstPath; } bool migrateMovedEpubState(const std::string& oldPath, const std::string& newPath, const std::string& oldCachePath, From f89f5436000d73d37ebb553edc84070bea7cccd0 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 00:03:14 -0700 Subject: [PATCH 77/91] ci: harden checkout action for unit tests --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c2fffa87..281944b5e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,9 @@ jobs: unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + persist-credentials: false submodules: recursive - name: Verify CMake From 9df71a33353f57a399bff0737e207ab8bd6d2b4f Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 00:03:48 -0700 Subject: [PATCH 78/91] test: add regression test for NFC/NFD equivalence in search queries --- test/search_matcher/SearchMatcherTest.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/test/search_matcher/SearchMatcherTest.cpp b/test/search_matcher/SearchMatcherTest.cpp index 63f3bb326a..2f02ba799f 100644 --- a/test/search_matcher/SearchMatcherTest.cpp +++ b/test/search_matcher/SearchMatcherTest.cpp @@ -210,5 +210,6 @@ TEST(SearchMatcherQuery, ValidatesUsableQueries) { TEST(SearchMatcherQuery, EquivalenceIgnoresCaseAndFuzz) { EXPECT_TRUE(SearchMatcher::queriesEquivalent("Cat", "cat")); EXPECT_TRUE(SearchMatcher::queriesEquivalent("mother-in-law", "motherinlaw")); + EXPECT_TRUE(SearchMatcher::queriesEquivalent("caf\xC3\xA9", "cafe\xCC\x81")); EXPECT_FALSE(SearchMatcher::queriesEquivalent("cat", "dog")); } From b73f90ccbb75246e8ae1cf43252733818af132de Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 00:03:56 -0700 Subject: [PATCH 79/91] fix: return IoError for unexpected short reads in text payload --- lib/Epub/Epub/SectionSearch.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index d35ca848bb..b5ec56a391 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -141,8 +141,12 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } uint32_t remaining = 0; - if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining) || - remaining > lutOffset - searchTextOffset - sizeof(uint32_t)) { + if (file.read(reinterpret_cast(&remaining), sizeof(remaining)) != sizeof(remaining)) { + LOG_ERR("SCT", "Search failed: could not read text record length"); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + if (remaining > lutOffset - searchTextOffset - sizeof(uint32_t)) { LOG_ERR("SCT", "Search failed: invalid text record length"); closeSearchState(); return {ScanStatus::CorruptCache, -1}; @@ -183,7 +187,7 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S if (file.read(buffer.data(), chunkSize) != chunkSize) { LOG_ERR("SCT", "Search failed: truncated text record"); closeSearchState(); - return {ScanStatus::CorruptCache, -1}; + return {ScanStatus::IoError, -1}; } remaining -= chunkSize; From bb3ab95346c727dbd4886957e2ae661169ac36cc Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 00:23:59 -0700 Subject: [PATCH 80/91] docs: update search architecture numbers --- docs/search-architecture.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index e17ea99589..77dfa52cd1 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -47,8 +47,8 @@ The user-visible behavior is intentionally narrow: of the scan has completed, measured from where the search began (so it rises from 0% to 100% over the whole scan even when the search starts mid-book) -The result is page-granular. The reader opens the matching page, shows a short -confirmation popup, and highlights the matched words on the page. +The result is page-granular. The reader opens the matching page and highlights +the matched words on the page. ## Component flow @@ -73,7 +73,7 @@ The responsibilities are split as follows: - `EpubReaderMenuActivity` exposes the existing translated `Search` command. - `EpubReaderActivity` owns query history and coordinates the keyboard, search - activity, reader position, and result popup. + activity, reader position, and result highlighting. - `SearchHighlighter` encapsulates the transient on-page text highlighting logic. It is a pure consumer of the byte span the scan reports for the matched page: it maps that span to the page's words and paints them, without re-running the @@ -165,7 +165,7 @@ and may grow the vector. The search feature adds no static RAM: a `default` build measured 102,516 bytes of static RAM both with the feature and on its pre-search base (`main`). Flash -grew by about 13,750 bytes in that same comparison (6,344,661 to 6,358,411 +grew by about 14,084 bytes in that same comparison (6,344,661 to 6,358,745 bytes) for the search behavior, cache handling, on-page highlighting, UI, and translated strings. These are build snapshots rather than permanent budgets; remeasure them when the implementation or toolchain changes. From d273cb86adbb36f60de505384505f20019c8c6bf Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 02:26:08 -0700 Subject: [PATCH 81/91] perf: batch text reads during search to reduce SPI overhead --- lib/Epub/Epub/Section.h | 5 +++++ lib/Epub/Epub/SectionSearch.cpp | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 8c25a2c3a3..6653b8c925 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -76,6 +76,11 @@ class Section { // failure rather than an abort. Freed when the Section is destroyed. std::unique_ptr lutBuf; size_t lutBufCapacity = 0; + + // Larger reused read buffer for text records to minimize slow SPI file reads. + // Allocated once (nothrow) on first use. + std::unique_ptr textBuf; + size_t textBufCapacity = 0; }; SearchScanState searchScan; diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index b5ec56a391..89c8639ab9 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -115,8 +115,21 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S return {ScanStatus::IoError, -1}; } + // Allocate a larger heap buffer to batch text reads, drastically reducing + // slow SPI transactions over the small 64-byte stack array previously used. + constexpr size_t TEXT_BUF_SIZE = 2048; + if (searchScan.textBufCapacity < TEXT_BUF_SIZE) { + searchScan.textBuf = makeUniqueNoThrow(TEXT_BUF_SIZE); + if (!searchScan.textBuf) { + searchScan.textBufCapacity = 0; + LOG_ERR("SCT", "Search failed: OOM for text buffer"); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + searchScan.textBufCapacity = TEXT_BUF_SIZE; + } + // Sequentially read the text records - std::array buffer; for (uint16_t i = 0; i < count; i++) { uint32_t searchTextOffset = 0; // searchTextOffset is the 2nd uint32_t in the LUT entry @@ -183,8 +196,8 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S // match lies so the highlighter can map it to words without re-scanning. uint32_t pageBytePos = 0; while (remaining > 0) { - const size_t chunkSize = std::min(buffer.size(), remaining); - if (file.read(buffer.data(), chunkSize) != chunkSize) { + const size_t chunkSize = std::min(searchScan.textBufCapacity, remaining); + if (file.read(searchScan.textBuf.get(), chunkSize) != chunkSize) { LOG_ERR("SCT", "Search failed: truncated text record"); closeSearchState(); return {ScanStatus::IoError, -1}; @@ -192,7 +205,7 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S remaining -= chunkSize; for (size_t j = 0; j < chunkSize; ++j) { - const int signal = matcher.feed(buffer[j]); + const int signal = matcher.feed(searchScan.textBuf[j]); if (signal > 0) { // A whole-word match completed on buffer[j], but its trailing boundary // is not yet known. Record the span now (buffer[j] is the match's last From 9f0307acdf1d757fb3cdf151fe626eb21ca24536 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 15:05:42 -0700 Subject: [PATCH 82/91] docs: fix stale buffer[j] comments after textBuf rename --- lib/Epub/Epub/SectionSearch.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 89c8639ab9..f43676a597 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -207,8 +207,8 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S for (size_t j = 0; j < chunkSize; ++j) { const int signal = matcher.feed(searchScan.textBuf[j]); if (signal > 0) { - // A whole-word match completed on buffer[j], but its trailing boundary - // is not yet known. Record the span now (buffer[j] is the match's last + // A whole-word match completed on textBuf[j], but its trailing boundary + // is not yet known. Record the span now (textBuf[j] is the match's last // byte; it spans the preceding `signal` bytes, clamped to the page // start when the match began on an earlier page) and keep feeding so // the next byte can confirm or reject it. @@ -218,7 +218,7 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S : 0; matcher.setPendingMatchSpan(static_cast(startPage + i), startByte, endByte); } else if (signal < 0) { - // buffer[j] is a word boundary that confirms the pending match: report + // textBuf[j] is a word boundary that confirms the pending match: report // the span captured when it completed. return {ScanStatus::Match, matcher.pendingPage(), matcher.pendingStartByte(), matcher.pendingEndByte()}; } From 340ccc9446111cee1f01a302474a32f917b9e857 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 15:06:08 -0700 Subject: [PATCH 83/91] ci: harden checkout action across all jobs --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 281944b5e8..6b61885267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,9 @@ jobs: clang-format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + persist-credentials: false submodules: recursive - uses: actions/setup-python@v6 @@ -36,8 +37,9 @@ jobs: cppcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + persist-credentials: false submodules: recursive - uses: actions/setup-python@v6 @@ -67,8 +69,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + persist-credentials: false submodules: recursive - uses: actions/setup-python@v6 From e0f73fd7825180bebda2920e62c38298934fd4d4 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 15:30:42 -0700 Subject: [PATCH 84/91] perf: add opt-in SEARCH_PROFILE instrumentation for scan I/O vs CPU --- lib/Epub/Epub/SectionSearch.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index f43676a597..e98a172eb7 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -14,6 +14,13 @@ #include "Section.h" #include "SectionCacheFormat.h" +#ifdef SEARCH_PROFILE +// Opt-in latency profiling: build firmware with -DSEARCH_PROFILE to log, per +// scanned chunk, how the per-page scan time splits between SD I/O (seek+read) +// and matcher CPU (feed()). Hardware-only: esp_timer is an ESP-IDF facility. +#include +#endif + using namespace epub; bool Section::ensureSearchHeader(ScanStatus& failureStatus) { @@ -129,6 +136,14 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S searchScan.textBufCapacity = TEXT_BUF_SIZE; } +#ifdef SEARCH_PROFILE + // Per-page scan accounting. profStart covers only the page loop below, not the + // one-time header/LUT setup, so the split reflects the steady-state cost. + int64_t profCpuUs = 0; + uint64_t profBytes = 0; + const int64_t profStart = esp_timer_get_time(); +#endif + // Sequentially read the text records for (uint16_t i = 0; i < count; i++) { uint32_t searchTextOffset = 0; @@ -203,6 +218,10 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S return {ScanStatus::IoError, -1}; } remaining -= chunkSize; +#ifdef SEARCH_PROFILE + profBytes += chunkSize; + const int64_t profChunkStart = esp_timer_get_time(); +#endif for (size_t j = 0; j < chunkSize; ++j) { const int signal = matcher.feed(searchScan.textBuf[j]); @@ -224,6 +243,9 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } ++pageBytePos; } +#ifdef SEARCH_PROFILE + profCpuUs += esp_timer_get_time() - profChunkStart; +#endif } // The record holds whole, space-separated words with no trailing separator, @@ -235,5 +257,16 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } } +#ifdef SEARCH_PROFILE + { + const unsigned long totalMs = static_cast((esp_timer_get_time() - profStart) / 1000); + const unsigned long cpuMs = static_cast(profCpuUs / 1000); + const unsigned long ioMs = totalMs > cpuMs ? totalMs - cpuMs : 0; + const float kbps = totalMs > 0 ? (static_cast(profBytes) * 1000.0f) / (1024.0f * totalMs) : 0.0f; + LOG_INF("SCT", "search profile: pages=%u textBytes=%lu total=%lums io(seek+read)=%lums cpu(feed)=%lums (%.1f KB/s)", + static_cast(count), static_cast(profBytes), totalMs, ioMs, cpuMs, + static_cast(kbps)); + } +#endif return {ScanStatus::NoMatch, -1}; } From 324be54c3c533dcd70b260b9566a4a5094ac9f2a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:20:46 -0700 Subject: [PATCH 85/91] perf: throttle search progress repaints by wall-clock to cut refresh stalls --- .../reader/EpubReaderSearchActivity.cpp | 24 +++++++++++++------ .../reader/EpubReaderSearchActivity.h | 6 +++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index df25196abc..feb7f42ccb 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -15,9 +15,15 @@ #include "fontIds.h" namespace { -// Repaint the progress screen only once the percentage has advanced this much, -// keeping e-ink refreshes bounded now that progress moves per page. -constexpr int PROGRESS_REPAINT_STEP_PERCENT = 1; +// Minimum wall-clock gap between progress-screen repaints. Each repaint is a +// full-panel FAST_REFRESH that single-core-blocks the scan ~380ms AND disturbs +// the shared SPI bus so the next freshly-opened section's reads run ~5x slower +// (~440ms penalty) — measured on hardware. Refresh cadence therefore dominates +// search latency far more than the scan itself, so we throttle by time rather +// than by percent: this bounds refreshes to ~one per interval no matter how +// large the book or how fast the scan, while still feeling live. Raising it +// trades progress smoothness for less refresh overhead. +constexpr unsigned long PROGRESS_REPAINT_MIN_INTERVAL_MS = 2000; } // namespace EpubReaderSearchActivity::SearchRoute EpubReaderSearchActivity::SearchRoute::plan(const Origin& origin) { @@ -319,13 +325,17 @@ void EpubReaderSearchActivity::loop() { return; } scanNextPage(); - // Progress is now page-granular, so only repaint once it has advanced a - // whole step. This bounds e-ink refreshes to ~100/step over an entire - // scan regardless of book structure, instead of one per page. + // Repaint the progress percentage at most once per interval. Each e-ink + // refresh is expensive (see PROGRESS_REPAINT_MIN_INTERVAL_MS), so we gate + // on both a changed percentage and elapsed wall-clock rather than per page + // or per percent — keeping refreshes (and the scan stalls they cause) rare + // no matter the book size. if (state == SearchState::Searching) { const int percent = searchProgressPercent(); - if (percent - lastProgressPercent >= PROGRESS_REPAINT_STEP_PERCENT) { + const unsigned long now = millis(); + if (percent != lastProgressPercent && now - lastProgressRepaintMs >= PROGRESS_REPAINT_MIN_INTERVAL_MS) { lastProgressPercent = percent; + lastProgressRepaintMs = now; requestUpdate(); } } diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h index 70e0d7e2cf..e9f44b8863 100644 --- a/src/activities/reader/EpubReaderSearchActivity.h +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -92,6 +92,12 @@ class EpubReaderSearchActivity final : public Activity { // changing so the e-ink panel is not refreshed per page. Starts at 0 because // onEnter() paints the initial 0% screen before the scan begins. int lastProgressPercent = 0; + // millis() of the last progress repaint, for the time-based repaint throttle + // (see PROGRESS_REPAINT_MIN_INTERVAL_MS). Each e-ink refresh both blocks the + // scan ~380ms and slows the next section's cold reads via the shared SPI bus, + // so refresh cadence dominates search latency; throttling by wall-clock keeps + // it bounded regardless of book length. + unsigned long lastProgressRepaintMs = 0; // Byte-weighted book position (0-1, via Epub::calculateProgress) where the // scan began, and the length of its route to the stop page (forward to the end // of the book, wrap, then up to the stop page). Captured once the start spine From afa17778127ebd5de2cf53399f49af3d20b1ba6b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:20:47 -0700 Subject: [PATCH 86/91] perf: log firstOfSection in SEARCH_PROFILE to separate cold-file from bus cost --- lib/Epub/Epub/SectionSearch.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index e98a172eb7..44d9c116d7 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -74,6 +74,15 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S endPage = pageCount; } +#ifdef SEARCH_PROFILE + // True when this is a section's first scan chunk (cache file not yet open / + // header not cached). Lets the per-chunk log below tell a cold-file cost apart + // from a post-e-ink-refresh shared-SPI-bus penalty: if the slow chunks are + // always firstOfSection=1 it is the file open; if firstOfSection=0 chunks are + // also slow it is the bus/refresh interaction. + const bool profSectionFirstChunk = !searchScan.headerReady || !file; +#endif + // File size and page-LUT offset are invariant per section; read them once. // ensureSearchHeader distinguishes a transient I/O failure from a corrupt // header so we do not delete a valid cache over a momentary glitch. @@ -263,9 +272,11 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S const unsigned long cpuMs = static_cast(profCpuUs / 1000); const unsigned long ioMs = totalMs > cpuMs ? totalMs - cpuMs : 0; const float kbps = totalMs > 0 ? (static_cast(profBytes) * 1000.0f) / (1024.0f * totalMs) : 0.0f; - LOG_INF("SCT", "search profile: pages=%u textBytes=%lu total=%lums io(seek+read)=%lums cpu(feed)=%lums (%.1f KB/s)", + LOG_INF("SCT", + "search profile: pages=%u textBytes=%lu total=%lums io(seek+read)=%lums cpu(feed)=%lums (%.1f KB/s) " + "firstOfSection=%d", static_cast(count), static_cast(profBytes), totalMs, ioMs, cpuMs, - static_cast(kbps)); + static_cast(kbps), profSectionFirstChunk ? 1 : 0); } #endif return {ScanStatus::NoMatch, -1}; From 9a01fcf27a2bcfa323a504fe3f2d4905238f0f7b Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:29:44 -0700 Subject: [PATCH 87/91] fix: avoid full-screen flash on no-results search --- src/activities/reader/EpubReaderSearchActivity.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index feb7f42ccb..4aa6dd9863 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -396,5 +396,11 @@ void EpubReaderSearchActivity::render(RenderLock&&) { const char* confirmLabel = terminal ? tr(STR_DONE) : ""; const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(terminal ? HalDisplay::FULL_REFRESH : HalDisplay::FAST_REFRESH); + // Always a partial refresh, including the terminal NotFound/Error screen. The + // throttled progress repaints leave little ghosting to clear, the message is a + // transient screen the user immediately dismisses, and dismissing returns to + // the reader which full-refreshes its page anyway — so a full-panel flash here + // is just visual noise. A match never reaches this render (it finish()es and + // the reader paints the result page). + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } From ef1f89d8f0dcd289544b44743bf9d5c19bd0a255 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:40:24 -0700 Subject: [PATCH 88/91] feat: show indexing popup during cold-cache search builds --- src/activities/reader/EpubReaderSearchActivity.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index 4aa6dd9863..ab163888ef 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -168,11 +168,18 @@ bool EpubReaderSearchActivity::ensureSectionLoaded() { } LOG_DBG("EPS", "Building section %d for search", currentSpineIndex); + // Searching a book whose section caches were never built (e.g. a never-read + // book, or after a cache clear) lays out each missing section synchronously — + // seconds per section, during which loop() cannot repaint. Pass the same + // indexing popup the reader uses so a cold-cache search shows "Indexing" + // instead of appearing frozen. The parser only fires this for slow (large) + // sections, so fast ones are unaffected. + const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); }; if (!section.createSectionFile( SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.forceParagraphIndents, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.bionicReadingEnabled, - SETTINGS.guideReadingEnabled, nullptr, nullptr, nullptr, renderMode)) { + SETTINGS.guideReadingEnabled, popupFn, nullptr, nullptr, renderMode)) { LOG_ERR("EPS", "Failed to build section %d for search", currentSpineIndex); setFailure(SearchState::Error); return false; From 6b54ca6345b8cc3ae0526a80889c5a7ffd47f594 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:40:26 -0700 Subject: [PATCH 89/91] docs: note cold-search indexing popup and add text-only index future extension --- docs/search-architecture.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/search-architecture.md b/docs/search-architecture.md index 77dfa52cd1..6e441d356b 100644 --- a/docs/search-architecture.md +++ b/docs/search-architecture.md @@ -341,7 +341,8 @@ prevents automatic sleep while searching. - A cold search can be slow. Reaching an uncached spine requires normal EPUB layout and may write images and section data before scanning can continue. - Cancellation is handled between page scans. An individual uncached-section - layout remains a blocking unit of work. + layout remains a blocking unit of work, but an `Indexing` popup is shown while + it runs so a cold-cache search does not appear frozen. - The cache uses more SD space: roughly the rendered text size plus 8 bytes per page. - Matches are page-level. Repeating a query skips the rest of the current page, @@ -414,6 +415,17 @@ heap alone is insufficient to detect fragmentation. - Make section layout cooperatively cancellable if cold-search latency becomes a usability problem. +- Build a text-only search index for cold sections. Reaching an uncached spine + currently runs a full section layout (parse, paginate, render, serialize) — + seconds per section — because search reuses the reader's page cache. A search + could instead extract only the per-page search text, skipping pagination and + glyph/image rendering (the bulk of that cost), making a cold whole-book search + dramatically faster. The trade-offs: it needs its own on-disk index (a new + format/region) rather than the shared section cache, and a search-triggered + build would no longer warm the reader cache as a side effect, so the first read + of each section would still pay full layout. Worth it only if cold-search + latency on never-read books becomes a priority. Pairs naturally with the + contiguous search-text region below, which would be the index's on-disk form. - Reduce per-page seeks during a warm scan. The page LUT for a chunk is already read once into a reused buffer, and the invariant header state (file size and page-LUT offset) is cached per section, so the remaining per-page cost is a From d9cb73cb6f212a75a8a6638bfca03cb72cca7b9d Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:43:43 -0700 Subject: [PATCH 90/91] chore: remove SEARCH_PROFILE instrumentation now that search latency is tuned --- lib/Epub/Epub/SectionSearch.cpp | 44 --------------------------------- 1 file changed, 44 deletions(-) diff --git a/lib/Epub/Epub/SectionSearch.cpp b/lib/Epub/Epub/SectionSearch.cpp index 44d9c116d7..f43676a597 100644 --- a/lib/Epub/Epub/SectionSearch.cpp +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -14,13 +14,6 @@ #include "Section.h" #include "SectionCacheFormat.h" -#ifdef SEARCH_PROFILE -// Opt-in latency profiling: build firmware with -DSEARCH_PROFILE to log, per -// scanned chunk, how the per-page scan time splits between SD I/O (seek+read) -// and matcher CPU (feed()). Hardware-only: esp_timer is an ESP-IDF facility. -#include -#endif - using namespace epub; bool Section::ensureSearchHeader(ScanStatus& failureStatus) { @@ -74,15 +67,6 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S endPage = pageCount; } -#ifdef SEARCH_PROFILE - // True when this is a section's first scan chunk (cache file not yet open / - // header not cached). Lets the per-chunk log below tell a cold-file cost apart - // from a post-e-ink-refresh shared-SPI-bus penalty: if the slow chunks are - // always firstOfSection=1 it is the file open; if firstOfSection=0 chunks are - // also slow it is the bus/refresh interaction. - const bool profSectionFirstChunk = !searchScan.headerReady || !file; -#endif - // File size and page-LUT offset are invariant per section; read them once. // ensureSearchHeader distinguishes a transient I/O failure from a corrupt // header so we do not delete a valid cache over a momentary glitch. @@ -145,14 +129,6 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S searchScan.textBufCapacity = TEXT_BUF_SIZE; } -#ifdef SEARCH_PROFILE - // Per-page scan accounting. profStart covers only the page loop below, not the - // one-time header/LUT setup, so the split reflects the steady-state cost. - int64_t profCpuUs = 0; - uint64_t profBytes = 0; - const int64_t profStart = esp_timer_get_time(); -#endif - // Sequentially read the text records for (uint16_t i = 0; i < count; i++) { uint32_t searchTextOffset = 0; @@ -227,10 +203,6 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S return {ScanStatus::IoError, -1}; } remaining -= chunkSize; -#ifdef SEARCH_PROFILE - profBytes += chunkSize; - const int64_t profChunkStart = esp_timer_get_time(); -#endif for (size_t j = 0; j < chunkSize; ++j) { const int signal = matcher.feed(searchScan.textBuf[j]); @@ -252,9 +224,6 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } ++pageBytePos; } -#ifdef SEARCH_PROFILE - profCpuUs += esp_timer_get_time() - profChunkStart; -#endif } // The record holds whole, space-separated words with no trailing separator, @@ -266,18 +235,5 @@ Section::ScanResult Section::scanForward(uint16_t startPage, uint16_t endPage, S } } -#ifdef SEARCH_PROFILE - { - const unsigned long totalMs = static_cast((esp_timer_get_time() - profStart) / 1000); - const unsigned long cpuMs = static_cast(profCpuUs / 1000); - const unsigned long ioMs = totalMs > cpuMs ? totalMs - cpuMs : 0; - const float kbps = totalMs > 0 ? (static_cast(profBytes) * 1000.0f) / (1024.0f * totalMs) : 0.0f; - LOG_INF("SCT", - "search profile: pages=%u textBytes=%lu total=%lums io(seek+read)=%lums cpu(feed)=%lums (%.1f KB/s) " - "firstOfSection=%d", - static_cast(count), static_cast(profBytes), totalMs, ioMs, cpuMs, - static_cast(kbps), profSectionFirstChunk ? 1 : 0); - } -#endif return {ScanStatus::NoMatch, -1}; } From 73988854145f8af92d6eb8779562b4846ba5b161 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Tue, 30 Jun 2026 16:47:36 -0700 Subject: [PATCH 91/91] fix: seed search progress throttle from initial paint to avoid an extra refresh --- src/activities/reader/EpubReaderSearchActivity.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/activities/reader/EpubReaderSearchActivity.cpp b/src/activities/reader/EpubReaderSearchActivity.cpp index ab163888ef..e7f8b25b9b 100644 --- a/src/activities/reader/EpubReaderSearchActivity.cpp +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -98,6 +98,11 @@ void EpubReaderSearchActivity::onEnter() { // Paint the status screen before an uncached chapter starts its potentially // long layout pass. requestUpdateAndWait(); + // Seed the repaint throttle from this initial 0% paint so the first nonzero + // percent waits a full interval instead of firing an immediate second refresh + // (lastProgressRepaintMs starts at 0, which the time gate would treat as long + // overdue). + lastProgressRepaintMs = millis(); } void EpubReaderSearchActivity::onExit() { Activity::onExit(); }