diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580f9cb6be..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 @@ -116,6 +119,26 @@ jobs: .pio/build/tiny/firmware-tiny.bin if-no-files-found: error + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + 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 +147,7 @@ jobs: - build - clang-format - cppcheck + - unit-tests if: always() runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cc302aa4..f3ff072674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog + +## [Unreleased] + +### Added + +- 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 ### Added 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..6e441d356b --- /dev/null +++ b/docs/search-architecture.md @@ -0,0 +1,451 @@ +# 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 highlights +the matched words on the page. + +## 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, match byte span)"] + 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 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 + 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`. +- `Page::serializeSearchText()` writes compact searchable text while the page + already exists during layout. +- `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 +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) + +## 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 on-disk page LUT stores two offsets per page — an 8-byte stride +(`PAGE_LUT_ENTRY_SIZE`): + +```text +u32 pageOffset +u32 searchTextOffset +``` + +`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 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: 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. + +## Memory budget + +The steady page-scan path has fixed memory use: + +| Item | Storage | Size | Lifetime | +| --- | --- | ---: | --- | +| Saved query | Inline reader and activity arrays | 65 bytes each | Reader / search activity | +| SD read buffer | Stack | 64 bytes | One page scan | +| 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 +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. Result highlighting is +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 +repeated allocate-copy-free growth. Chapters larger than that remain supported +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 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. + +## Matching algorithm + +`Section::scanForward()` 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. 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. + +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. + +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 +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 +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 +continued at the top of the next (`"national…"`), or any phrase that straddles +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 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 + 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 + +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 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 + +- 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, 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, + so multiple occurrences on one page are not individually navigable. +- 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 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. 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 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. +- 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 + +- 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 + 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, 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 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/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/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/Page.cpp b/lib/Epub/Epub/Page.cpp index 365d4bda93..79ecaf5e1d 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -419,6 +419,8 @@ bool Page::serialize(FsFile& file) const { return true; } +// Page::serializeSearchText() is defined in PageSearch.cpp. + 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/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/SearchMatcher.cpp b/lib/Epub/Epub/SearchMatcher.cpp new file mode 100644 index 0000000000..a1fcd0f3f2 --- /dev/null +++ b/lib/Epub/Epub/SearchMatcher.cpp @@ -0,0 +1,351 @@ +#include "SearchMatcher.h" + +#include +#include + +#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; + + 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'; // ý ÿ Ý + + // 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 0; +} +} // 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. + std::array dummy; + 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; + uint32_t utf8Codepoint = 0; + bool prevWasHyphen = false; + + 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 == 0) continue; // Drop characters that don't normalize to ASCII + + for (int shift = 0; shift < 32; shift += 8) { + uint8_t b = (norm >> shift) & 0xFF; + if (b == 0) break; + + 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; +} + +bool SearchMatcher::compile(const std::string_view query) { + pattern.fill(0); + prefix.fill(0); + length = 0; + reset(); + + if (query.empty() || query.size() > MAX_QUERY_BYTES) { + return false; + } + + length = normalizeSearchQuery(query, pattern); + if (length == 0) { + 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]) { + m = prefix[m - 1]; + } + if (value == pattern[m]) { + ++m; + } + prefix[i] = static_cast(m); + } + return true; +} + +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 == 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; + } + + currentCodepointId++; + const uint16_t ownBytes = utf8BytesConsumed; + uint16_t currentCodepointWidth = ownBytes + pendingSeparatorBytes; + utf8BytesConsumed = 0; + pendingSeparatorBytes = 0; + + int totalWidthReturn = 0; + + for (int shift = 0; shift < 32; shift += 8) { + uint8_t b = (norm >> shift) & 0xFF; + if (b == 0) break; + + // 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 == ' '); + + // 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]) { + matched = prefix[matched - 1]; + } + + 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; + 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]) { + ++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; + uint32_t cpId = matchCodepointIds[index]; + if (cpId != lastSeenCodepoint) { + totalWidth += matchByteWidths[index]; + lastSeenCodepoint = cpId; + } + } + matched = prefix[matched - 1]; + + // 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. + } + } + } + + return totalWidthReturn; +} diff --git a/lib/Epub/Epub/SearchMatcher.h b/lib/Epub/Epub/SearchMatcher.h new file mode 100644 index 0000000000..18daa1e0dc --- /dev/null +++ b/lib/Epub/Epub/SearchMatcher.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include + +class SearchMatcher { + public: + static constexpr size_t MAX_QUERY_BYTES = 64; + + // 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); + + // 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 + // normalizes to nothing (e.g. only spaces or hyphens). + bool compile(std::string_view query); + + // Feed one byte into the matcher. Decodes UTF-8 and maps Latin diacritics. + // 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. + // + // 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() { + matched = 0; + utf8State = 0; + utf8Codepoint = 0; + utf8BytesConsumed = 0; + pendingSeparatorBytes = 0; + widthBufferHead = 0; + currentCodepointId = 0; + prevWasHyphen = false; + lastEmittedWasSpace = false; + prevWasWordChar = false; + pendingActive = false; + } + + // 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{}; + std::array prefix{}; + size_t length = 0; + size_t matched = 0; + // 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{}; + // 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; + uint8_t utf8BytesConsumed = 0; + 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; + // 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/Section.cpp b/lib/Epub/Epub/Section.cpp index e2a522fa99..18e726f2b6 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -7,28 +7,42 @@ #include #include +#include +#include +#include +#include + +#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" -// v41: busts public v40 caches for updated text/layout metadata in this release. -constexpr uint8_t SECTION_FILE_VERSION = 41; 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 +// 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; 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 +62,35 @@ 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"); + +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); } +}; + +// 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 -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 +105,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 +347,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 +417,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 +458,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; } @@ -466,10 +513,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()); @@ -499,61 +546,133 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return true; } +bool Section::readPageLutOffset(uint32_t& lutOffset) { + if (!file.seek(PAGE_LUT_OFFSET_POS)) { + return false; + } + return file.read(reinterpret_cast(&lutOffset), sizeof(lutOffset)) == sizeof(lutOffset); +} + std::unique_ptr Section::loadPageFromSectionFile() { - if (!Storage.openFileForRead("SCT", filePath, file)) { + ScopedSectionFile sf(file, filePath); + if (!sf.ok()) { return nullptr; } - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { - file.close(); + const uint32_t fileSize = file.size(); + if (fileSize < HEADER_SIZE) { + LOG_ERR("SCT", "Section cache header is truncated"); return nullptr; } - uint32_t lutOffset; - if (!serialization::tryReadPod(file, lutOffset) || !file.seek(lutOffset + sizeof(uint32_t) * currentPage)) { - file.close(); + + uint32_t lutOffset = 0; + if (!readPageLutOffset(lutOffset)) { + LOG_ERR("SCT", "Failed to read page LUT offset"); return nullptr; } - uint32_t pagePos; - if (!serialization::tryReadPod(file, pagePos) || !file.seek(pagePos)) { - file.close(); + + if (!file.seek(PAGE_COUNT_POS)) { + LOG_ERR("SCT", "Failed to seek to page count"); + return nullptr; + } + uint16_t headerPageCount = 0; + if (!serialization::tryReadPod(file, headerPageCount)) { + LOG_ERR("SCT", "Failed to read page count from header"); return nullptr; } - auto page = Page::deserialize(file); - // Explicit close() required: member variable persists beyond function scope - file.close(); - return page; + // Validate LUT-derived offsets against the file before trusting them (mirrors + // 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"); + 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"); + return nullptr; + } + if (!file.seek(static_cast(entryOffset))) { + LOG_ERR("SCT", "Failed to seek to page LUT entry"); + 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"); + return nullptr; + } + if (!file.seek(pagePos)) { + LOG_ERR("SCT", "Failed to seek to page record"); + return nullptr; + } + + return Page::deserialize(file); } -std::optional Section::getPageForAnchor(const std::string& anchor) const { - FsFile f; - if (!Storage.openFileForRead("SCT", filePath, f)) { +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::closeSearchState() { + if (file) { + file.close(); + } + searchScan.headerReady = false; +} + +void Section::resetForSpine(const int newSpineIndex) { + closeSearchState(); + spineIndex = newSpineIndex; + rebuildFilePathForSpine(); + pageCount = 0; + currentPage = 0; +} + +// ensureSearchHeader() and scanForward() are defined in SectionSearch.cpp. + +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(ANCHOR_MAP_OFFSET_POS)) { 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) { @@ -564,36 +683,39 @@ 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(PARAGRAPH_LUT_OFFSET_POS)) { 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) { 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; } @@ -601,7 +723,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) { @@ -613,62 +735,64 @@ 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(PARAGRAPH_LUT_OFFSET_POS)) { 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) { 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; } - 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(LI_LUT_OFFSET_POS)) { return std::nullopt; } uint32_t liLutOffset; - if (!serialization::tryReadPod(f, liLutOffset)) { + if (!serialization::tryReadPod(file, liLutOffset)) { return std::nullopt; } if (liLutOffset == 0 || liLutOffset >= fileSize) { @@ -676,40 +800,41 @@ 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(PARAGRAPH_LUT_OFFSET_POS)) { 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) { 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; } - 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 5370926c2b..6653b8c925 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -1,11 +1,15 @@ #pragma once +#include +#include #include #include #include #include +#include #include "Epub.h" #include "EpubRenderMode.h" +#include "SearchMatcher.h" class Page; class GfxRenderer; @@ -18,29 +22,106 @@ 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 + // 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: 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; + + // 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; + + // 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; + 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 failure, setting failureStatus to IoError for an open/seek/read + // failure or CorruptCache for a truncated/malformed header. + bool ensureSearchHeader(ScanStatus& failureStatus); + // 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, 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, @@ -56,15 +137,25 @@ class Section { SectionBuildOptions buildOptions = {}); std::unique_ptr loadPageFromSectionFile(); + // 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); + + // 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 + // 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) 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); }; 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..f43676a597 --- /dev/null +++ b/lib/Epub/Epub/SectionSearch.cpp @@ -0,0 +1,239 @@ +// 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(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. 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; + } + } + + 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 (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; + } + + searchScan.fileSize = fileSize; + searchScan.lutOffset = lutOffset; + searchScan.headerReady = 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. + // 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; + + 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 (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}; + } + 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(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::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 + for (uint16_t i = 0; i < count; i++) { + 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)); + // Text records (each a u32 length prefix + bytes) live in the page-record + // 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}; + } + + 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)) { + 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}; + } + + // 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; + } + + // 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. 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 + // 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(searchScan.textBufCapacity, remaining); + if (file.read(searchScan.textBuf.get(), chunkSize) != chunkSize) { + LOG_ERR("SCT", "Search failed: truncated text record"); + closeSearchState(); + return {ScanStatus::IoError, -1}; + } + remaining -= chunkSize; + + 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 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. + 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(startPage + i), startByte, endByte); + } else if (signal < 0) { + // 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()}; + } + ++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}; +} 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..751161d7e5 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 pasują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..7f5ffe2587 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/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 c391f39414..7e2a06eaa4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -16,10 +17,12 @@ #include #include #include +#include #include #include #include #include +#include #include "../settings/KOReaderSettingsActivity.h" #include "BookStatsActivity.h" @@ -32,6 +35,7 @@ #include "EpubReaderClippingListActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" +#include "EpubReaderSearchActivity.h" #include "EpubReaderUtils.h" #include "GlobalActions.h" #include "KOReaderCredentialStore.h" @@ -46,6 +50,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 +71,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; @@ -101,24 +104,9 @@ 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)); } -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) { @@ -200,13 +188,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; @@ -297,44 +282,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; @@ -349,25 +300,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; @@ -401,32 +353,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; @@ -741,16 +694,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(), @@ -1859,10 +1802,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; } @@ -1936,6 +1877,7 @@ void EpubReaderActivity::loop() { return; } } + if (SETTINGS.longPressMenuAction != CrossPointSettings::LONG_MENU_OFF && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= longPressMenuMs) { longPressMenuHandled = true; @@ -1968,7 +1910,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( @@ -2268,7 +2210,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 +2315,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), @@ -2394,7 +2340,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), @@ -2878,7 +2824,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); @@ -3288,7 +3234,6 @@ void EpubReaderActivity::suppressPowerShortcutRelease() { mappedInput.suppressNextPowerRelease(); mappedInput.suppressNextPowerConfirmRelease(); } - void EpubReaderActivity::setBookCompleted(bool isCompleted) { if (stats.isCompleted == isCompleted) { return; @@ -3327,6 +3272,132 @@ 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(), SearchMatcher::MAX_QUERY_BYTES, InputType::Text); + if (!keyboard) { + LOG_ERR("ERS", "OOM: KeyboardEntryActivity (%u bytes)", static_cast(sizeof(KeyboardEntryActivity))); + requestUpdate(); + return; + } + + pauseReadingPaceTimer("search"); + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { + if (result.isCancelled) { + resumeReadingPaceTimer("search_cancel"); + requestUpdate(); + return; + } + + const auto& query = std::get(result.data).text; + 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. + showToast(tr(STR_INVALID_SEARCH_QUERY), ReaderUtils::READER_MESSAGE_DURATION_MS); + resumeReadingPaceTimer("search_invalid"); + requestUpdate(); + return; + } + launchBookSearch(query); + }); +} + +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 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 = 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 + // page; find-next begins one page past it). + const EpubReaderSearchActivity::SearchRoute route = EpubReaderSearchActivity::SearchRoute::plan( + {realSpine, realPage, epub->getSpineItemsCount(), realSectionLoaded, cachedChapterTotalPageCount, + cachedSpineIndex, sameQuery, lastSearchResultSpine, lastSearchResultPage}); + + 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 + // 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.viewportWidth, viewport.viewportHeight); + if (!searchActivity) { + LOG_ERR("ERS", "OOM: EpubReaderSearchActivity (%u bytes)", static_cast(sizeof(EpubReaderSearchActivity))); + resumeReadingPaceTimer("search_oom"); + requestUpdate(); + return; + } + + // 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; + lastSearchMatchStartByte = -1; + lastSearchMatchEndByte = -1; + } + + startActivityForResult(std::move(searchActivity), [this](const ActivityResult& result) { + 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(); + 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"); + }); +} + +void EpubReaderActivity::showToast(const char* message, const unsigned long durationMs) { + toast.message = message; + toast.showTime = millis(); + toast.durationMs = durationMs; +} + void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { completedFeedbackIsFinished = isCompleted; pendingCompletedFeedback = true; @@ -3340,22 +3411,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) { @@ -3552,7 +3619,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 +3656,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, @@ -3932,7 +3999,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 +4038,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 +4061,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, @@ -4036,6 +4105,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; @@ -4052,6 +4131,14 @@ 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)) { + // Left the search-result page: the highlight is no longer active. + lastSearchResultSpine = -1; + lastSearchResultPage = -1; + lastSearchMatchStartByte = -1; + lastSearchMatchEndByte = -1; + } + const auto t0 = millis(); // Font prewarm: scan pass accumulates text, then prewarm, then real render @@ -4082,6 +4169,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int fo const auto finalizeBufferComposition = [&]() { drawClippingHighlights(*page, fontId, orientedMarginTop, orientedMarginLeft); + 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); }; @@ -4124,10 +4215,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 (toast.message) { + drawToastBuffer(renderer, toast.message); } fcm->logStats("bw_render"); const auto tBwRender = millis(); @@ -4300,46 +4389,13 @@ void EpubReaderActivity::drawClippingHighlights(const Page& page, const int font return false; }; - 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 = 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 (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 { @@ -4423,7 +4479,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 +4595,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 +4631,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 +4651,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..efc53f40b5 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,13 +5,17 @@ #include #include +#include #include #include +#include +#include #include "BookReadingStats.h" #include "BookmarkStore.h" #include "EpubReaderMenuActivity.h" #include "GlobalReadingStats.h" +#include "SearchHighlighter.h" #include "activities/Activity.h" class EpubReaderActivity final : public Activity { @@ -104,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; @@ -118,6 +120,24 @@ class EpubReaderActivity final : public Activity { bool completionTriggerCrossed = false; bool lastAtOrPastCompletionTrigger = false; + // 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; + // 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). bool recentsEntryRemoved = false; @@ -191,6 +211,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 `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; 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..e7f8b25b9b --- /dev/null +++ b/src/activities/reader/EpubReaderSearchActivity.cpp @@ -0,0 +1,418 @@ +#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 { +// 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) { + 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; + } + + 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(normalizeRenderMode(SETTINGS.epubRenderMode))), + route(route), + currentSpineIndex(route.startSpineIndex), + currentPage(route.startPage), + viewportWidth(viewportWidth), + viewportHeight(viewportHeight) { + bool ok = false; + if (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'; + 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 (!ok) { + state = SearchState::NotFound; + } +} + +void EpubReaderSearchActivity::onEnter() { + Activity::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(); } + +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 && matcher.hasPartialMatch(); +} + +void EpubReaderSearchActivity::advanceSpine() { + ++currentSpineIndex; + currentPage = 0; + sectionLoaded = false; + sectionCacheRepairAttempted = false; + 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; + } + + 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); + // 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, popupFn, nullptr, nullptr, renderMode)) { + LOG_ERR("EPS", "Failed to build section %d for search", currentSpineIndex); + setFailure(SearchState::Error); + return false; + } + + sectionLoaded = true; + return true; +} + +bool EpubReaderSearchActivity::advanceSpineIfNeeded() { + 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; + matcher.reset(); // wrap is not contiguous reading text + } + + if (reachedWrappedStop() && !shouldScanWrappedStopContinuation()) { + setFailure(SearchState::NotFound); + return false; + } + + if (!ensureSectionLoaded()) { + return false; // ensureSectionLoaded calls setFailure + } + + if (!wrapped && currentSpineIndex == route.startSpineIndex && route.sourcePageCount > 0) { + route.resolvePageCount(section.pageCount); + currentPage = route.startPage; + } + + 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)); + scanRouteLength = (1.0f - scanStartPos) + stopPos; + } + + if (currentPage >= 0 && currentPage < section.pageCount) { + return true; + } + + advanceSpine(); + } +} + +void EpubReaderSearchActivity::scanNextPage() { + if (!advanceSpineIfNeeded()) { + return; + } + + 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); + + matcherBeforeChunk = matcher; + auto result = section.scanForward(currentPage, endPage, matcher); + + // 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; + + dropSectionCache(); + + if (!ensureSectionLoaded()) { + return; + } + result = section.scanForward(currentPage, endPage, matcher); + if (result.status == Section::ScanStatus::IoError) { + setFailure(SearchState::Error); + return; + } + } + + 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. + dropSectionCache(); + setFailure(SearchState::Error); + return; + } + + if (result.status == Section::ScanStatus::Match) { + setResult(ProgressChangeResult{currentSpineIndex, result.page, result.matchStartByte, result.matchEndByte}); + finish(); + return; + } + + // NoMatch: advance past the scanned chunk. + currentPage = endPage; +} + +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(); + // 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(); + const unsigned long now = millis(); + if (percent != lastProgressPercent && now - lastProgressRepaintMs >= PROGRESS_REPAINT_MIN_INTERVAL_MS) { + lastProgressPercent = percent; + lastProgressRepaintMs = now; + 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); + // 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); +} diff --git a/src/activities/reader/EpubReaderSearchActivity.h b/src/activities/reader/EpubReaderSearchActivity.h new file mode 100644 index 0000000000..e9f44b8863 --- /dev/null +++ b/src/activities/reader/EpubReaderSearchActivity.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#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}; + } + + // 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); + }; + + 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{}; + 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; + const uint16_t viewportWidth; + const uint16_t viewportHeight; + SearchState state = SearchState::Searching; + bool sectionLoaded = false; + bool sectionCacheRepairAttempted = false; + bool wrapped = false; + + // 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; + // 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 + // loads; progress is (work since start) / route length. scanStartPos is + // negative until captured. + float scanStartPos = -1.0f; + float scanRouteLength = 0.0f; + + bool advanceSpineIfNeeded(); + bool ensureSectionLoaded(); + 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 + // 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/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index 79864e3569..458abc801a 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -1,12 +1,17 @@ #pragma once #include +#include #include #include +#include #include +#include #include +#include "Epub/Page.h" + namespace EpubReaderUtils { struct Progress { @@ -133,4 +138,161 @@ 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; +} + +// 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 +// 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/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/reader/SearchHighlighter.cpp b/src/activities/reader/SearchHighlighter.cpp new file mode 100644 index 0000000000..9fe103ea5c --- /dev/null +++ b/src/activities/reader/SearchHighlighter.cpp @@ -0,0 +1,44 @@ +#include "SearchHighlighter.h" + +#include +#include + +#include "EpubReaderUtils.h" +#include "ReaderUtils.h" + +void SearchHighlighter::drawSearchHighlights(const Page& page, const int fontId, const int orientedMarginTop, + const int orientedMarginLeft, const int matchStartByte, + const int matchEndByte, GfxRenderer& renderer) const { + if (matchStartByte < 0 || matchEndByte < matchStartByte) { + return; + } + + // 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; + } + + // 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 = [firstWord, lastWord](const uint16_t pageWordIndex) { + return pageWordIndex >= firstWord && pageWordIndex <= lastWord; + }; + + 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, foregroundBlack); + renderer.drawText(fontId, wordX, wordY, visibleText, !foregroundBlack, + textStyle); + }); +} diff --git a/src/activities/reader/SearchHighlighter.h b/src/activities/reader/SearchHighlighter.h new file mode 100644 index 0000000000..992fa9dd03 --- /dev/null +++ b/src/activities/reader/SearchHighlighter.h @@ -0,0 +1,20 @@ +#pragma once + +class GfxRenderer; +class Page; + +// 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: + // 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; +}; 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) { 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/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 ) 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..2f02ba799f --- /dev/null +++ b/test/search_matcher/SearchMatcherTest.cpp @@ -0,0 +1,215 @@ +#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_TRUE(SearchMatcher::queriesEquivalent("caf\xC3\xA9", "cafe\xCC\x81")); + EXPECT_FALSE(SearchMatcher::queriesEquivalent("cat", "dog")); +}