diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 36451d6d95..972a8ccfdb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to report this bug! Please fill out the details below. If you have a feature request, please use the [Discussions](https://github.com/uxjulia/CrossInk/discussions/categories/ideas) feature. + Thanks for taking the time to report this bug! Please fill out the details below. If you have a feature request, please use the [Discussions](https://github.com/uxjulia/CrossInk/discussions/categories/ideas) feature so that issues focuses only on immediate bugs. - type: dropdown id: device-type @@ -29,18 +29,6 @@ body: validations: required: true - - type: dropdown - id: build-variant - attributes: - label: Build Variant - description: What build variant are you using? (e.g., tiny, xlarge, no_emoji) - options: - - tiny - - xlarge - - no_emoji - validations: - required: true - - type: dropdown id: epub-state attributes: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f03b49b17..d5645470a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,19 +62,13 @@ jobs: - name: Build CrossInk env: CROSSPOINT_RELEASE_VERSION: ${{ env.RELEASE_VERSION }} - run: pio run -e teensy -e tiny -e xlarge - - - name: Create legacy no_emoji OTA alias - run: | - cp ".pio/build/tiny/firmware-tiny-v${{ env.RELEASE_VERSION }}.bin" \ - ".pio/build/tiny/firmware-no_emoji-v${{ env.RELEASE_VERSION }}.bin" + run: pio run -e tiny -e xlarge - name: Generate release catalog run: | python3 scripts/generate_release_catalog.py \ --version "${{ env.RELEASE_VERSION }}" \ --repo "${{ github.repository }}" \ - --firmware ".pio/build/teensy/firmware-teensy-v${{ env.RELEASE_VERSION }}.bin" \ --firmware ".pio/build/tiny/firmware-tiny-v${{ env.RELEASE_VERSION }}.bin" \ --firmware ".pio/build/xlarge/firmware-xlarge-v${{ env.RELEASE_VERSION }}.bin" \ --output ".pio/build/catalog" @@ -119,7 +113,5 @@ jobs: target_commitish: ${{ github.sha }} draft: true files: | - .pio/build/teensy/firmware-teensy-v${{ env.RELEASE_VERSION }}.bin .pio/build/tiny/firmware-tiny-v${{ env.RELEASE_VERSION }}.bin - .pio/build/tiny/firmware-no_emoji-v${{ env.RELEASE_VERSION }}.bin .pio/build/xlarge/firmware-xlarge-v${{ env.RELEASE_VERSION }}.bin diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index 26c8bd496b..3b3a30ad79 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -102,6 +102,5 @@ jobs: draft: true prerelease: true files: | - .pio/build/teensy/firmware-teensy-${{ env.RC_BRANCH_SAFE }}-${{ env.RC_HASH_SHORT }}-RC.bin .pio/build/tiny/firmware-tiny-${{ env.RC_BRANCH_SAFE }}-${{ env.RC_HASH_SHORT }}-RC.bin .pio/build/xlarge/firmware-xlarge-${{ env.RC_BRANCH_SAFE }}-${{ env.RC_HASH_SHORT }}-RC.bin diff --git a/.gitignore b/.gitignore index 35e0dd39ab..d4b038512b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ lib/EpdFont/scripts/output/ /site/.astro/ /site/public/images/ /site/public/catalog -/site/public/CNAME \ No newline at end of file +/site/public/CNAME + +dist-publish/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 80308f0565..7c0ed75cb8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ -[submodule "open-x4-sdk"] - path = open-x4-sdk - url = https://github.com/crosspoint-reader/community-sdk.git +[submodule "freeink-sdk"] + path = freeink-sdk + url = https://github.com/Free-Ink/freeink-sdk.git + branch = main diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cc302aa4..a32295ee36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,55 @@ -# Changelog +## [v1.4.0] - 2026-07-10 + +### Added + +- Dashboard UI theme for the Home screen, showing the current book cover and reading stats. +- Nearby Position Sync for sending or applying the current EPUB position between two CrossInk devices over ESP-NOW. +- Web EPUB optimizer support for CrossInk location metadata, so optimized EPUBs can keep better progress and stable page numbers. +- Reading Stats support for XTC and XTCH books, including reader menus, Home and sleep screen stats, mark finished, delete stats, and preserving stats when clearing book caches. +- Web file manager image previews, so PNG, JPEG, BMP, GIF, and WebP files can be viewed inline before downloading. + +### Changed + +- Large EPUBs, SD-card font-heavy books, and cover thumbnails now open, index, and generate more reliably under low-memory conditions. +- Home and sleep screens now load more cover and thumbnail data only when needed, reducing reader startup work and reusing cached cover data where possible. +- Built-in reader font choices have been reduced to Lexend Deca and Bitter, reducing firmware size while keeping fallback glyph coverage. + +### Removed + +- Teensy firmware builds are no longer produced for releases or release candidates. + +### Fixed + +- EPUB render-mode and Safe Mode toast messages now clear reliably, even when the reader is low on memory. +- EPUB Reading Stats no longer drops unsaved page-turn counts after viewing the stats screen mid-session. +- KOSync is more reliable with many SD-card fonts installed, reducing low-memory failures during secure sync requests and uploads. +- Web file manager actions now handle filenames with special characters safely and reject unsafe rename characters before saving. +- Auto Turn interval settings and related action prompts opened from long-press shortcuts now stay open after releasing the shortcut button. +- EPUB footnote previews no longer show clipped status-bar labels or misleading reader progress indicators, and clipping selection now works from footnote previews. +- Font selection no longer reopens the font preview after choosing a font. +- EPUB chapters with stale publisher style data now rebuild it instead of opening without the book's styling. +- Large SD-card font EPUBs no longer overlap characters after font or line-spacing changes, and clipping selection can fall back to a built-in UI font when needed. +- EPUB cover and thumbnail generation is more reliable with custom SD-card fonts selected and optimized books under low-memory conditions. +- Web EPUB optimizer now preserves more PNG and SVG artwork on-device, including transparent PNGs, dividers, and images in malformed or XML-declared chapters. +- Unsupported SVG images in EPUB chapters are now skipped silently instead of triggering low-memory image warnings. +- Nearby Position Sync now silently restarts back into the reader after using ESP-NOW, matching other WiFi sync flows and reducing post-sync memory fragmentation. +- EPUB page cache loading now uses fewer small heap allocations, reducing fragmentation-related reader failures. +- EPUB grayscale page turns on X3 now use the grayscale-aware display base, reducing the moment where new text appears too dark before the anti-aliased overlay finishes. +- EPUB chapters with many inline anchors, footnote links, malformed XHTML, large publisher styles, or SD-card fonts are less likely to fail or get stuck on the indexing screen. +- EPUB opening and image rendering now recover from more low-memory conditions instead of rebooting, including landscape image pages and books that need lighter render modes. +- EPUB clipping selection now follows right-to-left line order when selecting Hebrew and other RTL text. +- Lyra Carousel no longer shows a blank carousel after returning from WiFi-related File Transfer screens and moving between the menu row and book row. +- Generated SD-card font packages now include the same core glyph coverage as built-in reader fonts. +- Manage Fonts no longer crashes while loading or reloading large SD-card font lists. +- Minimal Home no longer swaps to another recent book when returning from Settings when Back button is mapped to the first button. +- Cancelling a font download now stops on the first Cancel button press instead of needing several presses. +- The `Inverted` sleep cover filter now keeps book covers unchanged on Minimal and Dashboard sleep screens while switching the background to white. +- Rare EPUB open or thumbnail crashes during ZIP decompression are fixed. + ## [v1.3.4] - 2026-06-24 ### Added + - File Browser now indexes large SD-card folders so directories with many books can be browsed without loading every filename into memory at once. - EPUB text clipping with saved highlights, clipping lists, and Kindle-style `/My Clippings.txt` export. - `Create Clipping` is now available as a reader shortcut for short/long Power, long-press Menu, and long-press Back actions. @@ -11,10 +59,12 @@ - X3 SD-card writes now use the RTC for file timestamps when the clock is available. ### Changed + - The EPUB reader menu now splits the growing menu into 3 screens, labels per-book settings as `Book Options`, and avoids showing duplicate `Orientation` controls. - The `Inverted` sleep cover filter now flips Minimal and Reading Stats sleep screens to black text on a white background. ### Fixed + - Calibre Wireless transfer status no longer stacks the last received-file message on top of the upload percentage. - X3 Tilt Direction now labels left/right choices as `Left-Right` and `Right-Left`, with existing left/right preferences migrated to keep the same physical tilt behavior. - EPUB layout now honors publisher page-break CSS, avoids stretching justified spaces before closing punctuation, and keeps large CSS rule sets in a smaller disk-backed lookup cache. @@ -41,6 +91,7 @@ ## [v1.3.3] - 2026-06-13 ### Added + - `File Browser Display` in `Settings > System > Files & Cache` for choosing one-line or two-line file browser rows across all themes, while preserving Minimal users' existing two-line display on upgrade. - `Hide File Extension` in `Settings > System > Files & Cache` for expanding file-browser filenames by hiding the right-side extension label. - Device Name in Settings > System > Device for customizing the KOReader Sync and Nearby Stats Sync device label. @@ -48,10 +99,12 @@ - Delete Reading Stats actions in the EPUB reader and book action menus for clearing one book's stats without deleting its cache. ### Changed + - CrossInk settings now save to `/.crosspoint/crossink-settings.json`, with a one-time fallback migration from `/.crosspoint/settings.json`, so switching between firmware builds is less likely to reset preferences. - The X3 clock visibility setting is now phrased as `Hide Clock`, with existing `Show Clock` preferences migrated to the matching hide behavior. ### Fixed + - RoundedRaff's date shown in settings now sits lower on X3 devices instead of overlapping the battery. - Clear Bookmark List now asks for confirmation before deleting a book's bookmarks. - Clear Reading Cache now preserves per-book reading stats while continuing to leave all-time reading stats untouched. @@ -71,6 +124,7 @@ ## [v1.3.2] - 2026-06-10 ### Added + - Current date in the top-right Settings header on X3 devices. - Dark Reader Mode for EPUB and TXT reading screens, plus shortcut actions for the power button and front-button long press. - File Browser long-press folder action for choosing a custom sleep-image folder instead of only `/.sleep` or `/sleep`. @@ -78,6 +132,7 @@ - `Reset Reading Pace` in the EPUB reader menu when Time Left is enabled, for clearing only the time-left pace estimate while keeping book reading stats. ### Changed + - Display, Reader, and Controls settings now open list menus instead of cycling through options one by one. - Reading time and time-left pace tracking now ignore page intervals longer than the configured idle-time threshold. - Web portal pages now use shared templates, stylesheet, and logo assets, reducing on-device page size and improving browser caching. @@ -85,6 +140,7 @@ - Reader font-size choices now show point sizes like `10 pt` instead of names like `Tiny`. ### Fixed + - Inverted reader menus now honor orientation-aware side-button navigation. - EPUB book time-left estimates now wait for more session pace samples and use a progress-based floor after pace data exists, reducing swings from unusually short or long pages. - Deleting an EPUB book cache now preserves that book's reading stats and pace data. @@ -99,12 +155,14 @@ ## [v1.3.1] - 2026-05-28 ### Added + - EPUB reading-position improvements, including bookmark anchors, bookmark preview snippets, and optional chapter/book time-left estimates. - Nearby Reading Stats sync with separate totals for this device and all synced CrossInk readers. - Per-server OPDS filename settings so downloaded books can use either Author - Title or Title - Author. - EPUB render heap diagnostics that include the largest allocatable block, not just total free heap. ### Changed + - Moved the X3 reader clock into a new top-centered status bar and moved clock settings to Settings > System > Device. - Reworked Display, Reader, Controls, in-reader options, and larger System settings groups so related options open as submenus. - Improved OPDS and font download responsiveness by reducing progress-update overhead and temporarily disabling WiFi power saving during transfers. @@ -112,6 +170,7 @@ - Delayed the automatic finished-book prompt until the reader leaves the chapter where they reach 99%. ### Fixed + - WiFi settings screen now keeps the displayed MAC address consistent with the router-visible WiFi address. - Reader UI issues with inverted menu button hints, Lyra Carousel popups, and Auto Page Turn interval persistence. - Web uploads and KOReader Sync progress saves now preserve progress, stats, settings, and valid resume data for refreshed book files. @@ -123,6 +182,7 @@ ## [v1.3.0] - 2026-05-21 ### Added + - Back/Cancel support while downloading books from OPDS catalogs. - Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. - Minimal sleep screen option that shows the current book cover and reading progress on a dark background. @@ -131,6 +191,7 @@ - In-reader confirmation message when a shortcut turns tilt-to-turn on or off. ### Fixed + - WiFi and OPDS connection-flow edge cases: manual Settings connections now show the connected status before continuing, copied or corrupted saved-password files are rejected before use, OPDS retries show loading before requests, and large OPDS feeds fail safely under low memory instead of rebooting. - Reader and Home UI polish issues, including landscape status-bar settings, missing Vietnamese labels, File Browser and Lyra Carousel icon alignment, cover thumbnail artifacts, and duplicate Home progress/stat loading. - EPUB cache and low-memory handling now use stable cache folder keys, migrate older cache folders where possible, rebuild stale section caches, lay out very long text blocks earlier, stream table fallback content when heap is tight, and clarify the warning text. @@ -138,20 +199,26 @@ - Remaining reader service edge cases, including an XTC chapter selector crash on memory-constrained builds, SD-card font size selection, SD-card font-size shortcuts skipping manually installed sizes, and KOReader Sync login compatibility with self-hosted servers that return valid JSON on success. ### Changed + - Modified upstream "page-as-sleep" behavior into a new `Sleep Screen > Quick Resume` option, which also keeps `Quick Resume on Timeout` on, and renamed the timeout-only toggle. - Improved reader and browser menu behavior by moving the Footnotes shortcut above Select Chapter, wrapping long book titles in action menus, and reducing progress-screen repaint work during OPDS and SD font downloads. ## [v1.2.11.1] - 2026-05-15 ### Changed + - Removed Medium font size from `xlarge` build to get it below the size limit ### Fixed + - Lyra Carousel is now included by activating the build flag `DCROSSINK_ENABLE_LYRA_CAROUSEL=1` + --- + ## [v1.2.11] - 2026-05-14 ### Added + - New personal theme: "Minimal" - Custom sleep timer picker so `Time to Sleep` can be set from 1 to 30 minutes instead of cycling fixed presets. - In-reader Controls shortcut for customizing buttons without leaving the book. @@ -162,12 +229,14 @@ - File Browser long-press action for marking EPUB books as finished or unfinished. ### Changed + - Hardened deep sleep entry by shutting WiFi down before waiting for the power button to be released. - Raised the web file-transfer filename limit from 100 to 150 bytes so longer uploaded filenames are preserved. - Made the in-reader Reader Options menu include the same Reader settings and actions as Settings > Reader. - Split SD-card font descriptions and supported languages into separate lines in the font download screen. ### Fixed + - Inline EPUB images no longer disappear in landscape when their bottom edge slightly overlaps the screen margin. - Reduced unnecessary low-memory image suppression for JPEG-heavy EPUB chapters and added CSS heap diagnostics during chapter rebuilds. - Allowed wider inline JPEG images in EPUBs to render when they still fit the total pixel and heap safety limits. @@ -178,6 +247,7 @@ ## [v1.2.10] - 2026-05-11 ### Added + - `Recent Books View` setting so the dedicated Recent Books screen can switch between the classic list and a 3x3 cover grid. - More flexible reader controls, including orientation-aware front/side button settings, nav-only or all-button front inversion, tilt page turn shortcuts, and side-button long-press rotation actions. - Per-session auto page turn interval picker with values from 5 to 120 seconds. @@ -187,12 +257,14 @@ - Simulator tools for testing sleep/wake behavior and smoke-testing common screens and EPUB reader menus. ### Changed + - Reduced Controls settings section spacing so the grouped controls fit better on X3 screens. - Made front reader long-press actions trigger when the hold delay is reached while normal page turns still trigger on release. - Used the fast EPUB spine/TOC indexing path for books with 300+ spine entries so heavily split books build `book.bin` faster on first open. - Allowed the web file manager and WebDAV to browse dot-prefixed hidden files when hidden files are enabled, matching the device file browser. ### Fixed + - Reader button and shortcut behavior, including X3 power-button wake filtering, folder delete long-press timing, and WiFi scan/connect screens that could not be exited while work was in progress. - RoundedRaff home-menu, keyboard, and button-hint rendering issues so Settings remains reachable and compact labels no longer overlap or disappear. - Font and glyph handling now reduces persistent SD-card font advance-cache memory, releases optional font caches before image extraction only when heap is tight, and shows a visible replacement symbol when compact UI fonts lack `U+FFFD`. @@ -205,14 +277,18 @@ - Concurrent render/storage crashes are avoided by serializing `GfxRenderer` scratch-buffer access, shared SPI bus access, and failed SPI lock cleanup. - Recent Books, EPUB/XTC thumbnail caches, deleted-folder metadata, and XTC cover scaling now keep cached book data in sync and grid covers fill their slots correctly. - Simulator build configuration now lets SDL2 and simulator-provided network/OTA shims compile cleanly. + --- + ## [v1.2.9.1] - 2026-05-03 ### Changed + - Cleaned up EPUB table rendering by removing synthetic row/cell labels and defaulting table cells to readable left alignment - Allow simple EPUB tables with full-width note rows so a single `colspan` cell spanning the whole table no longer forces the entire table back to paragraph fallback ### Fixed + - Power-button shortcut conflicts outside the reader so reader-only actions fall back to `Confirm` while Sleep, Refresh, Screenshot, Sync Progress, and File Transfer remain real power actions. - Potential crash when using `Go to %` in EPUBs. - Potential crash when entering sleep with Page Overlay enabled if the cached EPUB page data is invalid. diff --git a/README.md b/README.md index 90f7e6e1fc..6fe4e7977b 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ My goal with this fork was to maintain the core Crosspoint firmware while integr ### Highlights -- New reader fonts: ChareInk, Lexend Deca, and Bitter. +- New reader fonts: Lexend Deca and Bitter. - Unicode emoji and miscellaneous symbols support (a limited subset). - Adjusted font sizes: 8 pt, 9 pt, 10 pt, 12 pt, 14 pt, 16 pt, 18 pt, and 20 pt. See [Font Build Variants](./docs/font-build-variants.md) for more details. - Added ~~strikethrough~~ support. - Made underlines thicker for better visibility. - Added a custom `Minimal` theme and sleep screen option for the minimalists out there. +- Added a custom `Dashboard` theme and sleep screen option for reading stats enthusiasts. - Added support for `
` section breaks. - Added support for "redaction" style rendering. - Added improved support for tables with simple markup. @@ -42,7 +43,8 @@ My goal with this fork was to maintain the core Crosspoint firmware while integr - Added ability to move finished books to "Read" folder. - In-book menu to quickly adjust reader options without having to exit the book. - Reading stats: total books read, total reading time, number of sessions, pages turned, average session time, pages turned per minute. You can also set your reading stats as your sleep screen. -- Reading stats [syncing](./docs/reading-stats-sync.md) between two devices. +- All-time reading stats [syncing](./docs/reading-stats-sync.md) between two CrossInk devices. +- Reading [progress sync](./docs/nearby-position-sync.md) between two CrossInk devices. - Added customizable Auto Page Turn Interval (anything between 5-120 seconds). - Added ability to view Recent Books as a 3x3 grid view. - To view a more detailed list for each version, visit the [releases](https://github.com/uxjulia/CrossInk/releases) page to read release notes. @@ -51,9 +53,8 @@ My goal with this fork was to maintain the core Crosspoint firmware while integr ### Reader Fonts -The default fonts have been replaced with ChareInk, Lexend Deca, and Bitter. These fonts have been chosen specifically to improve reading fluency and e-ink performance. These 'sturdier' typefaces feature uniform stroke weights and open geometries, allowing the X4/X3 to render crisp, high-contrast text with font-aliasing on while significantly reducing ghosting and artifacts. +The default fonts have been replaced with Lexend Deca and Bitter. These fonts have been chosen specifically to improve reading fluency and e-ink performance. These 'sturdier' typefaces feature uniform stroke weights and open geometries, allowing the X4/X3 to render crisp, high-contrast text with font-aliasing on while significantly reducing ghosting and artifacts. -- [ChareInk](https://www.mobileread.com/forums/showthread.php?t=184056) - A cult favorite among the e-reading community for over a decade based off of the typeface [Charis](https://software.sil.org/charis/). It is specially designed to make long texts pleasant and easy to read. - [Lexend Deca](https://fonts.google.com/specimen/Lexend+Deca) - A research-backed sans-serif typeface designed to improve reading fluency. Lexend was engineered based on the theory that reading issues are often a design problem (visual crowding) rather than a cognitive one. - [Bitter](https://fonts.google.com/specimen/Bitter) - A "contemporary" slab serif typeface for text, it is specially designed for comfortably reading on digital screens. The consistent stroke weight of Bitter helps it render particularly well on e-ink devices. The medium weight has been chosen specifically for improved rendering on the X4/X3. @@ -67,7 +68,7 @@ The UI now uses [Inter](https://fonts.google.com/specimen/Inter) as the display ### Font Sizes -There are 3 available build variants to choose from due to build size constraints: `teensy`, `tiny`, and `xlarge`. +There are 2 available build variants to choose from due to build size constraints: `tiny`, and `xlarge`. See [Font Build Variants](./docs/font-build-variants.md) for the full point-size and emoji-support matrix. @@ -89,7 +90,7 @@ See [Controls](./docs/controls.md) for the full action list and defaults. CrossInk runs on an ESP32-C3 with limited RAM, so very large folders or complex EPUBs can be slower than they would be on a phone, tablet, or desktop app. -- Keep folders under about 200 files. For the smoothest browsing, aim for 50-100 files per folder. +- Keep folders under about 200 files. For the smoothest browsing, aim for 50-100 files per folder. - Having 1000+ books on the SD card is fine if they are split into smaller folders, such as by author, series, genre, or read/unread status. - Avoid putting every book in the SD card root. The file browser has to scan and sort the current folder before it can show it. - Text-first EPUBs are the best fit. Large image-heavy EPUBs, scanned books, comics, and omnibus files with thousands of sections may load slowly or fail under memory pressure. @@ -104,6 +105,7 @@ The [device simulator](https://github.com/uxjulia/crosspoint-simulator) renders See [Simulator](./docs/simulator.md) for setup, platform notes, keyboard controls, and cache tips. --- + ## Installation Download a `firmware-*.bin` from the [releases page](https://github.com/uxjulia/CrossInk/releases), then flash it with the web installer or command line. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e81de5c45..829f9241ca 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -5,6 +5,7 @@ Welcome to the **CrossInk** firmware. This guide outlines the hardware controls, - [CrossInk User Guide](#CrossInk-user-guide) - [1. Hardware Overview](#1-hardware-overview) - [Button Layout](#button-layout) + - [Taking a Screenshot](#taking-a-screenshot) - [2. Power \& Startup](#2-power--startup) - [Power On / Off](#power-on--off) - [First Launch](#first-launch) @@ -14,7 +15,11 @@ Welcome to the **CrossInk** firmware. This guide outlines the hardware controls, - [3.3 Browse Files Screen](#33-browse-files-screen) - [3.4 Recent Books Screen](#34-recent-books-screen) - [3.5 File Transfer Screen](#35-file-transfer-screen) - - [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers) + - [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers) + - [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre) + - [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre) + - [Uploading Books](#uploading-books) + - [Removing a Book](#removing-a-book) - [3.6 Settings](#36-settings) - [3.6.1 Display](#361-display) - [3.6.2 Reader](#362-reader) @@ -23,19 +28,23 @@ Welcome to the **CrossInk** firmware. This guide outlines the hardware controls, - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) - [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds) - [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup) + - [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks) + - [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose) - [3.7 Sleep Screen](#37-sleep-screen) + - [Cover settings](#cover-settings) + - [Custom images](#custom-images) - [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card) - [4. Reading Mode](#4-reading-mode) - - [Page Turning](#page-turning) - - [Chapter Navigation](#chapter-navigation) - - [Auto Page Turn](#auto-page-turn) - - [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only) - - [Footnote Navigation](#footnote-navigation) - - [System Navigation](#system-navigation) - - [Supported Languages](#supported-languages) + - [Page Turning](#page-turning) + - [Chapter Navigation](#chapter-navigation) + - [Auto Page Turn](#auto-page-turn) + - [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only) + - [Footnote Navigation](#footnote-navigation) + - [System Navigation](#system-navigation) + - [Supported Languages](#supported-languages) - [5. Reader Menu](#5-reader-menu) - - [5.1 Chapter Selection](#51-chapter-selection) - - [5.2 Bookmarks](#52-bookmarks) + - [5.1 Chapter Selection](#51-chapter-selection) + - [5.2 Bookmarks](#52-bookmarks) - [6. Current Limitations & Roadmap](#6-current-limitations--roadmap) - [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop) @@ -92,10 +101,10 @@ See [Reading Mode](#4-reading-mode) below for more information. The Browse Files screen acts as a file and folder browser. The full path to the current directory is shown at the top of the screen. File extensions are displayed alongside each filename, and directories are shown with brackets (e.g. `[folder-name]`). Hidden directories can be shown from settings. -* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. -* **Open Selection:** Press **Confirm** to open a folder or start reading a selected book. Selecting a `.bmp` file will open the image viewer. -* **Delete Files or Folders:** Hold and release **Confirm** to open the selected file or folder action menu, then choose **Delete**. You will be given an option to either confirm or cancel. Folder deletion is limited to empty folders. -* **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache** or **Mark Finished** from the same action menu. +- **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. +- **Open Selection:** Press **Confirm** to open a folder or start reading a selected book. Selecting a `.bmp` file will open the image viewer. +- **Delete Files or Folders:** Hold and release **Confirm** to open the selected file or folder action menu, then choose **Delete**. You will be given an option to either confirm or cancel. Folder deletion is limited to empty folders. +- **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache** or **Mark Finished** from the same action menu. ### 3.4 Recent Books Screen @@ -123,7 +132,6 @@ A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined CrossInk supports sending books from Calibre using the CrossPoint Reader device plugin. 1. Install the plugin in Calibre: - - Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. - Download the zip file. @@ -143,7 +151,6 @@ The Settings screen allows you to configure the device's behavior. There are a f #### 3.6.1 Display - **Sleep Screen**: Which sleep screen to display when the device sleeps: - - "Dark" (default) - The default dark CrossInk logo sleep screen - "Light" - The same default sleep screen, on a white background - "Custom" - Custom images from the SD card; see [Sleep Screen](#37-sleep-screen) below for more information @@ -157,18 +164,17 @@ The Settings screen allows you to configure the device's behavior. There are a f - "Quick Resume" - Keeps the current content visible while sleeping - **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: - - "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary - "Crop" - Scale the image down and crop as necessary to try to fill the screen (Note: this is experimental and may not work as expected) - **Sleep Screen Cover Filter**: What filter will be applied to the book cover when "Cover" sleep screen is selected: - - "None" (default) - The cover image will be converted to a grayscale image and displayed as it is - "Contrast" - The image will be displayed as a black & white image without grayscale conversion - "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion -- **Hide Battery %**: Configure where to suppress the battery percentage display in the status bar; the battery icon will still be shown: +- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled. +- **Hide Battery %**: Configure where to suppress the battery percentage display in the status bar; the battery icon will still be shown: - "Never" (default) - Always show battery percentage - "In Reader" - Show battery percentage everywhere except in reading mode - "Always" - Always hide battery percentage @@ -176,7 +182,6 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting; options are every 1, 5, 10, 15, or 30 pages. - **UI Theme**: Set which UI theme to use: - - "Classic" - The original Crosspoint theme - "Minimal" - A minimal theme with a large book cover - "Lyra" - A theme with simple icons featuring your current book @@ -187,7 +192,6 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Recent Books View**: Choose whether the Recent Books screen uses a list or grid layout. - **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight: - - "OFF" (default) - Disable the fix - "ON" - Enable the fix @@ -197,10 +201,8 @@ The Settings screen allows you to configure the device's behavior. There are a f #### 3.6.2 Reader - **Reader Font Family**: Choose the font used for reading: - - "Lexend Deca" (default) - "Bitter" - - "ChareInk" - **Reader Font Size**: Adjust the text size for reading. Available sizes depend on the firmware variant and can include sizes ranging from 8pt to 20pt. @@ -215,14 +217,12 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". - **Reading Orientation**: Set the screen orientation for reading EPUB files: - - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down - "Landscape CCW" - Landscape, rotated counter-clockwise - **Extra Paragraph Spacing**: Set how to handle paragraph breaks: - - "ON" - Vertical space will be added between paragraphs in Reading Mode - "OFF" - Paragraphs will not have vertical space added, but will have first-line indentation @@ -234,6 +234,16 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Guide Dots**: Adds guide dots between words; options are "ON" or "OFF" (default). +- **Customise Status Bar**: Configure the status bar displayed while reading: + - Chapter Page Count - Show/Hide the current page in the chapter (ex: 5/25). Page count may change based on the font size and margins set. + - Book Progress Percentage - Show/Hide the current percent progress in the book. + - Progress Bar - Show/Hide a progress bar for either the book or chapter. + - Progress Bar Thickness - Set the thickness of the progress bar + - Title - Display the chapter or book title + - Time Left - Display the estimated reading time left for the book or chapter + - Battery - Show/Hide the battery indicator + - XTC Status Bar - Show/Hide a status bar for XTC files + #### 3.6.3 Controls - **Power Button**: Configure short-press and long-press power button actions. @@ -249,7 +259,6 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Side Button Long-press Action**: Set whether long-pressing side buttons does nothing, skips chapters, changes font size, or changes orientation. - **Short-press Action / Long-press Action**: Controls the effect of a short or long press of the power button: - - "Ignore" (default) - Require a long press to turn off the device - "Sleep" - A short press puts the device into sleep mode - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off @@ -283,7 +292,6 @@ CrossInk supports saving multiple OPDS servers and switching between them when b 2. Select **Add Server** to create a new entry, or select an existing server to edit it. 3. Configure these fields: - - **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog"). - **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`). @@ -345,7 +353,6 @@ Already have KOReader Sync credentials? Skip registration; basic sync only requi When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account. 2. On each device: - - Go to **Settings -> System -> KOReader Sync**. - Set **Username** and **Password** (enter the plain password; CrossInk computes MD5 internally, and use the same values on all devices). @@ -355,7 +362,6 @@ When this returns `HTTP 402` with `{"code":2002,"message":"Username is already r - Run **Authenticate**. 3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**. - - Choose **Apply Remote** to jump to remote progress. - Choose **Upload Local** to push current progress. @@ -422,7 +428,6 @@ curl -i "http://:17200/users/create" \ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, the account already exists. 4. On each device: - - Go to **Settings -> System -> KOReader Sync**. - Set **Username** and **Password** (enter the plain password; CrossInk computes MD5 internally, and use the same values on all devices). @@ -434,7 +439,6 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg If you use the HTTPS listener, use `https://:7200` (`curl -k` only for self-signed certificate testing). 5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**. - - Choose **Apply Remote** to jump to remote progress. - Choose **Upload Local** to push current progress. @@ -445,8 +449,8 @@ The **Sleep Screen** setting controls what is displayed when the device goes to | Mode | Behavior | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| **Dark** (default) | The CrossInk logo on a dark background. | -| **Light** | The CrossInk logo on a white background. | +| **Dark** (default) | The CrossInk logo on a dark background. | +| **Light** | The CrossInk logo on a white background. | | **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. | | **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. | | **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | @@ -482,7 +486,7 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover + ### 3.8 Custom Fonts (SD Card) -CrossInk supports loading additional fonts from the SD card, extending beyond the built-in Lexend Deca, Bitter, and ChareInk families. Custom fonts can include extended Unicode coverage, enabling CJK (Chinese, Japanese, Korean) and other scripts. +CrossInk supports loading additional fonts from the SD card, extending beyond the built-in Lexend Deca and Bitter families. Custom fonts can include extended Unicode coverage, enabling CJK (Chinese, Japanese, Korean) and other scripts. There are three ways to install fonts: @@ -513,8 +517,8 @@ If the **Short-press Action** setting is set to "Page Turn", you can also turn t ### Chapter Navigation -* **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. -* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. +- **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. +- **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. This feature can be disabled in the **[Controls Settings](#363-controls)** to help avoid changing chapters by mistake. @@ -534,17 +538,17 @@ If the device goes to sleep or you close the book while viewing a footnote, the ### System Navigation -* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. -* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen. -* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more. +- **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. +- **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen. +- **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more. ### Supported Languages CrossInk renders text using the following Unicode character blocks, enabling support for a wide range of languages: -* **Latin Script (Basic, Supplement, Extended-A/B):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, Catalan, and others. -* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. -* **Vietnamese:** Supported via extended Latin glyph coverage in the built-in reader fonts. +- **Latin Script (Basic, Supplement, Extended-A/B):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, Catalan, and others. +- **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. +- **Vietnamese:** Supported via extended Latin glyph coverage in the built-in reader fonts. What is not supported with built-in reader fonts: Chinese, Japanese, Korean, Arabic, Greek, Hebrew, and Farsi. However, **CJK, Hebrew, Greek, and other extended scripts can be enabled by installing custom SD card fonts** — see [Custom Fonts (SD Card)](#38-custom-fonts-sd-card). @@ -557,7 +561,7 @@ Press **Confirm** while reading to open the Reader Menu. From here you can acces Available options include: - **Select Chapter** – Open the table of contents to jump to a specific chapter (see [Chapter Selection](#51-chapter-selection) below). -- **Footnotes** – Navigate to the footnotes for the current section *(only shown in books that contain footnotes)*. +- **Footnotes** – Navigate to the footnotes for the current section _(only shown in books that contain footnotes)_. - **Reader Options** – Open reader-specific options without leaving the book. - **Controls** – Open reader control options without leaving the book. - **Reading Orientation** – Cycle through screen orientations without leaving the reader. @@ -580,7 +584,7 @@ Accessible by selecting **Chapters** from the Reader Menu. 1. Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to highlight the desired chapter. 2. Press **Confirm** to jump to that chapter. -3. *Alternatively, press **Back** to cancel and return to your current page.* +3. _Alternatively, press **Back** to cancel and return to your current page._ --- @@ -598,9 +602,9 @@ Bookmarks are stored as per-book `.bin` files in the `.crosspoint/bookmarks` fol Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: -* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up. -* **Unsupported Image Formats:** Most JPG and PNG images in EPUBs render correctly. GIFs and progressive JPEGs are not supported and will fall back to an `[Image]` placeholder. -* **Dictionary Lookup:** Inline word lookup is not yet implemented. +- **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up. +- **Unsupported Image Formats:** Most JPG and PNG images in EPUBs render correctly. GIFs and progressive JPEGs are not supported and will fall back to an `[Image]` placeholder. +- **Dictionary Lookup:** Inline word lookup is not yet implemented. --- diff --git a/bin/clang-format-fix.ps1 b/bin/clang-format-fix.ps1 index f90a81b730..28e9394c16 100644 --- a/bin/clang-format-fix.ps1 +++ b/bin/clang-format-fix.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Formats all C/C++ source and header files in the repository, excluding - generated, vendored, and build directories (open-x4-sdk, builtinFonts, + generated, vendored, and build directories (freeink-sdk, builtinFonts, hyphenation tries, uzlib, .pio, *.generated.h). The clang-format binary path is resolved once and cached in @@ -92,7 +92,7 @@ function Resolve-ClangFormat { $clangFormat = Resolve-ClangFormat $exclude = @( - 'open-x4-sdk' + 'freeink-sdk' 'lib\EpdFont\builtinFonts' 'lib\Epub\Epub\hyphenation\generated' 'lib\uzlib' diff --git a/docs/catalog b/docs/catalog index 2b51e39294..0f1d8f2740 100644 --- a/docs/catalog +++ b/docs/catalog @@ -9,7 +9,7 @@ "variant": "teensy", "released_at": "2026-06-25T01:54:42Z", "notes": "CrossInk 1.3.4 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-teensy-v1.3.4.bin", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/download/v1.3.4/firmware-teensy-v1.3.4.bin", "firmware_sha256": "75632380902bfe6ea5a5d43b0ac5fb047ecc6b00c2ce0d846ebc7ae48f1498ed", "size": 5924080, "supported_devices": [ @@ -25,7 +25,7 @@ "variant": "tiny", "released_at": "2026-06-25T01:54:42Z", "notes": "CrossInk 1.3.4 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-tiny-v1.3.4.bin", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/download/v1.3.4/firmware-tiny-v1.3.4.bin", "firmware_sha256": "15f593dd525d3a69782bba52e0243d650f0f34b9a6dbf8ceb23d1db3af0e1b37", "size": 6357968, "supported_devices": [ @@ -41,7 +41,7 @@ "variant": "xlarge", "released_at": "2026-06-25T01:54:42Z", "notes": "CrossInk 1.3.4 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-xlarge-v1.3.4.bin", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/download/v1.3.4/firmware-xlarge-v1.3.4.bin", "firmware_sha256": "add0e6bc744948e95ea794d711e5d78906281bb6188508309a5aac515eccaa89", "size": 6206608, "supported_devices": [ diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 6c9de5f758..7e58154cab 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite ```mermaid graph TD - A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk] + A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk] B --> C[lib/hal wrappers] C --> D[src/main.cpp runtime loop] D --> E[Activities layer] @@ -183,15 +183,36 @@ Some sources are generated and should not be edited manually. When editing related source assets, regenerate via normal build steps/scripts. +### Web portal preview + +When editing the web portal under `web/`, use the local preview server to see +the composed pages without flashing a device: + +```sh +python3 scripts/preview_web.py +``` + +The preview is available at `http://localhost:8000`. Pass a port number to use a +different port: + +```sh +python3 scripts/preview_web.py 9000 +``` + +The preview server re-renders `web/templates/base.html`, `web/pages/*`, and +shared assets on refresh, and it serves mock API responses for Files, Settings, +Fonts, Wi-Fi, and OPDS data. It is for browser iteration only; firmware builds +still use `scripts/build_web.py` to generate the flash-served headers. + ## Key directories - `src/`: app orchestration, settings/state, and activity implementations - `src/network/`: web server and OTA/update networking - `src/components/`: theming and shared UI components -- `lib/hal/`: hardware abstraction wrappers around open-x4-sdk +- `lib/hal/`: hardware abstraction wrappers around freeink-sdk - `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation - `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.) -- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery) +- `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs - `docs/`: user and technical documentation ## Embedded constraints that shape design diff --git a/docs/contributing/development-workflow.md b/docs/contributing/development-workflow.md index e7e33e32f9..0dd33ade40 100644 --- a/docs/contributing/development-workflow.md +++ b/docs/contributing/development-workflow.md @@ -34,7 +34,7 @@ pio run -e default CI enforces formatting, static analysis, and the primary firmware build. Use clang-format 21+ locally to match CI. If `clang-format` is missing or too old locally, see [Getting Started](./getting-started.md). -Run plain `pio run` before larger PRs to build the release variants (`teensy`, `tiny`, and `xlarge`). +Run plain `pio run` before larger PRs to build the release variants (`tiny` and `xlarge`). ## 4) Open the PR diff --git a/docs/contributing/getting-started.md b/docs/contributing/getting-started.md index 44029d9c4b..7c77b809b8 100644 --- a/docs/contributing/getting-started.md +++ b/docs/contributing/getting-started.md @@ -73,7 +73,7 @@ pio run -e simulator pio run -e default ``` -`pio run` without an environment builds the release variants listed in `platformio.ini`: `teensy`, `tiny`, and `xlarge`. +`pio run` without an environment builds the release variants listed in `platformio.ini`: `tiny` and `xlarge`. ## Flash diff --git a/docs/contributing/testing-debugging.md b/docs/contributing/testing-debugging.md index 9b82518642..bcd5b1dbca 100644 --- a/docs/contributing/testing-debugging.md +++ b/docs/contributing/testing-debugging.md @@ -20,7 +20,7 @@ pio run -e simulator pio run -e default ``` -`pio run` without `-e` builds the release matrix from `platformio.ini` (`teensy`, `tiny`, and `xlarge`). Use it before opening broad firmware PRs, but prefer explicit environments while iterating. +`pio run` without `-e` builds the release matrix from `platformio.ini` (`tiny` and `xlarge`). Use it before opening broad firmware PRs, but prefer explicit environments while iterating. ## Flash and monitor diff --git a/docs/file-formats.md b/docs/file-formats.md index 2661cd1d6e..aefb1697d5 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -229,13 +229,14 @@ Binary layout: ## `section.bin` -### Version 41 +### Version 44 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 44 invalidates older section caches so `TextBlock` word data can be +stored as one flat arena. It includes: - cache-busting fields for font, line compression, extra paragraph spacing, forced paragraph indents, paragraph alignment, viewport size, hyphenation, @@ -246,11 +247,18 @@ Version 41 includes: - paragraph and list-item LUTs used by KOReader sync page refinement - optional per-word Bionic Reading split metadata - optional per-word Guide Dot x-offset metadata +- optional per-word text flags for CSS backgrounds and layout-inserted hyphens - reading-aid layout that stores Bionic Reading and Guide Dots as per-word metadata instead of temporary layout words - publisher CSS page-break handling and adjusted justification spacing baked into page layout - table fragments - per-page footnote entries - per-page publisher page markers +- serialized word style bits for underline, strikethrough, superscript, and + subscript +- flat TextBlock word storage: per-word arrays plus one shared NUL-terminated + text blob, replacing length-prefixed word strings and parallel vectors. The + on-disk order mirrors the in-RAM arena so the firmware reads a whole block + payload with a single allocation and a single SD read ImHex pattern: @@ -259,7 +267,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 41 +#define EXPECTED_VERSION 44 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 @@ -321,23 +329,30 @@ struct BlockStyle { struct TextBlock { u16 wordCount; - String words[wordCount]; - s16 wordXPos[wordCount]; - WordStyle wordStyle[wordCount]; - - u8 hasFocus; - if (hasFocus != 0) { - u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; - u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; - } - + u8 hasBionic; u8 hasGuideDots; - if (hasGuideDots != 0) { - u16 wordGuideDotXOffset[wordCount] [[comment("Guide dot x offset from word start; 0 means no dot")]]; + u8 hasWordFlags; + u16 textBytes [[comment("Total size of text[], including one NUL per word")]]; + + if (wordCount > 0) { + u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]]; + s16 wordXPos[wordCount]; + if (hasBionic != 0) { + u16 wordBionicSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + } + if (hasGuideDots != 0) { + u16 wordGuideDotXOffset[wordCount] [[comment("Guide dot x offset from word start; 0 means no dot")]]; + } + WordStyle wordStyle[wordCount]; + if (hasBionic != 0) { + u8 wordBionicBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; + } + if (hasWordFlags != 0) { + u8 wordFlags[wordCount] [[comment("bit 0 = black background, bit 1 = layout-inserted trailing hyphen")]]; + } + char text[textBytes] [[comment("All words back to back, each NUL-terminated")]]; } - u8 wordFlags[wordCount] [[comment("bit 0 = black background, bit 1 = layout-inserted trailing hyphen")]]; - BlockStyle blockStyle; }; diff --git a/docs/font-build-variants.md b/docs/font-build-variants.md index cb9d555c58..81d71a4ccc 100644 --- a/docs/font-build-variants.md +++ b/docs/font-build-variants.md @@ -9,17 +9,6 @@ CrossInk ships multiple firmware build variants because the ESP32-C3 has limited ## Variants -### `teensy` - -Only the smallest point sizes. - -- Emoji and miscellaneous-symbol support -- 4 font sizes: - - 8 pt - - 9 pt - - 10 pt - - 12 pt - ### `tiny` No 18 pt or 20 pt font size. This is the preferred general-purpose build. @@ -49,4 +38,4 @@ Download the matching `firmware-*.bin` from the [releases page](https://github.c pio run -e tiny --target upload ``` -Replace `tiny` with `teensy` or `xlarge` as needed. +Replace `tiny` with `xlarge` as needed. diff --git a/docs/index.md b/docs/index.md index 9a3b2112d0..bf9de0ff43 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ Welcome to CrossInk, a personal fork of CrossPoint Reader. - [Installation](./installation.md) - [Font Build Variants](./font-build-variants.md) - [Reader Features](./reader-features.md) +- [Nearby Position Sync](./nearby-position-sync.md) - [EPUB Render Modes](./epub-render-modes.md) - [Controls](./controls.md) - [Simulator](./simulator.md) diff --git a/docs/nearby-position-sync.md b/docs/nearby-position-sync.md new file mode 100644 index 0000000000..c400be1a13 --- /dev/null +++ b/docs/nearby-position-sync.md @@ -0,0 +1,70 @@ +--- +title: Nearby Position Sync +nav_order: 8 +--- + +# Nearby Position Sync + +CrossInk can share the current EPUB reading position between two nearby X3/X4 readers running CrossInk. The sync is direct reader-to-reader over ESP-NOW; it does not use WiFi, a server, an account, or KOReader's online sync service. + +Nearby Position Sync is separate from [Reading Stats Sync](./reading-stats-sync.md). Stats Sync shares all-time reading totals. Nearby Position Sync shares one in-book location for the currently open EPUB. + +## What Gets Synced + +Nearby Position Sync shares the current location in the open EPUB: + +- book identity +- overall book percentage +- section / spine index +- page number within that section +- paragraph or anchor details when available + +It does not sync files, bookmarks, clippings, reading stats, KOReader server data, WiFi settings, OPDS servers, or reader options. + +The receiving reader only applies the position after you confirm it. + +## Requirements + +- Both readers must be running CrossInk. +- Both readers must have the same EPUB open. +- Both readers must be near each other. +- Nearby Position Sync must be run on real hardware; it is not available in the simulator. + +The book check uses the same document identity style as KOReader sync. If KOReader sync is configured to match by filename, Nearby Position Sync uses the filename-based document ID. Otherwise it uses the EPUB document hash. + +## How To Use It + +1. Open the same EPUB on both readers. +2. On the reader with the position you want to copy, go to that page. +3. On both readers, open the in-book menu and select **Nearby Position Sync**. +4. Press **Share** on the reader that is already at the correct page. +5. The other reader shows the nearby position beside its current local position. +6. Press **Select** on the receiving reader to apply the nearby position, or **Back** to leave the current position unchanged. + +Only one reader needs to press **Share**. The other reader should stay on the Nearby Position Sync screen and wait for the shared position. + +## What Happens On The Device + +Before starting the sync, CrossInk saves the current reader progress. The sender then broadcasts the current EPUB position over ESP-NOW. The receiver checks that the incoming packet is from a compatible CrossInk nearby-position protocol and that the book identity matches the EPUB currently open on the receiver. + +If the book matches, the receiver maps the shared KOReader-compatible position back to CrossInk's local section and page model. When paragraph, list-item, or anchor details are available, CrossInk uses them to refine the target page. + +After you press **Select**, CrossInk writes the received position to the normal EPUB progress file and returns to the reader at that position. + +## Common Messages + +**Different book. Position not synced** + +The two readers are not opened to the same EPUB according to the current document matching method. Open the same book on both readers and try again. + +**Version mismatch. Position not synced** + +The other reader is using an incompatible nearby-position sync protocol. Update both readers to compatible CrossInk builds. + +**No nearby reader found** or **Position sync timed out** + +The readers did not find each other within the sync window. Keep both readers close together, leave both on the Nearby Position Sync screen, and press **Share** on only the reader that should send its current position. + +**Nearby position sync is not available in simulator** + +This feature depends on ESP-NOW hardware support, so it only works on a real X3/X4 reader. diff --git a/docs/ota b/docs/ota deleted file mode 100644 index e173d87322..0000000000 --- a/docs/ota +++ /dev/null @@ -1,25 +0,0 @@ -{ - "tag_name": "v1.3.3", - "assets": [ - { - "name": "firmware-teensy-v1.3.3.bin", - "browser_download_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-teensy-v1.3.3.bin", - "size": 5752432 - }, - { - "name": "firmware-tiny-v1.3.3.bin", - "browser_download_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-tiny-v1.3.3.bin", - "size": 6186384 - }, - { - "name": "firmware-xlarge-v1.3.3.bin", - "browser_download_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-xlarge-v1.3.3.bin", - "size": 6050896 - }, - { - "name": "firmware-no_emoji-v1.3.3.bin", - "browser_download_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-no_emoji-v1.3.3.bin", - "size": 6509856 - } - ] -} diff --git a/docs/reader-features.md b/docs/reader-features.md index c7fe0c68ff..718267a201 100644 --- a/docs/reader-features.md +++ b/docs/reader-features.md @@ -139,6 +139,18 @@ Reading stats can also be used as a sleep screen, including the Minimal Stats sl For two-device syncing, see [Reading Stats Sync](./reading-stats-sync.md). +## Nearby Position Sync + +CrossInk can copy the current EPUB position from one nearby CrossInk reader to +another over ESP-NOW. Open the same EPUB on both readers, choose **Nearby +Position Sync** from the in-book menu on both devices, and press **Share** on +the reader that is already at the correct page. + +The receiving reader shows the incoming position and only applies it after you +confirm it. + +For details and troubleshooting, see [Nearby Position Sync](./nearby-position-sync.md). + ## Finished Books And Read Folder You can manually mark a book as finished from the in-book menu. diff --git a/docs/webserver.md b/docs/webserver.md index f0a2a1a6a2..ec3d6a315d 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -105,7 +105,9 @@ too large or memory-sensitive for the device. The default optimization path converts images for e-ink reading, limits them to the target device size, saves them as JPEG at 85% quality, and applies basic EPUB repairs such as safer SVG handling. Advanced Mode lets you pick the target -device, JPEG quality, image split or rotation handling, and split overlap. +device, JPEG quality, image split or rotation handling, split overlap, and +whether EPUB sections over 2,000 visible words should be split into smaller +reader sections. Optimization changes the EPUB file contents before upload. Note: if you use hash-based KOReader sync, this will break the syncing because it changes the epub diff --git a/freeink-sdk b/freeink-sdk new file mode 160000 index 0000000000..e93f67a4fb --- /dev/null +++ b/freeink-sdk @@ -0,0 +1 @@ +Subproject commit e93f67a4fb19c75fc6b3cb692f28ac3dec8d9e59 diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index b4a940f6b2..fefe4b2c32 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -33,12 +33,24 @@ void FontDecompressor::freePageBuffer() { } void FontDecompressor::freeHotGroup() { - hotGroup.clear(); - hotGroup.shrink_to_fit(); + free(hotGroup); + hotGroup = nullptr; + hotGroupCapacity = 0; hotGroupFont = nullptr; hotGroupIndex = UINT16_MAX; - hotGlyphBuf.clear(); - hotGlyphBuf.shrink_to_fit(); + free(hotGlyphBuf); + hotGlyphBuf = nullptr; + hotGlyphBufCapacity = 0; +} + +bool FontDecompressor::ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed) { + if (capacity >= needed) return true; + // Grow-only, free-then-malloc: every caller fully rewrites the buffer after a grow, so the + // old contents are dead -- freeing first gives the allocator its best shot on a tight heap. + free(buf); + buf = static_cast(malloc(needed)); // owned by FontDecompressor, freed in freeHotGroup() + capacity = buf ? needed : 0; + return buf != nullptr; } uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) { @@ -170,24 +182,20 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep } // Check if hot group already has this group decompressed — if not, decompress it - if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) { + if (!(hotGroup != nullptr && hotGroupFont == fontData && hotGroupIndex == groupIndex)) { stats.cacheMisses++; const EpdFontGroup& group = fontData->groups[groupIndex]; - hotGroup.resize(group.uncompressedSize); - if (hotGroup.empty()) { + // ensureCapacity may free the buffer, so the cached-group identity dies with it either way. + hotGroupFont = nullptr; + hotGroupIndex = UINT16_MAX; + if (!ensureCapacity(hotGroup, hotGroupCapacity, group.uncompressedSize)) { LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex); - hotGroupFont = nullptr; - hotGroupIndex = UINT16_MAX; stats.getBitmapTimeUs += micros() - tStart; return nullptr; } - if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) { - hotGroup.clear(); - hotGroup.shrink_to_fit(); - hotGroupFont = nullptr; - hotGroupIndex = UINT16_MAX; + if (!decompressGroup(fontData, groupIndex, hotGroup, group.uncompressedSize)) { stats.getBitmapTimeUs += micros() - tStart; return nullptr; } @@ -200,18 +208,16 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep } // Compact just the requested glyph from byte-aligned data into scratch buffer - if (glyph->dataLength > hotGlyphBuf.size()) { - hotGlyphBuf.resize(glyph->dataLength); - } - if (hotGlyphBuf.empty()) { + if (!ensureCapacity(hotGlyphBuf, hotGlyphBufCapacity, glyph->dataLength)) { + LOG_ERR("FDC", "Failed to allocate %u bytes for glyph scratch", (unsigned)glyph->dataLength); stats.getBitmapTimeUs += micros() - tStart; return nullptr; } uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex); - compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height); + compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf, glyph->width, glyph->height); stats.getBitmapTimeUs += micros() - tStart; - return hotGlyphBuf.data(); + return hotGlyphBuf; } // --- Prewarm: pre-decompress glyph bitmaps for a page of text --- diff --git a/lib/EpdFont/FontDecompressor.h b/lib/EpdFont/FontDecompressor.h index 706bdf74eb..652f9cd14e 100644 --- a/lib/EpdFont/FontDecompressor.h +++ b/lib/EpdFont/FontDecompressor.h @@ -2,8 +2,6 @@ #include -#include - #include "EpdFontData.h" class FontDecompressor { @@ -66,13 +64,22 @@ class FontDecompressor { // Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path. // Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf. + // Nothrow high-water malloc buffers, NOT std::vector: getBitmap() runs on the render path, + // and under -fno-exceptions a vector resize that hits OOM abort()s the firmware instead of + // failing (field crash: hotGroup.resize() -> std::bad_alloc -> abort with ~11 KB free). + // ensureCapacity() returns false on OOM so the caller can skip the glyph gracefully. const EpdFontData* hotGroupFont = nullptr; uint16_t hotGroupIndex = UINT16_MAX; - std::vector hotGroup; + uint8_t* hotGroup = nullptr; // owned; freed in freeHotGroup()/dtor + uint32_t hotGroupCapacity = 0; // Scratch buffer for compacting a single glyph from the hot group. - // Valid until the next getBitmap() call. - std::vector hotGlyphBuf; + // Valid until the next getBitmap() call. Same ownership/OOM contract as hotGroup. + uint8_t* hotGlyphBuf = nullptr; + uint32_t hotGlyphBufCapacity = 0; + + // Grow (never shrink) an owned buffer to at least `needed` bytes; false on OOM, buffer freed. + static bool ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed); void freePageBuffer(); void freeHotGroup(); diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index c865d6212f..6d659b508b 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -667,10 +667,45 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) return -1; } +bool SdCardFont::readAdvance(uint32_t codepoint, uint8_t style, uint16_t* outAdvance) const { + if (!outAdvance || !loaded_) return false; + + const uint8_t styleIdx = resolveStyle(style); + if (styleIdx >= MAX_STYLES || !styles_[styleIdx].present) return false; + const auto& s = styles_[styleIdx]; + if (advanceTableLookup(styleIdx, codepoint, outAdvance)) return true; + if (!s.fullIntervals && !s.bmpIntervals) return false; + + int32_t glyphIndex = findGlobalGlyphIndex(s, codepoint); + if (glyphIndex < 0 && codepoint != REPLACEMENT_GLYPH) { + glyphIndex = findGlobalGlyphIndex(s, REPLACEMENT_GLYPH); + } + if (glyphIndex < 0) return false; + + HalFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "readAdvance: failed to open .cpfont for U+%04X style %u", codepoint, styleIdx); + return false; + } + + const uint32_t fileOff = s.glyphsFileOffset + static_cast(glyphIndex) * sizeof(EpdGlyph); + EpdGlyph glyph = {}; + if (!file.seekSet(fileOff) || file.read(reinterpret_cast(&glyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "readAdvance: failed to read glyph for U+%04X style %u", codepoint, styleIdx); + file.close(); + return false; + } + file.close(); + + *outAdvance = glyph.advanceX; + return true; +} + // --- Prewarm --- int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) { - if (!loaded_) return -1; + lastPrewarmFailed_ = false; + if (!loaded_) return failPrewarm(-1); styleMask = resolveStyleMask(styleMask); if (styleMask == 0) return 0; @@ -685,7 +720,7 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn std::unique_ptr codepoints(new (std::nothrow) uint32_t[MAX_PAGE_GLYPHS]); if (!codepoints) { LOG_ERR("SDCF", "Failed to allocate codepoint buffer (%u bytes)", MAX_PAGE_GLYPHS * 4); - return -1; + return failPrewarm(-1); } uint32_t cpCount = 0; @@ -773,6 +808,11 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn return totalMissed; } +int SdCardFont::failPrewarm(const int missed) { + lastPrewarmFailed_ = true; + return missed; +} + int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { auto& s = styles_[styleIdx]; @@ -784,7 +824,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[cpCount]; if (!mappings) { LOG_ERR("SDCF", "Failed to allocate mapping array for style %u", styleIdx); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } uint32_t validCount = 0; @@ -813,7 +853,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 if (!s.miniIntervals) { LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); delete[] mappings; - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } s.miniIntervalCount = 0; @@ -835,7 +875,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } // Build sorted read order for sequential I/O @@ -844,7 +884,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } for (uint32_t i = 0; i < validCount; i++) readOrder[i] = i; std::sort(readOrder, readOrder + validCount, @@ -856,7 +896,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } unsigned long sdStart = millis(); @@ -881,7 +921,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } seekCount++; } @@ -890,7 +930,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } lastReadIndex = gIdx; } @@ -909,7 +949,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } // Read bitmap data sorted by file offset @@ -935,7 +975,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } seekCount++; } @@ -944,7 +984,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 delete[] readOrder; delete[] mappings; freeStyleMiniData(s); - return static_cast(cpCount); + return failPrewarm(static_cast(cpCount)); } lastBitmapEnd = fileOff + glyph.dataLength; @@ -1010,9 +1050,11 @@ void SdCardFont::clearCache() { } } -void SdCardFont::releaseForLowMemory() { +void SdCardFont::releaseForLowMemory(const bool preserveAdvanceTable) { clearOverflow(); - clearPersistentCache(); + if (!preserveAdvanceTable) { + clearPersistentCache(); + } for (uint8_t i = 0; i < MAX_STYLES; i++) { if (!styles_[i].present) continue; @@ -1122,10 +1164,21 @@ int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCoun if (!(styleMask & (1 << si)) || !styles_[si].present) continue; const auto& s = styles_[si]; - // Stop fetching once the cache is full — further inserts would be dropped - // by the merge anyway. The renderer fast path tolerates missing entries - // (returns 0); the slow path is still correct for those codepoints. - if (advanceTableSize_[si] >= ADVANCE_CACHE_LIMIT) continue; + if (advanceTableSize_[si] >= ADVANCE_CACHE_LIMIT) { + bool cacheMissesRequestedCodepoint = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (!advanceTableLookup(si, codepoints[i], nullptr)) { + cacheMissesRequestedCodepoint = true; + break; + } + } + if (!cacheMissesRequestedCodepoint) continue; + + delete[] advanceTable_[si]; + advanceTable_[si] = nullptr; + advanceTableSize_[si] = 0; + LOG_DBG("SDCF", "Advance table style %u: reset full cache for active text", si); + } // For each codepoint in `codepoints`, skip those already cached, then // resolve to a glyph index. Build a parallel array sorted by glyph index @@ -1264,6 +1317,37 @@ int SdCardFont::buildAdvanceTable(const std::vector& words, bool in return buildAdvanceTableRange(words.begin(), words.end(), words.size() > 1, includeHyphen, styleMask); } +int SdCardFont::buildAdvanceTableForCodepoints(const uint32_t* sourceCodepoints, uint32_t cpCount, bool includeSpace, + bool includeHyphen, uint8_t styleMask) { + if (!loaded_) return -1; + styleMask = resolveStyleMask(styleMask); + if (styleMask == 0) return 0; + + const unsigned long startMs = millis(); + const uint32_t extraCount = (includeSpace ? 1U : 0U) + (includeHyphen ? 1U : 0U); + std::unique_ptr codepoints(new (std::nothrow) uint32_t[cpCount + extraCount]); + if (!codepoints) { + LOG_ERR("SDCF", "buildAdvanceTableForCodepoints: failed to allocate codepoint buffer (%u bytes)", + static_cast((cpCount + extraCount) * sizeof(uint32_t))); + return -1; + } + + uint32_t outCount = 0; + for (uint32_t i = 0; i < cpCount; ++i) { + const uint32_t cp = sourceCodepoints[i]; + if (cp == 0) continue; + codepoints[outCount++] = cp; + } + if (includeSpace) codepoints[outCount++] = ' '; + if (includeHyphen) codepoints[outCount++] = '-'; + + std::sort(codepoints.get(), codepoints.get() + outCount); + outCount = static_cast(std::unique(codepoints.get(), codepoints.get() + outCount) - codepoints.get()); + const int totalMissed = fetchAdvancesForCodepoints(codepoints.get(), outCount, styleMask); + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + // --- Stats --- void SdCardFont::logStats(const char* label) { diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index a32bad9ede..e2e16771cd 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -50,10 +50,13 @@ class SdCardFont { // Returns number of codepoints not found in font coverage. int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F); int buildAdvanceTable(const std::vector& words, bool includeHyphen, uint8_t styleMask = 0x0F); + int buildAdvanceTableForCodepoints(const uint32_t* codepoints, uint32_t cpCount, bool includeSpace, + bool includeHyphen, uint8_t styleMask = 0x0F); // Look up advanceX for a codepoint from the advance table. // Returns the 12.4 fixed-point advance, or 0 if not found. uint16_t getAdvance(uint32_t codepoint, uint8_t style) const; + bool readAdvance(uint32_t codepoint, uint8_t style, uint16_t* outAdvance) const; // Returns true if advance table is populated for at least one style. bool hasAdvanceTable() const; @@ -66,7 +69,7 @@ class SdCardFont { // Release optional resident caches before memory-heavy work such as EPUB // image extraction. Keeps the font loaded and usable, but future layout or // rendering may need to re-read font metadata from SD. - void releaseForLowMemory(); + void releaseForLowMemory(bool preserveAdvanceTable = false); // Drop the persistent advance cache. Call when unloading the SD font or // when font/size/family/glyph-table state changes. @@ -109,6 +112,7 @@ class SdCardFont { void logStats(const char* label = "SDCF"); void resetStats(); const Stats& getStats() const { return stats_; } + bool lastPrewarmFailed() const { return lastPrewarmFailed_; } // Content hash of the file header + style TOC entries (computed during load). // Used to generate deterministic font IDs for section cache invalidation. @@ -243,6 +247,7 @@ class SdCardFont { Stats stats_; uint32_t contentHash_ = 0; bool loaded_ = false; + bool lastPrewarmFailed_ = false; // Per-style helpers void freeStyleMiniData(PerStyle& s); @@ -255,6 +260,7 @@ class SdCardFont { void applyGlyphMissCallback(uint8_t styleIdx); int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; int fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCount, uint8_t styleMask); + int failPrewarm(int missed); template int buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask); int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly); diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index 3e9119ce69..8039d8a1e0 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -219,6 +219,8 @@ bool SdCardFontRegistry::discover() { return !families_.empty(); } +void SdCardFontRegistry::clear() { std::vector().swap(families_); } + const char* SdCardFontRegistry::findFamilyRoot(const char* familyName) { if (!familyName || !*familyName) return nullptr; char path[160]; diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index 7596f72d54..f0009808bc 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -44,6 +44,7 @@ class SdCardFontRegistry { // Scan SD card, populate families_. Returns true if any families found. bool discover(); + void clear(); const std::vector& getFamilies() const { return families_; } const SdCardFontFamilyInfo* findFamily(const std::string& name) const; diff --git a/lib/EpdFont/builtinFonts/all.h b/lib/EpdFont/builtinFonts/all.h index b0b6a5f90b..f0c2cbcd8e 100644 --- a/lib/EpdFont/builtinFonts/all.h +++ b/lib/EpdFont/builtinFonts/all.h @@ -70,55 +70,6 @@ #include BUILTIN_READING_FONT_HEADER(bitter_20_regular) #endif -#ifndef OMIT_TEENSY_FONT -#include BUILTIN_READING_FONT_HEADER(charein_8_bold) -#include BUILTIN_READING_FONT_HEADER(charein_8_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_8_italic) -#include BUILTIN_READING_FONT_HEADER(charein_8_regular) -#endif -#ifndef OMIT_ITTY_BITTY_FONT -#include BUILTIN_READING_FONT_HEADER(charein_9_bold) -#include BUILTIN_READING_FONT_HEADER(charein_9_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_9_italic) -#include BUILTIN_READING_FONT_HEADER(charein_9_regular) -#endif -#ifndef OMIT_TINY_FONT -#include BUILTIN_READING_FONT_HEADER(charein_10_bold) -#include BUILTIN_READING_FONT_HEADER(charein_10_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_10_italic) -#include BUILTIN_READING_FONT_HEADER(charein_10_regular) -#endif -#ifndef OMIT_SMALL_FONT -#include BUILTIN_READING_FONT_HEADER(charein_12_bold) -#include BUILTIN_READING_FONT_HEADER(charein_12_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_12_italic) -#include BUILTIN_READING_FONT_HEADER(charein_12_regular) -#endif -#ifndef OMIT_MEDIUM_FONT -#include BUILTIN_READING_FONT_HEADER(charein_14_bold) -#include BUILTIN_READING_FONT_HEADER(charein_14_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_14_italic) -#include BUILTIN_READING_FONT_HEADER(charein_14_regular) -#endif -#ifndef OMIT_LARGE_FONT -#include BUILTIN_READING_FONT_HEADER(charein_16_bold) -#include BUILTIN_READING_FONT_HEADER(charein_16_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_16_italic) -#include BUILTIN_READING_FONT_HEADER(charein_16_regular) -#endif -#ifndef OMIT_XLARGE_FONT -#include BUILTIN_READING_FONT_HEADER(charein_18_bold) -#include BUILTIN_READING_FONT_HEADER(charein_18_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_18_italic) -#include BUILTIN_READING_FONT_HEADER(charein_18_regular) -#endif -#ifndef OMIT_HUGE_FONT -#include BUILTIN_READING_FONT_HEADER(charein_20_bold) -#include BUILTIN_READING_FONT_HEADER(charein_20_bolditalic) -#include BUILTIN_READING_FONT_HEADER(charein_20_italic) -#include BUILTIN_READING_FONT_HEADER(charein_20_regular) -#endif - #ifndef OMIT_TEENSY_FONT #include BUILTIN_READING_FONT_HEADER(lexenddeca_8_bold) #include BUILTIN_READING_FONT_HEADER(lexenddeca_8_bolditalic) diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index 63014f2d50..512c5078ad 100755 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -33,6 +33,7 @@ import tempfile import threading import time +import socket import urllib.request from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path @@ -47,7 +48,9 @@ DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf" -PATCHED_INTERVAL_PRESETS = ("punctuation",) +# Every generated SD-card reader font should cover the same core glyph ranges +# as the built-in reader fonts. Families can still add extra script presets. +PATCHED_INTERVAL_PRESETS = ("builtin",) def is_url(value: str) -> bool: @@ -89,17 +92,44 @@ def patched_intervals(intervals: str) -> str: return ",".join(patched) -def download_font(url: str, dest: Path) -> Path: - """Download a font file if not already cached. Returns the local path.""" +_orig_getaddrinfo = socket.getaddrinfo + + +def _ipv4_only_getaddrinfo(*args, **kwargs): + """getaddrinfo variant that drops AAAA records (IPv4 only).""" + return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET] + + +def download_font(url: str, dest: Path, retries: int = 3) -> Path: + """Download a font file if not already cached. Returns the local path. + + Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land + on a different mirror each request; a mirror may advertise an IPv6 address a + host without an IPv6 route cannot reach ([Errno 101] Network is unreachable). + Retry on failure, forcing IPv4 resolution after the first attempt. + """ if dest.exists(): return dest dest.parent.mkdir(parents=True, exist_ok=True) print(f" Downloading {dest.name}...") - try: - urllib.request.urlretrieve(url, dest) - except Exception as e: - dest.unlink(missing_ok=True) - raise RuntimeError(f"Failed to download {url}: {e}") from e + last_err = None + for attempt in range(1, retries + 1): + force_ipv4 = attempt > 1 + if force_ipv4: + socket.getaddrinfo = _ipv4_only_getaddrinfo + try: + urllib.request.urlretrieve(url, dest) + break + except Exception as e: # noqa: BLE001 - reported via RuntimeError below + last_err = e + dest.unlink(missing_ok=True) + if attempt < retries: + print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...") + finally: + if force_ipv4: + socket.getaddrinfo = _orig_getaddrinfo + else: + raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err size_kb = dest.stat().st_size / 1024 print(f" Downloaded {dest.name} ({size_kb:.0f} KB)") return dest diff --git a/lib/EpdFont/scripts/sd-fonts.yaml b/lib/EpdFont/scripts/sd-fonts.yaml index fcfcc34400..3cf21cc663 100644 --- a/lib/EpdFont/scripts/sd-fonts.yaml +++ b/lib/EpdFont/scripts/sd-fonts.yaml @@ -9,7 +9,9 @@ # name: Output family name (used in filenames and on-device UI) # description: Human-readable description (shown in download UI and manifest) # languages: Human-readable supported scripts/languages (shown as a separate UI line) -# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py +# intervals: Comma-separated extra Unicode interval presets for fontconvert_sdcard.py. +# build-sd-fonts.py always adds the "builtin" preset so SD fonts +# request the same core ranges as built-in reader fonts. # sizes: Point sizes to generate # force_autohint: (optional) Force FreeType auto-hinter instead of native hinting # styles: Map of style name -> font source @@ -40,10 +42,26 @@ families: intervals: latin-ext,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 500}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 500}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}} + regular: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", + variable: { wght: 500 }, + } + bold: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", + variable: { wght: 700 }, + } + italic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", + variable: { wght: 500 }, + } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", + variable: { wght: 700 }, + } - name: ChareInk description: "E-ink optimized serif for comfortable reading" @@ -51,10 +69,15 @@ families: intervals: latin-ext,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-BoldItalic.ttf"} + regular: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Bold.ttf" } + italic: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-Italic.ttf" } + bolditalic: + { + url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/ChareInk7/ChareInk7-BoldItalic.ttf", + } - name: GentiumBookPlus description: "Scholarly serif with wide Unicode coverage" @@ -62,10 +85,15 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"} + regular: + { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf" } + italic: + { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf" } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf", + } - name: IBMPlexSerif description: "Professional serif" @@ -73,10 +101,11 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"} + regular: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf" } - name: Literata description: "Screen-optimized serif" @@ -84,10 +113,11 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"} + regular: { url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf" } - name: Lora description: "Calligraphic serif for literary reading" @@ -95,10 +125,11 @@ families: intervals: latin-ext,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"} + regular: { url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf" } - name: Merriweather description: "Warm serif for long-form reading" @@ -106,10 +137,15 @@ families: intervals: latin-ext,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"} + regular: + { url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf" } + italic: + { url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf" } + bolditalic: + { + url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf", + } - name: NotoSerifExtended description: "Serif" @@ -117,10 +153,22 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"} + regular: + { + url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf", + } + bold: + { + url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf", + } + italic: + { + url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf", + } - name: SourceSerif4 description: "Adobe transitional serif" @@ -128,10 +176,12 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"} + regular: + { url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf" } - name: Tinos description: "Refreshing serif font with improved on-screen readability" @@ -139,10 +189,10 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-BoldItalic.ttf"} + regular: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-Italic.ttf" } + bolditalic: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/tinos/Tinos-BoldItalic.ttf" } - name: Domitian description: "A humanist serif for literary reading" @@ -150,22 +200,60 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Roman.otf"} - bold: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Bold.otf"} - italic: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Italic.otf"} - bolditalic: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-BoldItalic.otf"} + regular: { url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Roman.otf" } + bold: { url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Bold.otf" } + italic: { url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Italic.otf" } + bolditalic: { url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-BoldItalic.otf" } - name: LibreBaskerville - description: "A serif reimplementing the classic Baskerville (Latin)" + description: "A serif reimplementing the classic Baskerville" languages: "Latin" intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"} + regular: + { + url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Regular.ttf", + } + bold: + { + url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Bold.ttf", + } + italic: + { + url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf", + } + - name: Vollkorn + description: "A serif for bread and butter use by Friedrich Althausen" + languages: "Latin, Greek, Cyrillic" + intervals: latin-ext,greek,cyrillic + sizes: [8, 9, 10, 12, 14, 16, 18, 20] + styles: + regular: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", + variable: { wght: 400 }, + } + bold: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", + variable: { wght: 700 }, + } + italic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", + variable: { wght: 400 }, + } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", + variable: { wght: 700 }, + } # ── Sans-serif ───────────────────────────────────────────────────────── @@ -175,10 +263,26 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}} + regular: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", + variable: { wght: 400 }, + } + bold: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", + variable: { wght: 700 }, + } + italic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", + variable: { wght: 400 }, + } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", + variable: { wght: 700 }, + } - name: IBMPlexSans description: "IBM corporate sans-serif" @@ -186,10 +290,22 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"} + regular: + { + url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf", + } + bold: + { + url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf", + } + italic: + { + url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf", + } - name: Inter description: "Modern sans-serif" @@ -197,10 +313,26 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}} + regular: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", + variable: { wght: 400, opsz: 14 }, + } + bold: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", + variable: { wght: 700, opsz: 14 }, + } + italic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", + variable: { wght: 400, opsz: 14 }, + } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", + variable: { wght: 700, opsz: 14 }, + } - name: Lexend Deca description: "Friendly sans-serif for clear reading" @@ -208,10 +340,23 @@ families: intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", variable: {wght: 400}} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", variable: {wght: 700}} - italic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/LexendDeca/LexendDeca-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", variable: {wght: 700}} + regular: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", + variable: { wght: 400 }, + } + bold: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", + variable: { wght: 700 }, + } + italic: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/LexendDeca/LexendDeca-Italic.ttf" } + bolditalic: + { + url: "https://raw.githubusercontent.com/google/fonts/main/ofl/lexenddeca/LexendDeca%5Bwght%5D.ttf", + variable: { wght: 700 }, + } - name: NotoSansExtended description: "Sans-serif" @@ -219,10 +364,13 @@ families: intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-BoldItalic.ttf"} + regular: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Bold.ttf" } + italic: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-Italic.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/NotoSans/NotoSans-BoldItalic.ttf" } - name: SourceSans3 description: "Adobe humanist sans-serif" @@ -230,10 +378,11 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"} + regular: { url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf" } # ── Monospace ────────────────────────────────────────────────────────── @@ -243,10 +392,11 @@ families: intervals: latin-ext,greek,cyrillic sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"} + regular: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf" } - name: SourceCodePro description: "Adobe monospace with excellent hinting" @@ -254,10 +404,12 @@ families: intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"} + regular: + { url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf" } + bold: { url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf" } + italic: { url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf" } + bolditalic: + { url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf" } # ── Dyslexia ─────────────────────────────────────────────────────────────── @@ -266,10 +418,22 @@ families: intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Regular.otf"} - bold: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Bold.otf"} - italic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Italic.otf"} - bolditalic: {url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf"} + regular: + { + url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Regular.otf", + } + bold: + { + url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Bold.otf", + } + italic: + { + url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-Italic.otf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/uxjulia/crossink-fonts/main/fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf", + } # ── Accessibility ────────────────────────────────────────────────────── @@ -279,10 +443,22 @@ families: intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"} + regular: + { + url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf", + } + bold: + { + url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf", + } + italic: + { + url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf", + } - name: LexicaUltralegible description: "Accessibility font for low vision / dyslexia" @@ -290,7 +466,19 @@ families: intervals: latin-ext sizes: [8, 9, 10, 12, 14, 16, 18, 20] styles: - regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"} - bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"} - italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"} - bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"} + regular: + { + url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf", + } + bold: + { + url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf", + } + italic: + { + url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf", + } + bolditalic: + { + url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf", + } diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index eb8b5847cd..eb520c6f1e 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -2,9 +2,12 @@ #include #include +#include #include #include #include +#include +#include #include #include #include @@ -23,9 +26,37 @@ namespace { constexpr int kDefaultThumbHeight = 180; -constexpr char kCrossInkLocationsPath[] = "META-INF/x-locations.json"; -constexpr size_t kCrossInkLocationsMaxBytes = 64 * 1024; -constexpr uint32_t kDefaultReferenceWordsPerPage = 250; +constexpr char kXLocationsPath[] = "META-INF/x-locations.json"; +constexpr char kXLocationsFormat[] = "x-locations"; +constexpr char kLegacyXLocationsFormat[] = "crossink-locations"; +constexpr size_t kXLocationsMaxBytes = 64 * 1024; +constexpr uint32_t kDefaultReferenceCharactersPerPage = 1500; + +bool isSupportedLocationsFormat(const char* format) { + return std::strcmp(format, kXLocationsFormat) == 0 || std::strcmp(format, kLegacyXLocationsFormat) == 0; +} + +void buildXLocationsJsonFilter(JsonDocument& filter) { + JsonObject root = filter.to(); + root["format"] = true; + root["version"] = true; + root["totalLocations"] = true; + root["totalWords"] = true; + root["totalCharacters"] = true; + root["wordsPerReferencePage"] = true; + root["charactersPerReferencePage"] = true; + root["totalReferencePages"] = true; + + JsonArray spine = root["spine"].to(); + JsonObject spineEntry = spine.add(); + spineEntry["index"] = true; + spineEntry["startLocation"] = true; + spineEntry["endLocation"] = true; + spineEntry["wordStart"] = true; + spineEntry["wordCount"] = true; + spineEntry["characterStart"] = true; + spineEntry["characterCount"] = true; +} float clampUnit(const float value) { if (value <= 0.0f) { @@ -47,8 +78,24 @@ void normalizeThumbDimensions(int& width, int& height) { height = kDefaultThumbHeight; } if (width <= 0) { - width = static_cast((static_cast(height) * 3 + 2) / 5); + width = static_cast((static_cast(height) * 2 + 1) / 3); + } +} + +std::unique_ptr makeBookMetadataCacheNoThrow(const std::string& cachePath) { + auto cache = makeUniqueNoThrow(cachePath); + if (!cache) { + LOG_ERR("EBP", "OOM: BookMetadataCache (%u free, %u max alloc)", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); } + return cache; +} + +std::unique_ptr makeCssParserNoThrow(const std::string& cachePath) { + auto parser = makeUniqueNoThrow(cachePath); + if (!parser) { + LOG_ERR("EBP", "OOM: CssParser (%u free, %u max alloc)", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } + return parser; } bool cachedBmpMatchesDimensions(const std::string& path, const int width, const int height, @@ -81,6 +128,22 @@ bool cachedBmpMatchesDimensions(const std::string& path, const int width, const return matches; } +void releaseReaderSdFontCachesBeforeCoverDecode(const GfxRenderer* renderer, const int readerFontId, + const char* reason) { + if (!renderer) return; + if (readerFontId <= 0) return; + if (!renderer->isSdCardFont(readerFontId)) return; + + const auto before = MemoryBudget::snapshot(); + if (!MemoryBudget::shouldReleaseSdFontCachesForEpubInlineImage(before)) return; + + if (!renderer->releaseSdCardFontForLowMemory(readerFontId)) return; + + const auto after = MemoryBudget::snapshot(); + LOG_DBG("EBP", "Released SD font caches before %s: free=%u->%u maxAlloc=%u->%u", reason, before.freeHeap, + after.freeHeap, before.maxAllocHeap, after.maxAllocHeap); +} + std::string getThumbBmpPathForDimensions(const std::string& cachePath, int width, int height) { return cachePath + "/thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp"; } @@ -445,10 +508,17 @@ Epub::CssParseStatus Epub::parseCssFiles(const bool forceRebuild) const { LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size()); - // See if we have a cached version of the CSS rules + // See if we have a usable cached version of the CSS rules. File existence alone is not enough: + // stale cache formats are removed by loadFromCache(), then rebuilt below. if (cssParser->hasCache() && !forceRebuild) { - LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles"); - return cssParser->isCachePartial() ? CssParseStatus::Partial : CssParseStatus::Complete; + if (cssParser->loadFromCache()) { + const bool partialCache = cssParser->isCachePartial(); + LOG_DBG("EBP", "CSS cache valid, skipping parseCssFiles"); + cssParser->clear(); + return partialCache ? CssParseStatus::Partial : CssParseStatus::Complete; + } + LOG_DBG("EBP", "CSS cache invalid, rebuilding CSS rules"); + cssParser->clear(); } // No cache yet - parse CSS files. If memory runs out partway through, keep @@ -553,9 +623,16 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str()); // Initialize spine/TOC cache - bookMetadataCache.reset(new BookMetadataCache(cachePath)); + bookMetadataCache = makeBookMetadataCacheNoThrow(cachePath); + if (!bookMetadataCache) { + return false; + } // Always create CssParser - needed for inline style parsing even without CSS files - cssParser.reset(new CssParser(cachePath)); + cssParser = makeCssParserNoThrow(cachePath); + if (!cssParser) { + bookMetadataCache.reset(); + return false; + } // Try to load existing cache first if (bookMetadataCache->load()) { @@ -591,7 +668,10 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { } bookMetadataCache.reset(); const CssParseStatus cssStatus = parseCssFiles(forceCssRebuild); - bookMetadataCache.reset(new BookMetadataCache(cachePath)); + bookMetadataCache = makeBookMetadataCacheNoThrow(cachePath); + if (!bookMetadataCache) { + return false; + } if (!bookMetadataCache->load()) { LOG_ERR("EBP", "Failed to reload cache after CSS rebuild"); return false; @@ -607,7 +687,7 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { } } } - loadCrossInkLocations(); + loadXLocations(); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); return true; } @@ -709,13 +789,16 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { } // Reload the cache from disk so it's in the correct state - bookMetadataCache.reset(new BookMetadataCache(cachePath)); + bookMetadataCache = makeBookMetadataCacheNoThrow(cachePath); + if (!bookMetadataCache) { + return false; + } if (!bookMetadataCache->load()) { LOG_ERR("EBP", "Failed to reload cache after writing"); return false; } - loadCrossInkLocations(); + loadXLocations(); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); return true; } @@ -779,7 +862,7 @@ std::string Epub::getCoverBmpPath(bool cropped) const { return cachePath + "/" + coverFileName + ".bmp"; } -bool Epub::generateCoverBmp(bool cropped) const { +bool Epub::generateCoverBmp(bool cropped, const GfxRenderer* renderer, const int readerFontId) const { // Already generated, return true if (Storage.exists(getCoverBmpPath(cropped).c_str())) { return true; @@ -798,37 +881,26 @@ bool Epub::generateCoverBmp(bool cropped) const { if (FsHelpers::hasJpgExtension(coverImageHref)) { LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit"); - const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; - - FsFile coverJpg; - if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + std::string coverJpgPath; + if (!ensureCachedCoverImage(coverImageHref, coverJpgPath)) { return false; } - if (!readItemContentsToStream(coverImageHref, coverJpg, 1024)) { - LOG_ERR("EBP", "Failed to read cover JPG item: %s", coverImageHref.c_str()); - coverJpg.close(); - Storage.remove(coverJpgTempPath.c_str()); - return false; - } - // Explicitly close() file before reopening for reading - coverJpg.close(); - if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { - Storage.remove(coverJpgTempPath.c_str()); + FsFile coverJpg; + if (!Storage.openFileForRead("EBP", coverJpgPath, coverJpg)) { return false; } FsFile coverBmp; if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { coverJpg.close(); - Storage.remove(coverJpgTempPath.c_str()); return false; } + releaseReaderSdFontCachesBeforeCoverDecode(renderer, readerFontId, "cover JPG decode"); const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped); - // Explicitly close() files before calling Storage.remove() + // Explicitly close() files before leaving the converter path. coverJpg.close(); coverBmp.close(); - Storage.remove(coverJpgTempPath.c_str()); if (!success) { LOG_ERR("EBP", "Failed to generate BMP from cover image"); @@ -840,37 +912,26 @@ bool Epub::generateCoverBmp(bool cropped) const { if (FsHelpers::hasPngExtension(coverImageHref)) { LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit"); - const auto coverPngTempPath = getCachePath() + "/.cover.png"; - - FsFile coverPng; - if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) { - return false; - } - if (!readItemContentsToStream(coverImageHref, coverPng, 1024)) { - LOG_ERR("EBP", "Failed to read cover PNG item: %s", coverImageHref.c_str()); - coverPng.close(); - Storage.remove(coverPngTempPath.c_str()); + std::string coverPngPath; + if (!ensureCachedCoverImage(coverImageHref, coverPngPath)) { return false; } - // Explicitly close() file before reopening for reading - coverPng.close(); - if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { - Storage.remove(coverPngTempPath.c_str()); + FsFile coverPng; + if (!Storage.openFileForRead("EBP", coverPngPath, coverPng)) { return false; } FsFile coverBmp; if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { coverPng.close(); - Storage.remove(coverPngTempPath.c_str()); return false; } + releaseReaderSdFontCachesBeforeCoverDecode(renderer, readerFontId, "cover PNG decode"); const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped); - // Explicitly close() files before calling Storage.remove() + // Explicitly close() files before leaving the converter path. coverPng.close(); coverBmp.close(); - Storage.remove(coverPngTempPath.c_str()); if (!success) { LOG_ERR("EBP", "Failed to generate BMP from PNG cover image"); @@ -904,15 +965,70 @@ std::string Epub::getAdaptiveThumbBmpPath(int width, int height) const { return getAdaptiveThumbBmpPathForDimensions(cachePath, width, height); } -bool Epub::generateThumbBmp(int height) const { return generateThumbBmp(0, height); } +bool Epub::generateThumbBmp(int height, const GfxRenderer* renderer, const int readerFontId) const { + return generateThumbBmp(0, height, renderer, readerFontId); +} + +bool Epub::generateThumbBmp(int width, int height, const GfxRenderer* renderer, const int readerFontId) const { + return generateThumbBmpInternal(width, height, false, renderer, readerFontId); +} + +bool Epub::generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer, const int readerFontId) const { + return generateThumbBmpInternal(width, height, true, renderer, readerFontId); +} + +std::string Epub::getCachedCoverImagePath(const std::string& coverImageHref) const { + if (FsHelpers::hasJpgExtension(coverImageHref)) { + return getCachePath() + "/cover_src.jpg"; + } + if (FsHelpers::hasPngExtension(coverImageHref)) { + return getCachePath() + "/cover_src.png"; + } + return {}; +} + +bool Epub::ensureCachedCoverImage(const std::string& coverImageHref, std::string& outPath) const { + outPath = getCachedCoverImagePath(coverImageHref); + if (outPath.empty()) { + LOG_ERR("EBP", "Cover image is not a supported format, cannot cache source"); + return false; + } + if (Storage.exists(outPath.c_str())) { + return true; + } + + const std::string tmpPath = outPath + ".tmp"; + if (Storage.exists(tmpPath.c_str())) { + Storage.remove(tmpPath.c_str()); + } + + FsFile coverFile; + if (!Storage.openFileForWrite("EBP", tmpPath, coverFile)) { + return false; + } + if (!readItemContentsToStream(coverImageHref, coverFile, 1024)) { + LOG_ERR("EBP", "Failed to cache cover image item: %s", coverImageHref.c_str()); + coverFile.close(); + Storage.remove(tmpPath.c_str()); + return false; + } + coverFile.close(); -bool Epub::generateThumbBmp(int width, int height) const { return generateThumbBmpInternal(width, height, false); } + if (Storage.exists(outPath.c_str())) { + Storage.remove(outPath.c_str()); + } + if (!Storage.rename(tmpPath.c_str(), outPath.c_str())) { + LOG_ERR("EBP", "Failed to finalize cached cover image: %s", outPath.c_str()); + Storage.remove(tmpPath.c_str()); + return false; + } -bool Epub::generateAdaptiveThumbBmp(int width, int height) const { - return generateThumbBmpInternal(width, height, true); + LOG_DBG("EBP", "Cached cover image source: %s", outPath.c_str()); + return true; } -bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveContain) const { +bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveContain, const GfxRenderer* renderer, + const int readerFontId) const { if (height <= 0) { LOG_DBG("EBP", "Using default thumb BMP height for requested dimensions: %dx%d", width, height); } @@ -935,40 +1051,29 @@ bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveCo LOG_DBG("EBP", "No known cover image for thumbnail"); } else if (FsHelpers::hasJpgExtension(coverImageHref)) { LOG_DBG("EBP", "Generating thumb BMP from JPG cover image"); - const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; - - FsFile coverJpg; - if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + std::string coverJpgPath; + if (!ensureCachedCoverImage(coverImageHref, coverJpgPath)) { return false; } - if (!readItemContentsToStream(coverImageHref, coverJpg, 1024)) { - LOG_ERR("EBP", "Failed to read thumbnail JPG item: %s", coverImageHref.c_str()); - coverJpg.close(); - Storage.remove(coverJpgTempPath.c_str()); - return false; - } - // Explicitly close() file before reopening for reading - coverJpg.close(); - if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { - Storage.remove(coverJpgTempPath.c_str()); + FsFile coverJpg; + if (!Storage.openFileForRead("EBP", coverJpgPath, coverJpg)) { return false; } FsFile thumbBmp; if (!Storage.openFileForWrite("EBP", thumbPath, thumbBmp)) { coverJpg.close(); - Storage.remove(coverJpgTempPath.c_str()); return false; } int THUMB_TARGET_WIDTH = width; int THUMB_TARGET_HEIGHT = height; + releaseReaderSdFontCachesBeforeCoverDecode(renderer, readerFontId, "thumbnail JPG decode"); const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT, adaptiveContain); - // Explicitly close() files before calling Storage.remove() + // Explicitly close() files before leaving the converter path. coverJpg.close(); thumbBmp.close(); - Storage.remove(coverJpgTempPath.c_str()); if (!success) { LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image"); @@ -978,40 +1083,29 @@ bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveCo return success; } else if (FsHelpers::hasPngExtension(coverImageHref)) { LOG_DBG("EBP", "Generating thumb BMP from PNG cover image"); - const auto coverPngTempPath = getCachePath() + "/.cover.png"; - - FsFile coverPng; - if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) { - return false; - } - if (!readItemContentsToStream(coverImageHref, coverPng, 1024)) { - LOG_ERR("EBP", "Failed to read thumbnail PNG item: %s", coverImageHref.c_str()); - coverPng.close(); - Storage.remove(coverPngTempPath.c_str()); + std::string coverPngPath; + if (!ensureCachedCoverImage(coverImageHref, coverPngPath)) { return false; } - // Explicitly close() file before reopening for reading - coverPng.close(); - if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { - Storage.remove(coverPngTempPath.c_str()); + FsFile coverPng; + if (!Storage.openFileForRead("EBP", coverPngPath, coverPng)) { return false; } FsFile thumbBmp; if (!Storage.openFileForWrite("EBP", thumbPath, thumbBmp)) { coverPng.close(); - Storage.remove(coverPngTempPath.c_str()); return false; } int THUMB_TARGET_WIDTH = width; int THUMB_TARGET_HEIGHT = height; + releaseReaderSdFontCachesBeforeCoverDecode(renderer, readerFontId, "thumbnail PNG decode"); const bool success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT, adaptiveContain); - // Explicitly close() files before calling Storage.remove() + // Explicitly close() files before leaving the converter path. coverPng.close(); thumbBmp.close(); - Storage.remove(coverPngTempPath.c_str()); if (!success) { LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image"); @@ -1058,13 +1152,13 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { return ZipFile(filepath).getInflatedFileSize(path.c_str(), size); } -bool Epub::loadCrossInkLocations() { +bool Epub::loadXLocations() { locationSpine.clear(); totalLocations = 0; totalWords = 0; wordsPerReferencePage = 0; totalReferencePages = 0; - crossinkLocationsLoaded = false; + xLocationsLoaded = false; if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { return false; @@ -1076,27 +1170,30 @@ bool Epub::loadCrossInkLocations() { } size_t manifestSize = 0; - if (!getItemSize(kCrossInkLocationsPath, &manifestSize)) { + if (!getItemSize(kXLocationsPath, &manifestSize)) { return false; } - if (manifestSize == 0 || manifestSize > kCrossInkLocationsMaxBytes) { - LOG_ERR("EBP", "Ignoring CrossInk locations manifest with unsupported size: %zu bytes", manifestSize); + if (manifestSize == 0 || manifestSize > kXLocationsMaxBytes) { + LOG_ERR("EBP", "Ignoring X locations manifest with unsupported size: %zu bytes", manifestSize); return false; } size_t bytesRead = 0; - uint8_t* manifestData = readItemContentsToBytes(kCrossInkLocationsPath, &bytesRead, true); + uint8_t* manifestData = readItemContentsToBytes(kXLocationsPath, &bytesRead, true); if (!manifestData) { - LOG_ERR("EBP", "Failed to read CrossInk locations manifest"); + LOG_ERR("EBP", "Failed to read X locations manifest"); return false; } + JsonDocument filter; + buildXLocationsJsonFilter(filter); JsonDocument doc; - const DeserializationError err = deserializeJson(doc, reinterpret_cast(manifestData), bytesRead); + const DeserializationError err = deserializeJson(doc, reinterpret_cast(manifestData), bytesRead, + DeserializationOption::Filter(filter.as())); free(manifestData); if (err) { - LOG_ERR("EBP", "CrossInk locations parse error: %s", err.c_str()); + LOG_ERR("EBP", "X locations parse error: %s", err.c_str()); return false; } @@ -1104,12 +1201,18 @@ bool Epub::loadCrossInkLocations() { const int version = doc["version"] | 0; const uint32_t parsedTotalLocations = doc["totalLocations"] | 0; const uint32_t parsedTotalWords = doc["totalWords"] | 0; + const uint32_t parsedTotalCharacters = doc["totalCharacters"] | 0; const uint32_t parsedWordsPerReferencePage = doc["wordsPerReferencePage"] | 0; + const uint32_t parsedCharactersPerReferencePage = doc["charactersPerReferencePage"] | 0; + const bool useCharacterReferencePages = parsedTotalCharacters > 0 && parsedCharactersPerReferencePage > 0; + const uint32_t parsedReferenceUnits = useCharacterReferencePages ? parsedTotalCharacters : parsedTotalWords; + const uint32_t parsedReferenceUnitsPerPage = + useCharacterReferencePages ? parsedCharactersPerReferencePage : parsedWordsPerReferencePage; const uint32_t parsedTotalReferencePages = doc["totalReferencePages"] | 0; JsonArrayConst spine = doc["spine"]; - if (std::strcmp(format, "crossink-locations") != 0 || version != 1 || parsedTotalLocations == 0 || spine.isNull()) { - LOG_ERR("EBP", "Ignoring unsupported CrossInk locations manifest"); + if (!isSupportedLocationsFormat(format) || version != 1 || parsedTotalLocations == 0 || spine.isNull()) { + LOG_ERR("EBP", "Ignoring unsupported X locations manifest"); return false; } @@ -1125,13 +1228,15 @@ bool Epub::loadCrossInkLocations() { const uint32_t startLocation = spineItem["startLocation"] | 0; const uint32_t endLocation = spineItem["endLocation"] | 0; - const uint32_t wordStart = spineItem["wordStart"] | 0; - const uint32_t wordCount = spineItem["wordCount"] | 0; + const uint32_t wordStart = + useCharacterReferencePages ? (spineItem["characterStart"] | 0) : (spineItem["wordStart"] | 0); + const uint32_t wordCount = + useCharacterReferencePages ? (spineItem["characterCount"] | 0) : (spineItem["wordCount"] | 0); if (startLocation == 0 && endLocation == 0) { continue; } if (startLocation == 0 || endLocation < startLocation || endLocation > parsedTotalLocations) { - LOG_ERR("EBP", "Ignoring invalid CrossInk location range at spine %d", index); + LOG_ERR("EBP", "Ignoring invalid X location range at spine %d", index); continue; } @@ -1145,14 +1250,15 @@ bool Epub::loadCrossInkLocations() { } totalLocations = parsedTotalLocations; - totalWords = parsedTotalWords; - wordsPerReferencePage = parsedWordsPerReferencePage > 0 ? parsedWordsPerReferencePage : kDefaultReferenceWordsPerPage; + totalWords = parsedReferenceUnits; + wordsPerReferencePage = + parsedReferenceUnitsPerPage > 0 ? parsedReferenceUnitsPerPage : kDefaultReferenceCharactersPerPage; totalReferencePages = parsedTotalReferencePages; if (totalReferencePages == 0 && totalWords > 0 && wordsPerReferencePage > 0) { totalReferencePages = (totalWords + wordsPerReferencePage - 1) / wordsPerReferencePage; } - crossinkLocationsLoaded = true; - LOG_INF("EBP", "Loaded CrossInk locations: %lu locations, %lu reference pages across %zu spine items", + xLocationsLoaded = true; + LOG_INF("EBP", "Loaded X locations: %lu locations, %lu reference pages across %zu spine items", static_cast(totalLocations), static_cast(totalReferencePages), locationSpine.size()); return true; @@ -1282,7 +1388,7 @@ float Epub::calculateSizeProgress(const int currentSpineIndex, const float curre // Calculate progress in book (returns 0.0-1.0) float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { - if (!crossinkLocationsLoaded || totalLocations == 0 || currentSpineIndex < 0 || + if (!xLocationsLoaded || totalLocations == 0 || currentSpineIndex < 0 || currentSpineIndex >= static_cast(locationSpine.size())) { return calculateSizeProgress(currentSpineIndex, currentSpineRead); } @@ -1299,7 +1405,7 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp } bool Epub::resolveLocationPercentToSpineProgress(const int percent, int& spineIndex, float& spineProgress) const { - if (!crossinkLocationsLoaded || totalLocations == 0 || locationSpine.empty()) { + if (!xLocationsLoaded || totalLocations == 0 || locationSpine.empty()) { return false; } @@ -1349,7 +1455,7 @@ bool Epub::resolveReferencePage(const int currentSpineIndex, const float current uint32_t& pageCount) const { currentPage = 0; pageCount = 0; - if (!crossinkLocationsLoaded || totalWords == 0 || wordsPerReferencePage == 0 || totalReferencePages == 0 || + if (!xLocationsLoaded || totalWords == 0 || wordsPerReferencePage == 0 || totalReferencePages == 0 || currentSpineIndex < 0 || currentSpineIndex >= static_cast(locationSpine.size())) { return false; } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 572645fa72..76dea3662a 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -12,6 +12,7 @@ #include "Epub/css/CssParser.h" class ZipFile; +class GfxRenderer; class Epub { // the ncx file (EPUB 2) @@ -41,7 +42,7 @@ class Epub { uint32_t totalWords = 0; uint32_t wordsPerReferencePage = 0; uint32_t totalReferencePages = 0; - bool crossinkLocationsLoaded = false; + bool xLocationsLoaded = false; enum class CssParseStatus : uint8_t { Failed, Partial, @@ -73,7 +74,7 @@ class Epub { const std::string& getAuthor() const; const std::string& getLanguage() const; std::string getCoverBmpPath(bool cropped = false) const; - bool generateCoverBmp(bool cropped = false) const; + bool generateCoverBmp(bool cropped = false, const GfxRenderer* renderer = nullptr, int readerFontId = 0) const; std::string getThumbBmpPath() const; // Deprecated compatibility wrapper; forwards to getThumbBmpPath(0, height). [[deprecated("use getThumbBmpPath(int width, int height)")]] @@ -87,15 +88,16 @@ class Epub { std::string getAdaptiveThumbBmpPath(int width, int height) const; // Deprecated compatibility wrapper; forwards to generateThumbBmp(0, height). [[deprecated("use generateThumbBmp(int width, int height)")]] - bool generateThumbBmp(int height) const; + bool generateThumbBmp(int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0) const; // Writes a thumbnail BMP to cache. width <= 0 derives the default 3:5 // (width:height) thumbnail width from height; height <= 0 uses the default // thumbnail height. // Returns false on missing cache/cover, unsupported image format, or conversion failure. - bool generateThumbBmp(int width, int height) const; + bool generateThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0) const; // Writes a thumbnail that can either crop-to-fill or contain unusual cover // ratios, depending on the source image dimensions. - bool generateAdaptiveThumbBmp(int width, int height) const; + bool generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, + int readerFontId = 0) const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; @@ -110,9 +112,9 @@ class Epub { int getSpineIndexForTextReference() const; size_t getBookSize() const; - bool hasCrossInkLocations() const { return crossinkLocationsLoaded; } + bool hasXLocations() const { return xLocationsLoaded; } bool hasStablePageNumbers() const { - return crossinkLocationsLoaded && totalWords > 0 && wordsPerReferencePage > 0 && totalReferencePages > 0; + return xLocationsLoaded && totalWords > 0 && wordsPerReferencePage > 0 && totalReferencePages > 0; } float calculateSizeProgress(int currentSpineIndex, float currentSpineRead) const; float calculateProgress(int currentSpineIndex, float currentSpineRead) const; @@ -123,6 +125,9 @@ class Epub { int resolveHrefToSpineIndex(const std::string& href) const; private: - bool loadCrossInkLocations(); - bool generateThumbBmpInternal(int width, int height, bool adaptiveContain) const; + bool loadXLocations(); + std::string getCachedCoverImagePath(const std::string& coverImageHref) const; + bool ensureCachedCoverImage(const std::string& coverImageHref, std::string& outPath) const; + bool generateThumbBmpInternal(int width, int height, bool adaptiveContain, const GfxRenderer* renderer, + int readerFontId) const; }; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 8b11a1015a..d7532ab9a7 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -5,7 +5,6 @@ #include #include -#include #include #include "FsHelpers.h" @@ -16,6 +15,7 @@ constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; +constexpr size_t METADATA_ARENA_SLAB_BYTES = 4096; } // namespace /* ============= WRITING / BUILDING FUNCTIONS ================ */ @@ -54,8 +54,16 @@ bool BookMetadataCache::beginTocPass() { } if (spineCount >= LARGE_SPINE_THRESHOLD) { - spineHrefIndex.clear(); - spineHrefIndex.resize(spineCount); + spineHrefIndex.resetStorage(); + spineHrefIndexArena.release(); + if (!spineHrefIndexArena.init(METADATA_ARENA_SLAB_BYTES) || !spineHrefIndex.resize(spineCount)) { + LOG_ERR("BMC", "Failed to allocate spine href index arena for %u spine items", spineCount); + spineHrefIndex.resetStorage(); + spineHrefIndexArena.release(); + tocFile.close(); + spineFile.close(); + return false; + } spineFile.seek(0); for (int i = 0; i < spineCount; i++) { auto entry = readSpineEntry(spineFile); @@ -84,8 +92,8 @@ bool BookMetadataCache::endTocPass() { tocFile.close(); spineFile.close(); - spineHrefIndex.clear(); - spineHrefIndex.shrink_to_fit(); + spineHrefIndex.resetStorage(); + spineHrefIndexArena.release(); useSpineHrefIndex = false; return true; @@ -120,6 +128,11 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta spineFile.close(); return false; } + auto closeBuildFiles = [this]() { + bookFile.close(); + spineFile.close(); + tocFile.close(); + }; constexpr uint32_t headerASize = sizeof(BOOK_CACHE_MAGIC) + sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount); @@ -161,8 +174,24 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta // LUTs complete // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin + Arena metadataArena; + if (!metadataArena.init(METADATA_ARENA_SLAB_BYTES)) { + LOG_ERR("BMC", "Failed to allocate metadata scratch arena (%u bytes)", + static_cast(METADATA_ARENA_SLAB_BYTES)); + closeBuildFiles(); + return false; + } + // Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m)) - std::deque spineToTocIndex(spineCount, -1); + ArenaVector spineToTocIndex(metadataArena); + if (!spineToTocIndex.resize(spineCount)) { + LOG_ERR("BMC", "Failed to allocate spine-to-TOC index for %u spine items", spineCount); + closeBuildFiles(); + return false; + } + for (size_t i = 0; i < spineToTocIndex.size(); ++i) { + spineToTocIndex[i] = -1; + } tocFile.seek(0); for (int j = 0; j < tocCount; j++) { auto tocEntry = readTocEntry(tocFile); @@ -178,9 +207,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta if (!zip.open()) { LOG_ERR("BMC", "Could not open EPUB zip for size calculations"); // Explicit close() required: member variables persist beyond function scope - bookFile.close(); - spineFile.close(); - tocFile.close(); + closeBuildFiles(); return false; } // NOTE: We intentionally skip calling loadAllFileStatSlims() here. @@ -191,14 +218,19 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta // This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion. // See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134 - std::deque spineSizes; + ArenaVector spineSizes(metadataArena); bool useBatchSizes = false; if (spineCount >= LARGE_SPINE_THRESHOLD) { LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount); - std::deque targets; - targets.resize(spineCount); + ArenaVector targets(metadataArena); + if (!targets.resize(spineCount) || !spineSizes.resize(spineCount)) { + LOG_ERR("BMC", "Failed to allocate batch size lookup scratch for %u spine items", spineCount); + zip.close(); + closeBuildFiles(); + return false; + } spineFile.seek(0); for (int i = 0; i < spineCount; i++) { @@ -216,12 +248,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta return a.hash < b.hash || (a.hash == b.hash && a.len < b.len); }); - spineSizes.resize(spineCount, 0); - int matched = zip.fillUncompressedSizes(targets, spineSizes); - LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount); - - targets.clear(); - targets.shrink_to_fit(); + int matched = zip.fillUncompressedSizes(targets.data(), targets.size(), spineSizes.data(), spineSizes.size()); + LOG_DBG("BMC", "Batch lookup matched %d/%d spine items (arena=%u bytes)", matched, spineCount, + static_cast(metadataArena.used())); useBatchSizes = true; } @@ -263,9 +292,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta if (itemSize > maxStoredCumulativeSize || cumSize > maxStoredCumulativeSize - itemSize) { LOG_ERR("BMC", "Spine cumulative size overflow for item %d (cumSize=%u, itemSize=%zu)", i, cumSize, itemSize); zip.close(); - bookFile.close(); - spineFile.close(); - tocFile.close(); + closeBuildFiles(); return false; } @@ -286,9 +313,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta } // Explicit close() required: member variables persist beyond function scope - bookFile.close(); - spineFile.close(); - tocFile.close(); + closeBuildFiles(); LOG_DBG("BMC", "Successfully built book.bin"); return true; diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index e589f9c497..e65c510e05 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -1,9 +1,10 @@ #pragma once +#include +#include #include #include -#include #include class BookMetadataCache { @@ -61,7 +62,8 @@ class BookMetadataCache { uint16_t hrefLen; // length for collision reduction int16_t spineIndex; }; - std::deque spineHrefIndex; + Arena spineHrefIndexArena; + ArenaVector spineHrefIndex; bool useSpineHrefIndex = false; static constexpr uint16_t LARGE_SPINE_THRESHOLD = 300; @@ -85,7 +87,13 @@ class BookMetadataCache { BookMetadata coreMetadata; explicit BookMetadataCache(std::string cachePath) - : cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} + : cachePath(std::move(cachePath)), + lutOffset(0), + spineCount(0), + tocCount(0), + loaded(false), + buildMode(false), + spineHrefIndex(spineHrefIndexArena) {} ~BookMetadataCache() = default; // Building phase (stream to disk immediately) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 44861f8fd1..5dc77a6a92 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -33,6 +33,7 @@ constexpr char GUIDE_DOT_UTF8[] = "\xc2\xb7"; constexpr uint32_t GUIDE_DOT_CODEPOINT = 0x00B7; constexpr size_t FOCUS_READING_PERCENT = 43; constexpr size_t LAYOUT_ARENA_SLAB_BYTES = 4096; +constexpr size_t INITIAL_TOKEN_VECTOR_RESERVE = 16; bool mayContainRtlBytes(const char* str) { for (const auto* p = reinterpret_cast(str); *p; ++p) { @@ -414,6 +415,29 @@ bool isWordCharacter(uint32_t cp) { } // namespace +void ParsedText::reserveTokenCapacity(const size_t additionalTokens) { + const size_t requiredSize = words.size() + additionalTokens; + if (words.capacity() >= requiredSize && wordStyles.capacity() >= requiredSize && + wordContinues.capacity() >= requiredSize && wordNoSpaceBefore.capacity() >= requiredSize && + wordBionicBoundary.capacity() >= requiredSize && wordGuideDotBefore.capacity() >= requiredSize && + wordBackgroundBlack.capacity() >= requiredSize) { + return; + } + + size_t newCapacity = words.capacity() == 0 ? INITIAL_TOKEN_VECTOR_RESERVE : words.capacity() * 2; + if (newCapacity < requiredSize) { + newCapacity = requiredSize; + } + + words.reserve(newCapacity); + wordStyles.reserve(newCapacity); + wordContinues.reserve(newCapacity); + wordNoSpaceBefore.reserve(newCapacity); + wordBionicBoundary.reserve(newCapacity); + wordGuideDotBefore.reserve(newCapacity); + wordBackgroundBlack.reserve(newCapacity); +} + void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, const bool attachToPrevious, const bool backgroundBlack) { if (word.empty()) return; @@ -436,6 +460,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, bool guideDotBeforeNextToken = false; const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore, const EpdFontFamily::Style tokenStyle, const uint8_t bionicBoundary) { + reserveTokenCapacity(1); words.push_back(std::move(token)); wordStyles.push_back(tokenStyle); wordContinues.push_back(continues); @@ -499,30 +524,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // --- FOCUS READING LOGIC BELOW --- // Pre-reserve capacity to prevent mid-word heap reallocations. - size_t maxPossibleNewTokens = word.length(); - size_t requiredSize = words.size() + maxPossibleNewTokens; - - if (words.capacity() < requiredSize) { - // Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word. - size_t newCapacity = words.capacity() * 2; - - // Ensure the doubled capacity is actually enough for this specific word - if (newCapacity < requiredSize) { - newCapacity = requiredSize; - } - // Set a sensible minimum starting size so the first few words don't trigger tiny reallocations - if (newCapacity < 16) { - newCapacity = 16; - } - - words.reserve(newCapacity); - wordStyles.reserve(newCapacity); - wordContinues.reserve(newCapacity); - wordNoSpaceBefore.reserve(newCapacity); - wordBionicBoundary.reserve(newCapacity); - wordGuideDotBefore.reserve(newCapacity); - wordBackgroundBlack.reserve(newCapacity); - } + reserveTokenCapacity(word.length()); // Lambda helper to process and push individual sub-segments of the string // Use std::string_view to avoid heap allocations when slicing @@ -648,25 +650,35 @@ bool ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo } const int pageWidth = viewportWidth; - auto wordWidths = calculateWordWidths(renderer, fontId); + ArenaVector wordWidths(layoutArena); + if (!calculateWordWidths(wordWidths, renderer, fontId)) { + LOG_ERR("PTX", "OOM allocating word width scratch (%u words)", static_cast(words.size())); + return false; + } - std::vector lineBreakIndices; + ArenaVector naturalGaps(layoutArena); + ArenaVector gapSlots(layoutArena); + ArenaVector lineBreakIndices(layoutArena); + bool breaksOk = false; if (hyphenationEnabled) { // Use greedy layout that can split words mid-loop when a hyphenated prefix fits. - lineBreakIndices = - computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore); + breaksOk = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, + lineBreakIndices); + if (breaksOk) { + breaksOk = calculateGapMetrics(naturalGaps, gapSlots, renderer, fontId); + } } else { - lineBreakIndices = - computeLineBreaks(layoutArena, renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore); + breaksOk = computeLineBreaks(layoutArena, renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, + naturalGaps, gapSlots, lineBreakIndices); } - if (lineBreakIndices.empty()) { + if (!breaksOk || lineBreakIndices.empty()) { return false; } const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; for (size_t i = 0; i < lineCount; ++i) { - if (!extractLine(layoutArena, i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, lineBreakIndices, - processLine, renderer, fontId)) { + if (!extractLine(layoutArena, i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, naturalGaps, gapSlots, + lineBreakIndices, processLine, renderer, fontId)) { return false; } } @@ -685,23 +697,64 @@ bool ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo return true; } -std::vector ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) { - std::vector wordWidths; - wordWidths.reserve(words.size()); +bool ParsedText::layoutAndExtractLinesPreservingSource( + const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, + const std::function)>& processLine) const { + ParsedText layoutProbe(*this); + return layoutProbe.layoutAndExtractLines(renderer, fontId, viewportWidth, processLine); +} + +bool ParsedText::calculateWordWidths(ArenaVector& wordWidths, const GfxRenderer& renderer, const int fontId) { + if (!wordWidths.reserve(words.size())) { + return false; + } for (size_t i = 0; i < words.size(); ++i) { - wordWidths.push_back(measureTokenWidth(renderer, fontId, words[i], wordStyles[i], wordBionicBoundary[i])); + if (!wordWidths.push_back(measureTokenWidth(renderer, fontId, words[i], wordStyles[i], wordBionicBoundary[i]))) { + return false; + } } - return wordWidths; + return true; } -std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const GfxRenderer& renderer, const int fontId, - const int pageWidth, std::vector& wordWidths, - std::vector& continuesVec, - std::vector& noSpaceBeforeVec) { +bool ParsedText::calculateGapMetrics(ArenaVector& naturalGaps, ArenaVector& gapSlots, + const GfxRenderer& renderer, const int fontId) { + if (!naturalGaps.resize(words.size()) || !gapSlots.resize(words.size())) { + LOG_ERR("PTX", "OOM allocating gap metric scratch (%u words)", static_cast(words.size())); + return false; + } + if (words.empty()) { - return {}; + return true; + } + + naturalGaps[0] = 0; + gapSlots[0] = 0; + for (auto& word : words) { + if (containsSoftHyphen(word)) { + stripSoftHyphensInPlace(word); + } + } + for (size_t i = 1; i < words.size(); ++i) { + const bool continues = wordContinues[i]; + const bool noSpaceBefore = wordNoSpaceBefore[i]; + const bool guideDotBefore = wordGuideDotBefore[i]; + naturalGaps[i] = static_cast(naturalGapBeforeToken( + renderer, fontId, words[i - 1], words[i], wordStyles[i - 1], continues, noSpaceBefore, guideDotBefore)); + gapSlots[i] = static_cast( + std::min(UINT8_MAX, gapSlotsBeforeToken(words[i], continues, noSpaceBefore, guideDotBefore))); + } + return true; +} + +bool ParsedText::computeLineBreaks(Arena& scratchArena, const GfxRenderer& renderer, const int fontId, + const int pageWidth, ArenaVector& wordWidths, + std::vector& continuesVec, std::vector& noSpaceBeforeVec, + ArenaVector& naturalGaps, ArenaVector& gapSlots, + ArenaVector& lineBreakIndices) { + if (words.empty()) { + return false; } auto nextTokenAttaches = [&](const size_t index, const size_t totalWordCount) { @@ -721,6 +774,10 @@ std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const Gfx } } + if (!calculateGapMetrics(naturalGaps, gapSlots, renderer, fontId)) { + return false; + } + const size_t totalWordCount = words.size(); // DP table to store the minimum badness (cost) of lines starting at index i @@ -729,7 +786,7 @@ std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const Gfx ArenaVector ans(scratchArena); if (!dp.resize(totalWordCount) || !ans.resize(totalWordCount)) { LOG_ERR("PTX", "OOM allocating line-break scratch (%u words)", static_cast(totalWordCount)); - return {}; + return false; } // Base Case @@ -747,8 +804,7 @@ std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const Gfx // Add space before word j, unless it's the first word on the line or a continuation int gap = 0; if (j > static_cast(i)) { - gap = naturalGapBeforeToken(renderer, fontId, words[j - 1], words[j], wordStyles[j - 1], continuesVec[j], - noSpaceBeforeVec[j], wordGuideDotBefore[j]); + gap = naturalGaps[j]; } currlen += wordWidths[j] + gap; @@ -795,8 +851,6 @@ std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const Gfx } } - // Stores the index of the word that starts the next line (last_word_index + 1) - std::vector lineBreakIndices; size_t currentWordIndex = 0; while (currentWordIndex < totalWordCount) { @@ -808,21 +862,23 @@ std::vector ParsedText::computeLineBreaks(Arena& scratchArena, const Gfx nextBreakIndex = currentWordIndex + 1; } - lineBreakIndices.push_back(nextBreakIndex); + if (!lineBreakIndices.push_back(nextBreakIndex)) { + LOG_ERR("PTX", "OOM growing line-break result scratch"); + return false; + } currentWordIndex = nextBreakIndex; } - return lineBreakIndices; + return !lineBreakIndices.empty(); } // Builds break indices while opportunistically splitting the word that would overflow the current line. -std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, - const int pageWidth, std::vector& wordWidths, - std::vector& continuesVec, - std::vector& noSpaceBeforeVec) { +bool ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, + ArenaVector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec, + ArenaVector& lineBreakIndices) { const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId); - std::vector lineBreakIndices; size_t currentIndex = 0; bool isFirstLine = true; auto currentTokenAttaches = [&](const size_t index) { return index < wordWidths.size() && continuesVec[index]; }; @@ -878,17 +934,20 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r --currentIndex; } - lineBreakIndices.push_back(currentIndex); + if (!lineBreakIndices.push_back(currentIndex)) { + LOG_ERR("PTX", "OOM growing hyphenated line-break scratch"); + return false; + } isFirstLine = false; } - return lineBreakIndices; + return !lineBreakIndices.empty(); } // Splits words[wordIndex] into prefix (adding a hyphen only when needed) and remainder when a legal breakpoint fits the // available width. bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, - const int fontId, std::vector& wordWidths, + const int fontId, ArenaVector& wordWidths, const bool allowFallbackBreaks) { // Guard against invalid indices or zero available width before attempting to split. if (availableWidth <= 0 || wordIndex >= words.size()) { @@ -989,13 +1048,16 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl wordWidths[wordIndex] = static_cast(chosenWidth); const uint16_t remainderWidth = measureTokenWidth(renderer, fontId, remainder, remainderBionic.style, remainderBionic.boundary); - wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); + if (!wordWidths.insert(wordIndex + 1, remainderWidth)) { + LOG_ERR("PTX", "OOM inserting hyphenated word width"); + return false; + } return true; } bool ParsedText::splitPathologicalTokenAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, const int fontId, - std::vector& wordWidths) { + ArenaVector& wordWidths) { if (availableWidth <= 0 || wordIndex >= words.size() || wordIndex >= wordWidths.size()) { return false; } @@ -1051,13 +1113,17 @@ bool ParsedText::splitPathologicalTokenAtIndex(const size_t wordIndex, const int remainder.size() >= PATHOLOGICAL_TOKEN_MIN_BYTES ? std::numeric_limits::max() : measureTokenWidth(renderer, fontId, remainder, remainderBionic.style, remainderBionic.boundary); - wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); + if (!wordWidths.insert(wordIndex + 1, remainderWidth)) { + LOG_ERR("PTX", "OOM inserting split token width"); + return false; + } return true; } bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const int pageWidth, - const std::vector& wordWidths, const std::vector& continuesVec, - const std::vector& noSpaceBeforeVec, const std::vector& lineBreakIndices, + const ArenaVector& wordWidths, const std::vector& continuesVec, + const std::vector& noSpaceBeforeVec, const ArenaVector& naturalGaps, + const ArenaVector& gapSlots, const ArenaVector& lineBreakIndices, const std::function)>& processLine, const GfxRenderer& renderer, const int fontId) { const size_t lineBreak = lineBreakIndices[breakIndex]; @@ -1066,12 +1132,18 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const const int firstLineIndent = resolveFirstLineIndent(breakIndex == 0, renderer, fontId); - std::vector lineWords; - std::vector lineWordStyles; - std::vector lineWordWidths; - std::vector lineBionicBoundary; - std::vector lineGuideDotBefore; - std::vector lineBackgroundBlack; + auto& lineWords = lineWordsScratch; + auto& lineWordStyles = lineStylesScratch; + auto& lineWordWidths = lineWidthsScratch; + auto& lineBionicBoundary = lineBionicBoundaryScratch; + auto& lineGuideDotBefore = lineGuideDotBeforeScratch; + auto& lineBackgroundBlack = lineBackgroundBlackScratch; + lineWords.clear(); + lineWordStyles.clear(); + lineWordWidths.clear(); + lineBionicBoundary.clear(); + lineGuideDotBefore.clear(); + lineBackgroundBlack.clear(); lineWords.reserve(lineWordCount); lineWordStyles.reserve(lineWordCount); lineWordWidths.reserve(lineWordCount); @@ -1082,9 +1154,6 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const for (size_t i = 0; i < lineWordCount; ++i) { const size_t sourceIndex = lastBreakAt + i; std::string word = std::move(words[sourceIndex]); - if (containsSoftHyphen(word)) { - stripSoftHyphensInPlace(word); - } lineWords.push_back(std::move(word)); lineWordStyles.push_back(wordStyles[sourceIndex]); lineWordWidths.push_back(wordWidths[sourceIndex]); @@ -1104,12 +1173,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) { lineWordWidthSum += lineWordWidths[wordIdx]; if (wordIdx > 0) { - const bool continues = continuesVec[lastBreakAt + wordIdx]; - const bool noSpaceBefore = noSpaceBeforeVec[lastBreakAt + wordIdx]; - const bool guideDotBefore = lineGuideDotBefore[wordIdx]; - actualGapCount += gapSlotsBeforeToken(lineWords[wordIdx], continues, noSpaceBefore, guideDotBefore); - totalNaturalGaps += naturalGapBeforeToken(renderer, fontId, lineWords[wordIdx - 1], lineWords[wordIdx], - lineWordStyles[wordIdx - 1], continues, noSpaceBefore, guideDotBefore); + const size_t sourceIndex = lastBreakAt + wordIdx; + actualGapCount += gapSlots[sourceIndex]; + totalNaturalGaps += naturalGaps[sourceIndex]; } } @@ -1277,13 +1343,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const } if (wordIdx + 1 < lineWordCount) { - const bool nextContinues = continuesVec[lastBreakAt + wordIdx + 1]; - const bool nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; - const bool nextGuideDot = lineGuideDotBefore[wordIdx + 1]; - int gap = naturalGapBeforeToken(renderer, fontId, lineWords[wordIdx], lineWords[wordIdx + 1], - lineWordStyles[wordIdx], nextContinues, nextNoSpace, nextGuideDot); - gap += justifyExtra * static_cast(gapSlotsBeforeToken(lineWords[wordIdx + 1], nextContinues, nextNoSpace, - nextGuideDot)); + const size_t nextSourceIndex = lastBreakAt + wordIdx + 1; + int gap = naturalGaps[nextSourceIndex]; + gap += justifyExtra * static_cast(gapSlots[nextSourceIndex]); xpos -= gap; } } @@ -1304,13 +1366,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const int gap = 0; if (wordIdx + 1 < lineWordCount) { - const bool nextContinues = continuesVec[lastBreakAt + wordIdx + 1]; - const bool nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; - const bool nextGuideDot = lineGuideDotBefore[wordIdx + 1]; - gap = naturalGapBeforeToken(renderer, fontId, lineWords[wordIdx], lineWords[wordIdx + 1], - lineWordStyles[wordIdx], nextContinues, nextNoSpace, nextGuideDot); - gap += justifyExtra * static_cast(gapSlotsBeforeToken(lineWords[wordIdx + 1], nextContinues, nextNoSpace, - nextGuideDot)); + const size_t nextSourceIndex = lastBreakAt + wordIdx + 1; + gap = naturalGaps[nextSourceIndex]; + gap += justifyExtra * static_cast(gapSlots[nextSourceIndex]); } if (wordIdx + 1 < lineWordCount) { xpos += lineWordWidths[wordIdx] + gap; @@ -1321,6 +1379,7 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const bool lineHasBionicSplit = false; bool lineHasGuideDot = false; + bool lineHasBackgroundFlags = false; for (size_t i = 0; i < lineWordCount; i++) { if (lineBionicBoundary[i] > 0) { lineHasBionicSplit = true; @@ -1328,7 +1387,10 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const if (i > 0 && lineGuideDotBefore[i]) { lineHasGuideDot = true; } - if (lineHasBionicSplit && lineHasGuideDot) { + if (lineBackgroundBlack[i] != 0) { + lineHasBackgroundFlags = true; + } + if (lineHasBionicSplit && lineHasGuideDot && lineHasBackgroundFlags) { break; } } @@ -1350,7 +1412,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const if (lineHasGuideDot) { outGuideDotXOffset.reserve(lineWordCount); } - outBackgroundBlack.reserve(lineWordCount); + if (lineHasBackgroundFlags) { + outBackgroundBlack.reserve(lineWordCount); + } for (size_t i = 0; i < lineWordCount; i++) { const uint8_t boundary = lineBionicBoundary[i]; @@ -1378,11 +1442,17 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const outGuideDotXOffset[outGuideDotXOffset.size() - 2] = static_cast(dotDelta > 0 ? dotDelta : 0); } } - outBackgroundBlack.push_back(lineBackgroundBlack[i]); + if (lineHasBackgroundFlags) { + outBackgroundBlack.push_back(lineBackgroundBlack[i]); + } } - processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles), - std::move(outBoundaries), std::move(outSuffixX), - std::move(outGuideDotXOffset), std::move(outBackgroundBlack), blockStyle)); + auto block = std::make_shared(outWords, outXPos, outStyles, outBoundaries, outSuffixX, outGuideDotXOffset, + outBackgroundBlack, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return false; + } + processLine(std::move(block)); return true; } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 60d31e5fc0..374510c6ae 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -12,6 +12,8 @@ class GfxRenderer; struct Arena; +template +class ArenaVector; class ParsedText { std::vector words; @@ -36,25 +38,36 @@ class ParsedText { std::vector reorderedBionicBoundaryScratch; std::vector reorderedGuideDotBeforeScratch; std::vector reorderedBackgroundBlackScratch; + std::vector lineWordsScratch; + std::vector lineStylesScratch; + std::vector lineWidthsScratch; + std::vector lineBionicBoundaryScratch; + std::vector lineGuideDotBeforeScratch; + std::vector lineBackgroundBlackScratch; std::vector visualOrderScratch; + void reserveTokenCapacity(size_t additionalTokens); int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const; - std::vector computeLineBreaks(Arena& scratchArena, const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec, - std::vector& noSpaceBeforeVec); - std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec, - std::vector& noSpaceBeforeVec); + bool calculateGapMetrics(ArenaVector& naturalGaps, ArenaVector& gapSlots, + const GfxRenderer& renderer, int fontId); + bool computeLineBreaks(Arena& scratchArena, const GfxRenderer& renderer, int fontId, int pageWidth, + ArenaVector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec, ArenaVector& naturalGaps, + ArenaVector& gapSlots, ArenaVector& lineBreakIndices); + bool computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, + ArenaVector& wordWidths, std::vector& continuesVec, + std::vector& noSpaceBeforeVec, ArenaVector& lineBreakIndices); bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, - std::vector& wordWidths, bool allowFallbackBreaks); + ArenaVector& wordWidths, bool allowFallbackBreaks); bool splitPathologicalTokenAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, - std::vector& wordWidths); - bool extractLine(Arena& scratchArena, size_t breakIndex, int pageWidth, const std::vector& wordWidths, + ArenaVector& wordWidths); + bool extractLine(Arena& scratchArena, size_t breakIndex, int pageWidth, const ArenaVector& wordWidths, const std::vector& continuesVec, const std::vector& noSpaceBeforeVec, - const std::vector& lineBreakIndices, + const ArenaVector& naturalGaps, const ArenaVector& gapSlots, + const ArenaVector& lineBreakIndices, const std::function)>& processLine, const GfxRenderer& renderer, int fontId); - std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); + bool calculateWordWidths(ArenaVector& wordWidths, const GfxRenderer& renderer, int fontId); public: explicit ParsedText(const bool extraParagraphSpacing, const bool forceParagraphIndents = false, @@ -78,4 +91,6 @@ class ParsedText { bool layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function)>& processLine, bool includeLastLine = true); + bool layoutAndExtractLinesPreservingSource(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, + const std::function)>& processLine) const; }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index e2a522fa99..2c1cd83990 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -1,10 +1,13 @@ #include "Section.h" #include +#include #include +#include #include #include #include +#include #include #include "Epub/css/CssParser.h" @@ -14,14 +17,17 @@ 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; +// v44: TextBlock word data is stored as one flat arena with optional bionic, +// guide-dot, and word-flag arrays. +constexpr uint8_t SECTION_FILE_VERSION = 44; 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); +constexpr size_t SECTION_HTML_STREAM_CHUNK_SIZE = 8192; +constexpr size_t LOW_MEMORY_SECTION_HTML_STREAM_CHUNK_SIZE = 1024; struct PageLutEntry { uint32_t fileOffset; @@ -29,7 +35,8 @@ struct PageLutEntry { uint16_t listItemIndex; }; -bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCapacity, const uint16_t lutCount) { +template +bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCapacity, const uint16_t lutCount) { if (lutCount < lutCapacity) return true; if (lutCapacity == UINT16_MAX) return false; @@ -38,7 +45,7 @@ bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCa nextCapacity = UINT16_MAX; } - auto grown = makeUniqueNoThrow(nextCapacity); + auto grown = makeUniqueNoThrow(nextCapacity); if (!grown) return false; for (uint16_t i = 0; i < lutCount; i++) { @@ -48,8 +55,44 @@ bool ensurePageLutCapacity(std::unique_ptr& lut, uint16_t& lutCa lutCapacity = static_cast(nextCapacity); return true; } + +ScratchWorkspace::Lease acquireSectionZipInflateScratch(GfxRenderer& renderer, const int fontId, const char* reason) { + if (ESP.getMaxAllocHeap() < InflateReader::STREAMING_DICT_SIZE && renderer.isSdCardFont(fontId)) { + renderer.releaseSdCardFontForLowMemory(fontId); + } + + auto scratch = ScratchWorkspace::acquire(InflateReader::STREAMING_DICT_SIZE, reason); + if (scratch || !renderer.isSdCardFont(fontId)) { + return scratch; + } + + renderer.releaseSdCardFontForLowMemory(fontId); + return ScratchWorkspace::acquire(InflateReader::STREAMING_DICT_SIZE, reason); +} + +size_t sectionHtmlStreamChunkSize(const bool preview) { + if (preview) { + return LOW_MEMORY_SECTION_HTML_STREAM_CHUNK_SIZE; + } + + const uint32_t maxAlloc = ESP.getMaxAllocHeap(); + const size_t largeStreamBudget = InflateReader::STREAMING_DICT_SIZE + (2U * SECTION_HTML_STREAM_CHUNK_SIZE); + if (maxAlloc < largeStreamBudget) { + LOG_DBG("SCT", "Using low-memory HTML stream chunk (maxAlloc=%u)", maxAlloc); + return LOW_MEMORY_SECTION_HTML_STREAM_CHUNK_SIZE; + } + return SECTION_HTML_STREAM_CHUNK_SIZE; +} } // namespace +Section::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") {} + uint32_t Section::onPageComplete(std::unique_ptr page) { if (!file) { LOG_ERR("SCT", "File not open for writing page %d", pageCount); @@ -61,11 +104,13 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { } const uint32_t position = file.position(); + const uint32_t serializeStart = millis(); if (!page->serialize(file)) { LOG_ERR("SCT", "Failed to serialize page %d", pageCount); return 0; } - LOG_DBG("SCT", "Page %d processed (pos=%lu, free=%u, maxAlloc=%u)", pageCount, static_cast(position), + LOG_DBG("SCT", "Page %d processed (pos=%lu, serialize=%lums, free=%u, maxAlloc=%u)", pageCount, + static_cast(position), static_cast(millis() - serializeStart), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); pageCount++; @@ -199,6 +244,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con clearCache(); return false; } + // Explicit close() required: member variable persists beyond function scope file.close(); LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount); @@ -230,7 +276,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c bool* layoutAbortedForLowMemory, const EpubRenderMode renderMode, const SectionBuildOptions buildOptions) { const auto localPath = epub->getSpineItem(spineIndex).href; - const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; + const auto htmlDir = epub->getCachePath() + "/html"; + const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html"; + const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpSectionPath = filePath + ".tmp"; pageCount = 0; if (layoutAbortedForLowMemory) *layoutAbortedForLowMemory = false; @@ -249,49 +297,81 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c Storage.mkdir(sectionsDir.c_str()); } - // Retry logic for SD card timing issues - bool success = false; - uint32_t fileSize = 0; - for (int attempt = 0; attempt < 3 && !success; attempt++) { - if (attempt > 0) { - LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1); - delay(50); // Brief delay before retry - } - - // Remove any incomplete file from previous attempt before retrying - if (Storage.exists(tmpHtmlPath.c_str())) { + // Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the + // book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation + // that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip + // inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so + // future rebuilds can skip the multi-second inflate. If htmlPath exists it is known-complete. + const bool reusedHtml = Storage.exists(htmlPath.c_str()); + bool htmlCached = reusedHtml; + const auto cleanupTempHtml = [&]() { + if (!htmlCached && Storage.exists(tmpHtmlPath.c_str())) { Storage.remove(tmpHtmlPath.c_str()); } + }; + if (reusedHtml) { + LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str()); + } else { + Storage.mkdir(htmlDir.c_str()); + + // Retry logic for SD card timing issues + bool streamed = false; + uint32_t fileSize = 0; + for (int attempt = 0; attempt < 3 && !streamed; attempt++) { + if (attempt > 0) { + LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1); + delay(50); // Brief delay before retry + } - FsFile tmpHtml; - if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { - continue; + // Remove any incomplete file from previous attempt before retrying + if (Storage.exists(tmpHtmlPath.c_str())) { + Storage.remove(tmpHtmlPath.c_str()); + } + + HalFile tmpHtml; + if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + continue; + } + const size_t htmlStreamChunkSize = sectionHtmlStreamChunkSize(buildOptions.isPreview()); + { + auto zipInflateScratch = acquireSectionZipInflateScratch(renderer, fontId, "section one-shot HTML inflate"); + streamed = epub->readItemContentsToStream(localPath, tmpHtml, htmlStreamChunkSize); + } + fileSize = tmpHtml.size(); + // Explicitly close() file before calling Storage.remove() + tmpHtml.close(); + + // If streaming failed, remove the incomplete file immediately + if (!streamed && Storage.exists(tmpHtmlPath.c_str())) { + Storage.remove(tmpHtmlPath.c_str()); + LOG_DBG("SCT", "Removed incomplete temp file after failed attempt"); + } } - success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); - fileSize = tmpHtml.size(); - // Explicitly close() file before calling Storage.remove() - tmpHtml.close(); - // If streaming failed, remove the incomplete file immediately - if (!success && Storage.exists(tmpHtmlPath.c_str())) { - Storage.remove(tmpHtmlPath.c_str()); - LOG_DBG("SCT", "Removed incomplete temp file after failed attempt"); + if (!streamed) { + LOG_ERR("SCT", "Failed to stream item contents to temp file after retries"); + return false; } - } - if (!success) { - LOG_ERR("SCT", "Failed to stream item contents to temp file after retries"); - return false; - } + LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes, free=%u, maxAlloc=%u)", tmpHtmlPath.c_str(), fileSize, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); - LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes, free=%u, maxAlloc=%u)", tmpHtmlPath.c_str(), fileSize, - ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + // Promote to the persistent HTML cache immediately -- the inflate is complete, and caching it + // lets future section rebuilds skip re-inflation. If the rename fails we just parse the temp. + if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) { + htmlCached = true; + } else { + LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp"); + } + } + const std::string& parsePath = htmlCached ? htmlPath : tmpHtmlPath; if (Storage.exists(tmpSectionPath.c_str())) { Storage.remove(tmpSectionPath.c_str()); } if (!Storage.openFileForWrite("SCT", tmpSectionPath, file)) { + cleanupTempHtml(); return false; } if (!writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, forceParagraphIndents, paragraphAlignment, @@ -300,6 +380,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c LOG_ERR("SCT", "Failed to write section header"); file.close(); Storage.remove(tmpSectionPath.c_str()); + cleanupTempHtml(); return false; } // 1024 entries is 8 KB. Stack is too small, and std::vector growth in the page callback can abort on OOM. @@ -310,6 +391,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c if (layoutAbortedForLowMemory) *layoutAbortedForLowMemory = true; file.close(); Storage.remove(tmpSectionPath.c_str()); + cleanupTempHtml(); return false; } uint16_t lutCount = 0; @@ -353,7 +435,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } ChapterHtmlSlimParser visitor( - epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, forceParagraphIndents, + epub, parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, forceParagraphIndents, paragraphAlignment, viewportWidth, viewportHeight, hyphenationEnabled, effectiveBionicReadingEnabled, effectiveGuideReadingEnabled, [this, &lut, &lutCapacity, &lutCount, &pageCompletionFailed, layoutAbortedForLowMemory]( @@ -383,7 +465,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c buildOptions.isPreview() ? std::string(buildOptions.previewAnchor) : std::string{}, buildOptions.previewMaxPages); Hyphenator::setPreferredLanguage(epub->getLanguage()); LOG_DBG("SCT", "Parser start: spine=%d free=%u maxAlloc=%u", spineIndex, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); - success = visitor.parseAndBuildPages(); + const bool success = visitor.parseAndBuildPages(); LOG_DBG("SCT", "Parser done: spine=%d success=%u pages=%u free=%u maxAlloc=%u", spineIndex, success, pageCount, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); @@ -392,7 +474,21 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c *layoutAbortedForLowMemory = *layoutAbortedForLowMemory || visitor.wasLowMemoryAbortTriggered(); } - Storage.remove(tmpHtmlPath.c_str()); + if (!htmlCached) { + if (success || pageCompletionFailed) { + // Promote the freshly unzipped HTML to the persistent cache so future rebuilds (e.g. after a + // settings change invalidates the layout caches) can skip zip inflation. If promotion fails, + // drop the temp file; the section build can still continue from the already-open source. + if (!Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) { + LOG_DBG("SCT", "Failed to promote HTML cache, removing temp"); + Storage.remove(tmpHtmlPath.c_str()); + } + } else { + // Parse failed on a freshly unzipped file -- discard it rather than caching a bad source. + Storage.remove(tmpHtmlPath.c_str()); + } + } + if (!success || pageCompletionFailed) { LOG_ERR("SCT", "Failed to parse XML and build pages"); // Explicitly close() file before calling Storage.remove() @@ -499,32 +595,93 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return true; } +bool Section::hasHtmlCache() const { + const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html"; + return Storage.exists(htmlPath.c_str()); +} + std::unique_ptr Section::loadPageFromSectionFile() { - if (!Storage.openFileForRead("SCT", filePath, file)) { - return nullptr; + if (!file) { + if (!Storage.openFileForRead("SCT", filePath, file)) { + return nullptr; + } } - if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { + auto closeAndReturnNull = [this]() -> std::unique_ptr { file.close(); return nullptr; + }; + + if (!file.seek(HEADER_SIZE - sizeof(uint32_t) * 4)) { + return closeAndReturnNull(); } uint32_t lutOffset; if (!serialization::tryReadPod(file, lutOffset) || !file.seek(lutOffset + sizeof(uint32_t) * currentPage)) { - file.close(); - return nullptr; + return closeAndReturnNull(); } uint32_t pagePos; if (!serialization::tryReadPod(file, pagePos) || !file.seek(pagePos)) { - file.close(); - return nullptr; + return closeAndReturnNull(); } auto page = Page::deserialize(file); - // Explicit close() required: member variable persists beyond function scope file.close(); return page; } +std::string Section::getTextFromSectionFile() { + std::string fullText; + auto p = loadPageFromSectionFile(); + if (p) { + for (const auto& el : p->elements) { + if (el->getTag() == TAG_PageLine) { + const auto& line = static_cast(*el); + if (line.getBlock()) { + const auto& block = *line.getBlock(); + for (uint16_t i = 0; i < block.wordCount(); i++) { + if (!fullText.empty()) fullText += " "; + fullText += block.wordText(i); + } + } + } + } + } + return fullText; +} + +std::optional Section::getCachedPageCount() const { + FsFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + if (fileSize < HEADER_SIZE) { + return std::nullopt; + } + + uint32_t magic; + if (!serialization::tryReadPod(f, magic) || magic != SECTION_CACHE_MAGIC) { + return std::nullopt; + } + uint8_t version; + if (!serialization::tryReadPod(f, version)) { + return std::nullopt; + } + if (version != SECTION_FILE_VERSION) { + return std::nullopt; + } + + if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t))) { + return std::nullopt; + } + uint16_t count; + if (!serialization::tryReadPod(f, count)) { + return std::nullopt; + } + return count; +} + std::optional Section::getPageForAnchor(const std::string& anchor) const { FsFile f; if (!Storage.openFileForRead("SCT", filePath, f)) { @@ -657,6 +814,61 @@ std::optional Section::getParagraphIndexForPage(const uint16_t page) c return pIdx; } +std::optional Section::getListItemIndexForPage(const uint16_t page) const { + FsFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + if (!f.seek(HEADER_SIZE - sizeof(uint32_t))) { + return std::nullopt; + } + uint32_t liLutOffset; + if (!serialization::tryReadPod(f, liLutOffset)) { + return std::nullopt; + } + if (liLutOffset == 0 || liLutOffset >= fileSize) { + return std::nullopt; + } + + if (!f.seek(HEADER_SIZE - sizeof(uint32_t) * 2)) { + return std::nullopt; + } + uint32_t paragraphLutOffset; + if (!serialization::tryReadPod(f, paragraphLutOffset)) { + return std::nullopt; + } + if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { + return std::nullopt; + } + + if (!f.seek(paragraphLutOffset)) { + return std::nullopt; + } + uint16_t count; + if (!serialization::tryReadPod(f, count)) { + return std::nullopt; + } + if (count == 0 || page >= count) { + return std::nullopt; + } + + const uint32_t entryEnd = liLutOffset + (page + 1) * sizeof(uint16_t); + if (entryEnd > fileSize) { + return std::nullopt; + } + + if (!f.seek(liLutOffset + page * sizeof(uint16_t))) { + return std::nullopt; + } + uint16_t liIdx; + if (!serialization::tryReadPod(f, liIdx)) { + return std::nullopt; + } + return liIdx; +} + std::optional Section::getPageForListItemIndex(const uint16_t liIndex) const { FsFile f; if (!Storage.openFileForRead("SCT", filePath, f)) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 5370926c2b..3fe91cf7b2 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -23,7 +23,6 @@ class Section { GfxRenderer& renderer; std::string filePath; HalFile file; - 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, @@ -34,13 +33,8 @@ class Section { uint16_t pageCount = 0; int currentPage = 0; - explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer, - const char* cacheSuffix = "") - : epub(epub), - spineIndex(spineIndex), - renderer(renderer), - filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + (cacheSuffix ? cacheSuffix : "") + - ".bin") {} + explicit Section(const std::shared_ptr& epub, int spineIndex, GfxRenderer& renderer, + const char* cacheSuffix = ""); ~Section() = default; bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, @@ -54,11 +48,20 @@ class Section { bool* imagesWereSuppressed = nullptr, bool* layoutAbortedForLowMemory = nullptr, EpubRenderMode renderMode = EpubRenderMode::CrossInkDefault, SectionBuildOptions buildOptions = {}); + std::unique_ptr loadPageFromSectionFile(); + std::string getTextFromSectionFile(); + + // True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a + // giant spine) zip inflation. + bool hasHtmlCache() const; // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; + // Get the page count from the section cache file without fully loading it. + std::optional getCachedPageCount() const; + // Look up the page number for a synthetic paragraph index from XPath p[N]. std::optional getPageForParagraphIndex(uint16_t pIndex) const; @@ -67,4 +70,7 @@ class Section { // Look up the synthetic paragraph index for the given rendered page. std::optional getParagraphIndexForPage(uint16_t page) const; + + // Look up the running list-item index for the given rendered page. + std::optional getListItemIndexForPage(uint16_t page) const; }; diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index bb9d7ec061..3b1f008a91 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -29,6 +29,35 @@ std::string getCachePath(const std::string& imagePath) { return imagePath + ".pxc"; } +void clampCachedRowsToLandscapeStrip(const GfxRenderer& renderer, const int imageY, int& rowStart, int& rowEnd) { + if (!renderer.isStripTargetActive()) { + return; + } + + const int stripY0 = renderer.getWriteOriginY(); + const int stripY1Exclusive = stripY0 + renderer.getWriteRows(); + int logicalY0; + int logicalY1Exclusive; + + switch (renderer.getOrientation()) { + case GfxRenderer::LandscapeCounterClockwise: + logicalY0 = stripY0; + logicalY1Exclusive = stripY1Exclusive; + break; + case GfxRenderer::LandscapeClockwise: + logicalY0 = renderer.getDisplayHeight() - stripY1Exclusive; + logicalY1Exclusive = renderer.getDisplayHeight() - stripY0; + break; + default: + return; + } + + const int stripRowStart = logicalY0 - imageY; + const int stripRowEnd = logicalY1Exclusive - imageY; + if (rowStart < stripRowStart) rowStart = stripRowStart; + if (rowEnd > stripRowEnd) rowEnd = stripRowEnd; +} + bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth, int expectedHeight) { FsFile cacheFile; @@ -75,15 +104,24 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, return true; } + int renderRowStart = clipYStart; + int renderRowEnd = clipYEnd; + clampCachedRowsToLandscapeStrip(renderer, y, renderRowStart, renderRowEnd); + if (renderRowStart >= renderRowEnd) { + cacheFile.close(); + return true; + } + // Read several rows per SD access. A full-page image is re-rendered on every // grayscale strip pass (~14x per page), and a one-row-per-read loop here means // cachedHeight (~728) tiny reads through the storage mutex + SdFat each time — // the dominant cost of displaying an image page. Batching rows into a ~4KB // buffer cuts that to ~20 reads per pass without holding the whole image. const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte + const int rowsToRender = renderRowEnd - renderRowStart; int rowsPerRead = 4096 / bytesPerRow; if (rowsPerRead < 1) rowsPerRead = 1; - if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight; + if (rowsPerRead > rowsToRender) rowsPerRead = rowsToRender; uint8_t* readBuffer = (uint8_t*)malloc((size_t)rowsPerRead * bytesPerRow); if (!readBuffer) { // Fall back to a single-row buffer under memory pressure. @@ -99,11 +137,19 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, DirectPixelWriter pw; pw.init(renderer); + const size_t dataOffset = 4U + static_cast(renderRowStart) * static_cast(bytesPerRow); + if (!cacheFile.seek(dataOffset)) { + LOG_ERR("IMG", "Cache seek error at row %d", renderRowStart); + free(readBuffer); + cacheFile.close(); + return false; + } + int rowsInBuffer = 0; int bufferRow = 0; - for (int row = 0; row < cachedHeight; row++) { + for (int row = renderRowStart; row < renderRowEnd; row++) { if (bufferRow >= rowsInBuffer) { - const int toRead = (cachedHeight - row < rowsPerRead) ? (cachedHeight - row) : rowsPerRead; + const int toRead = (renderRowEnd - row < rowsPerRead) ? (renderRowEnd - row) : rowsPerRead; const size_t bytes = (size_t)toRead * bytesPerRow; if (cacheFile.read(readBuffer, bytes) != static_cast(bytes)) { LOG_ERR("IMG", "Cache read error at row %d", row); diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 848461af2f..634341e8c4 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -11,38 +12,31 @@ namespace { constexpr uint16_t MAX_WORDS_PER_TEXT_BLOCK = 512; -constexpr uint32_t MAX_SERIALIZED_WORD_BYTES = 4096; -constexpr uint32_t SERIALIZED_TEXT_BLOCK_TAIL_BYTES = - sizeof(EpdFontFamily::Style) + sizeof(bool) + sizeof(int16_t) * 7 + sizeof(bool) + sizeof(bool) + sizeof(bool); -constexpr uint32_t SERIALIZED_MIN_WORD_METADATA_BYTES = - sizeof(uint32_t) + sizeof(int16_t) + sizeof(EpdFontFamily::Style) + sizeof(uint8_t); -constexpr uint32_t SERIALIZED_POST_WORD_MIN_METADATA_BYTES = - sizeof(int16_t) + sizeof(EpdFontFamily::Style) + sizeof(uint8_t); - -uint16_t measureBackgroundWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, + +uint16_t measureBackgroundWidth(const GfxRenderer& renderer, const int fontId, const char* word, const EpdFontFamily::Style style) { - if (word.size() == 1 && word[0] == ' ') { + if (word[0] == ' ' && word[1] == '\0') { return renderer.getSpaceWidth(fontId, style); } - return static_cast(std::max(0, renderer.getTextAdvanceX(fontId, word.c_str(), style))); + return static_cast(std::max(0, renderer.getTextAdvanceX(fontId, word, style))); } -bool isWhitespaceOnlyBackgroundToken(const std::string& word) { - if (word.empty()) { +bool isWhitespaceOnlyBackgroundToken(const char* word) { + if (!word || *word == '\0') { return false; } - for (size_t i = 0; i < word.size();) { + for (size_t i = 0; word[i] != '\0';) { const auto c = static_cast(word[i]); if (c == ' ' || c == '\r' || c == '\n' || c == '\t') { ++i; continue; } - if (c == 0xC2 && i + 1 < word.size() && static_cast(word[i + 1]) == 0xA0) { + if (c == 0xC2 && word[i + 1] != '\0' && static_cast(word[i + 1]) == 0xA0) { i += 2; continue; } - if (c == 0xE2 && i + 2 < word.size() && static_cast(word[i + 1]) == 0x80 && + if (c == 0xE2 && word[i + 1] != '\0' && word[i + 2] != '\0' && static_cast(word[i + 1]) == 0x80 && static_cast(word[i + 2]) == 0xAF) { i += 3; continue; @@ -53,74 +47,176 @@ bool isWhitespaceOnlyBackgroundToken(const std::string& word) { return true; } -bool readBoundedString(FsFile& file, std::string& s) { - uint32_t len = 0; - if (!serialization::tryReadPod(file, len)) { - LOG_ERR("TXB", "Deserialization failed: could not read word length"); - return false; +bool hasSyntheticIndentPrefix(const char* word, const uint16_t len) { + return len >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83; +} + +} // namespace + +size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasBionic, const bool hasGuideDots, + const bool hasWordFlags, const uint16_t textBytes) { + // 16-bit arrays first so direct loads stay aligned on RISC-V, then byte arrays, then text. + size_t size = static_cast(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t)); + if (hasBionic) { + size += static_cast(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t)); } - if (len > MAX_SERIALIZED_WORD_BYTES) { - LOG_ERR("TXB", "Deserialization failed: word length %lu exceeds maximum", static_cast(len)); - return false; + if (hasGuideDots) { + size += static_cast(wordCount) * sizeof(uint16_t); + } + if (hasWordFlags) { + size += static_cast(wordCount) * sizeof(uint8_t); } + return size + textBytes; +} - const int remaining = file.available(); - if (remaining < 0 || static_cast(remaining) < len) { - LOG_ERR("TXB", "Deserialization failed: truncated word payload (%lu bytes requested, %d available)", - static_cast(len), remaining); - return false; +void TextBlock::bindArenaPointers() { + uint8_t* base = arena.get(); + const size_t wc = numWords; + textOffArr = reinterpret_cast(base); + xposArr = reinterpret_cast(base + wc * 2); + size_t off = wc * 4; + if (bionicPresent) { + bionicSuffixXArr = reinterpret_cast(base + off); + off += wc * 2; + } + if (guideDotsPresent) { + guideDotXOffsetArr = reinterpret_cast(base + off); + off += wc * 2; + } + stylesArr = base + off; + off += wc; + if (bionicPresent) { + bionicBoundaryArr = base + off; + off += wc; + } + if (wordFlagsPresent) { + wordFlagsArr = base + off; + off += wc; + } + textArr = reinterpret_cast(base + off); +} + +TextBlock::TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& bionicBoundary, + const std::vector& bionicSuffixX, const std::vector& guideDotXOffset, + const std::vector& wordFlags, const BlockStyle& blockStyle) + : blockStyle(blockStyle) { + const bool hasBionic = !bionicBoundary.empty(); + const bool hasGuideDots = !guideDotXOffset.empty(); + const bool hasWordFlags = !wordFlags.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > MAX_WORDS_PER_TEXT_BLOCK || + (hasBionic && (words.size() != bionicBoundary.size() || words.size() != bionicSuffixX.size())) || + (!hasBionic && !bionicSuffixX.empty()) || (hasGuideDots && words.size() != guideDotXOffset.size()) || + (hasWordFlags && words.size() != wordFlags.size())) { + LOG_ERR("TXB", + "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, " + "dotX=%u, flags=%u)", + static_cast(words.size()), static_cast(wordXpos.size()), + static_cast(wordStyles.size()), static_cast(bionicBoundary.size()), + static_cast(bionicSuffixX.size()), static_cast(guideDotXOffset.size()), + static_cast(wordFlags.size())); + isValid = false; + return; } - if (len == 0) { - s.clear(); - return true; + numWords = static_cast(words.size()); + bionicPresent = hasBionic; + guideDotsPresent = hasGuideDots; + wordFlagsPresent = hasWordFlags; + if (numWords == 0) { + return; } - s.resize(len); - if (file.read(&s[0], len) != static_cast(len)) { - LOG_ERR("TXB", "Deserialization failed: could not read %lu-byte word payload", static_cast(len)); - return false; + size_t totalText = 0; + for (const auto& word : words) { + totalText += word.size() + 1; + } + if (totalText > UINT16_MAX) { + LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast(totalText)); + numWords = 0; + bionicPresent = false; + guideDotsPresent = false; + wordFlagsPresent = false; + isValid = false; + return; + } + textBytes = static_cast(totalText); + + const size_t size = arenaSize(numWords, bionicPresent, guideDotsPresent, wordFlagsPresent, textBytes); + arena = makeUniqueNoThrow(size); + if (!arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + numWords = 0; + textBytes = 0; + bionicPresent = false; + guideDotsPresent = false; + wordFlagsPresent = false; + isValid = false; + return; + } + bindArenaPointers(); + + auto* textOff = const_cast(textOffArr); + auto* xpos = const_cast(xposArr); + auto* styles = const_cast(stylesArr); + auto* text = const_cast(textArr); + uint16_t off = 0; + for (uint16_t i = 0; i < numWords; i++) { + textOff[i] = off; + xpos[i] = wordXpos[i]; + styles[i] = static_cast(wordStyles[i]); + memcpy(text + off, words[i].data(), words[i].size()); + off += static_cast(words[i].size()); + text[off++] = '\0'; + } + if (bionicPresent) { + auto* suffixX = const_cast(bionicSuffixXArr); + auto* boundary = const_cast(bionicBoundaryArr); + for (uint16_t i = 0; i < numWords; i++) { + suffixX[i] = bionicSuffixX[i]; + boundary[i] = bionicBoundary[i]; + } + } + if (guideDotsPresent) { + auto* dotX = const_cast(guideDotXOffsetArr); + for (uint16_t i = 0; i < numWords; i++) { + dotX[i] = guideDotXOffset[i]; + } + } + if (wordFlagsPresent) { + auto* flags = const_cast(wordFlagsArr); + for (uint16_t i = 0; i < numWords; i++) { + flags[i] = wordFlags[i]; + } } - return true; } -} // namespace - void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y, const bool foregroundBlack) const { - // Validate iterator bounds before rendering - const bool hasBionic = !wordBionicBoundary.empty(); - const bool hasGuideDots = !wordGuideDotXOffset.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - words.size() != wordBackgroundBlack.size() || - (hasBionic && (words.size() != wordBionicBoundary.size() || words.size() != wordBionicSuffixX.size())) || - (!hasBionic && !wordBionicSuffixX.empty()) || (hasGuideDots && words.size() != wordGuideDotXOffset.size())) { - LOG_ERR("TXB", - "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, dotX=%u, bg=%u)\n", - (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), - (uint32_t)wordBionicBoundary.size(), (uint32_t)wordBionicSuffixX.size(), - (uint32_t)wordGuideDotXOffset.size(), (uint32_t)wordBackgroundBlack.size()); + if (!isValid) { + LOG_ERR("TXB", "Render skipped: invalid block"); return; } const bool scanning = renderer.isFontCacheScanning(); const int ascender = renderer.getFontAscenderSize(fontId); - for (size_t i = 0; i < words.size(); i++) { - const int wordX = wordXpos[i] + x; - const EpdFontFamily::Style currentStyle = wordStyles[i]; - const uint8_t boundary = hasBionic ? wordBionicBoundary[i] : 0; - const auto baseDir = static_cast( - BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0)); - - if ((wordBackgroundBlack[i] & WORD_FLAG_BACKGROUND_BLACK) != 0 && isWhitespaceOnlyBackgroundToken(words[i])) { - const uint16_t backgroundWidth = measureBackgroundWidth(renderer, fontId, words[i], currentStyle); + for (uint16_t i = 0; i < numWords; i++) { + const char* word = wordText(i); + const uint16_t wordLen = wordTextLen(i); + const int wordX = wordXpos(i) + x; + const EpdFontFamily::Style currentStyle = wordStyle(i); + const uint8_t boundary = bionicBoundary(i); + const auto baseDir = + static_cast(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0)); + + if ((wordFlags(i) & WORD_FLAG_BACKGROUND_BLACK) != 0 && isWhitespaceOnlyBackgroundToken(word)) { + const uint16_t backgroundWidth = measureBackgroundWidth(renderer, fontId, word, currentStyle); if (backgroundWidth > 0) { renderer.fillRect(wordX, y, backgroundWidth, renderer.getFontAscenderSize(fontId), true); } } - // SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside - // drawText, so these offsets are chosen relative to the full-size ascender. int wordY = y; if ((currentStyle & EpdFontFamily::SUP) != 0) { wordY -= ascender * 2 / 5; @@ -129,39 +225,34 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } if (boundary > 0) { - // Bionic split: draw bold prefix (max 9 codepoints = 36 UTF-8 bytes + null). - // suffixX is pre-computed at cache creation time to avoid font metric lookups at render time. const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); char boldBuf[40]; - const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); - memcpy(boldBuf, words[i].c_str(), boldLen); + const size_t boldLen = + std::min({static_cast(boundary), static_cast(wordLen), sizeof(boldBuf) - 1}); + memcpy(boldBuf, word, boldLen); boldBuf[boldLen] = '\0'; renderer.drawText(fontId, wordX, wordY, boldBuf, foregroundBlack, boldStyle, baseDir); - const int suffixX = wordX + wordBionicSuffixX[i]; - renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, foregroundBlack, currentStyle, baseDir); + renderer.drawText(fontId, wordX + bionicSuffixX(i), wordY, word + boldLen, foregroundBlack, currentStyle, + baseDir); } else { - renderer.drawText(fontId, wordX, wordY, words[i].c_str(), foregroundBlack, currentStyle, baseDir); + renderer.drawText(fontId, wordX, wordY, word, foregroundBlack, currentStyle, baseDir); } - if (hasGuideDots && wordGuideDotXOffset[i] > 0) { - renderer.drawText(fontId, wordX + wordGuideDotXOffset[i], wordY, "\xc2\xb7", foregroundBlack, - EpdFontFamily::REGULAR, baseDir); + const uint16_t dotOffset = guideDotXOffset(i); + if (dotOffset > 0) { + renderer.drawText(fontId, wordX + dotOffset, wordY, "\xc2\xb7", foregroundBlack, EpdFontFamily::REGULAR, baseDir); } if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) { - const std::string& w = words[i]; int startX = wordX; - int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); + int underlineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); const int underlineY = wordY + ascender + 2; - // Skip em-space prefix so the underline starts under visible glyphs. - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visiblePtr = w.c_str() + 3; + if (hasSyntheticIndentPrefix(word, wordLen)) { + const char* visiblePtr = word + 3; const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); - const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle, baseDir); startX = wordX + prefixWidth; - underlineWidth = visibleWidth; + underlineWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle, baseDir); } if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { @@ -172,25 +263,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } if ((currentStyle & EpdFontFamily::STRIKETHROUGH) != 0) { - const std::string& w = words[i]; - const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); - // Position at roughly mid-glyph height. Offset down from the half-ascender - // point to align with the visual centre of lowercase letters. - // Added a 6 pixel offset after testing on various fonts to improve the visual alignment of the strike-through - // line. - const int strikeY = y + renderer.getFontAscenderSize(fontId) / 2 + 6; - int startX = wordX; - int strikeWidth = fullWordWidth; + int strikeWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); + const int strikeY = y + renderer.getFontAscenderSize(fontId) / 2 + 6; - // Skip em-space prefix same as underline does - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visiblePtr = w.c_str() + 3; + if (hasSyntheticIndentPrefix(word, wordLen)) { + const char* visiblePtr = word + 3; const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); - const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle, baseDir); startX = wordX + prefixWidth; - strikeWidth = visibleWidth; + strikeWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle, baseDir); } if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { @@ -202,64 +283,28 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } } -bool TextBlock::serialize(FsFile& file) const { - const bool hasBionic = !wordBionicBoundary.empty(); - const bool hasGuideDots = !wordGuideDotXOffset.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - words.size() != wordBackgroundBlack.size() || - (hasBionic && (words.size() != wordBionicBoundary.size() || words.size() != wordBionicSuffixX.size())) || - (!hasBionic && !wordBionicSuffixX.empty()) || (hasGuideDots && words.size() != wordGuideDotXOffset.size())) { - LOG_ERR( - "TXB", - "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, dotX=%u, bg=%u)\n", - static_cast(words.size()), static_cast(wordXpos.size()), - static_cast(wordStyles.size()), static_cast(wordBionicBoundary.size()), - static_cast(wordBionicSuffixX.size()), static_cast(wordGuideDotXOffset.size()), - static_cast(wordBackgroundBlack.size())); +bool TextBlock::serialize(HalFile& file) const { + if (!isValid) { + LOG_ERR("TXB", "Serialization failed: invalid block"); return false; } - // Word data - if (!serialization::tryWritePod(file, static_cast(words.size()))) { - LOG_ERR("TXB", "Serialization failed: could not write word count"); + if (!serialization::tryWritePod(file, numWords) || + !serialization::tryWritePod(file, static_cast(bionicPresent ? 1 : 0)) || + !serialization::tryWritePod(file, static_cast(guideDotsPresent ? 1 : 0)) || + !serialization::tryWritePod(file, static_cast(wordFlagsPresent ? 1 : 0)) || + !serialization::tryWritePod(file, textBytes)) { + LOG_ERR("TXB", "Serialization failed: could not write block header"); return false; } - for (const auto& w : words) { - if (!serialization::tryWriteString(file, w)) { - LOG_ERR("TXB", "Serialization failed: could not write word payload"); + if (numWords > 0) { + const size_t size = arenaSize(numWords, bionicPresent, guideDotsPresent, wordFlagsPresent, textBytes); + if (file.write(arena.get(), size) != static_cast(size)) { + LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast(size)); return false; } } - for (auto x : wordXpos) { - if (!serialization::tryWritePod(file, x)) return false; - } - for (auto s : wordStyles) { - if (!serialization::tryWritePod(file, s)) return false; - } - if (!serialization::tryWritePod(file, static_cast(hasBionic ? 1 : 0))) { - return false; - } - if (hasBionic) { - for (auto b : wordBionicBoundary) { - if (!serialization::tryWritePod(file, b)) return false; - } - for (auto sx : wordBionicSuffixX) { - if (!serialization::tryWritePod(file, sx)) return false; - } - } - if (!serialization::tryWritePod(file, static_cast(hasGuideDots ? 1 : 0))) { - return false; - } - if (hasGuideDots) { - for (auto dx : wordGuideDotXOffset) { - if (!serialization::tryWritePod(file, dx)) return false; - } - } - for (auto bg : wordBackgroundBlack) { - if (!serialization::tryWritePod(file, bg)) return false; - } - // Style (alignment + margins/padding/indent) return serialization::tryWritePod(file, blockStyle.alignment) && serialization::tryWritePod(file, blockStyle.textAlignDefined) && serialization::tryWritePod(file, blockStyle.marginTop) && @@ -276,96 +321,78 @@ bool TextBlock::serialize(FsFile& file) const { serialization::tryWritePod(file, blockStyle.directionDefined); } -std::unique_ptr TextBlock::deserialize(FsFile& file) { - uint16_t wc; - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - std::vector wordBionicBoundary; - std::vector wordBionicSuffixX; - std::vector wordGuideDotXOffset; - std::vector wordBackgroundBlack; - BlockStyle blockStyle; - - // Word count - if (!serialization::tryReadPod(file, wc)) { - LOG_ERR("TXB", "Deserialization failed: could not read word count"); +std::unique_ptr TextBlock::deserialize(HalFile& file) { + uint16_t wc = 0; + uint8_t hasBionic = 0; + uint8_t hasGuideDots = 0; + uint8_t hasWordFlags = 0; + uint16_t textBytes = 0; + if (!serialization::tryReadPod(file, wc) || !serialization::tryReadPod(file, hasBionic) || + !serialization::tryReadPod(file, hasGuideDots) || !serialization::tryReadPod(file, hasWordFlags) || + !serialization::tryReadPod(file, textBytes)) { + LOG_ERR("TXB", "Deserialization failed: could not read block header"); return nullptr; } - // A TextBlock is one rendered line of text, so counts far above a few hundred are not legitimate. - // Clamp aggressively here so corrupted cache data cannot trigger huge STL allocations on the ESP32-C3. if (wc > MAX_WORDS_PER_TEXT_BLOCK) { LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc); return nullptr; } - - const uint32_t minimumRemainingBytes = static_cast(wc) * SERIALIZED_MIN_WORD_METADATA_BYTES + - sizeof(uint8_t) + sizeof(uint8_t) + SERIALIZED_TEXT_BLOCK_TAIL_BYTES; - const int remainingBeforeWords = file.available(); - if (remainingBeforeWords < 0 || static_cast(remainingBeforeWords) < minimumRemainingBytes) { - LOG_ERR("TXB", "Deserialization failed: truncated block metadata (%u words need at least %lu bytes, %d available)", - wc, static_cast(minimumRemainingBytes), remainingBeforeWords); + if (hasBionic > 1 || hasGuideDots > 1 || hasWordFlags > 1) { + LOG_ERR("TXB", "Deserialization failed: invalid metadata flags"); return nullptr; } - - // Word data - words.resize(wc); - wordXpos.resize(wc); - wordStyles.resize(wc); - wordBackgroundBlack.resize(wc); - for (auto& w : words) { - if (!readBoundedString(file, w)) { - return nullptr; - } - } - - const uint32_t remainingMetadataBytes = static_cast(wc) * SERIALIZED_POST_WORD_MIN_METADATA_BYTES + - sizeof(uint8_t) + sizeof(uint8_t) + SERIALIZED_TEXT_BLOCK_TAIL_BYTES; - const int remainingAfterWords = file.available(); - if (remainingAfterWords < 0 || static_cast(remainingAfterWords) < remainingMetadataBytes) { - LOG_ERR("TXB", "Deserialization failed: truncated post-word metadata (%lu bytes needed, %d available)", - static_cast(remainingMetadataBytes), remainingAfterWords); + if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) { + LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc); return nullptr; } - for (auto& x : wordXpos) { - if (!serialization::tryReadPod(file, x)) return nullptr; - } - for (auto& s : wordStyles) { - if (!serialization::tryReadPod(file, s)) return nullptr; - } - uint8_t hasBionic = 0; - if (!serialization::tryReadPod(file, hasBionic) || hasBionic > 1) { - LOG_ERR("TXB", "Deserialization failed: invalid bionic metadata flag"); + std::unique_ptr block(new (std::nothrow) TextBlock()); + if (!block) { + LOG_ERR("TXB", "Deserialization failed: could not allocate TextBlock"); return nullptr; } - if (hasBionic) { - wordBionicBoundary.resize(wc); - wordBionicSuffixX.resize(wc); - for (auto& b : wordBionicBoundary) { - if (!serialization::tryReadPod(file, b)) return nullptr; + block->numWords = wc; + block->textBytes = textBytes; + block->bionicPresent = hasBionic != 0; + block->guideDotsPresent = hasGuideDots != 0; + block->wordFlagsPresent = hasWordFlags != 0; + + if (wc > 0) { + const size_t size = + arenaSize(wc, block->bionicPresent, block->guideDotsPresent, block->wordFlagsPresent, textBytes); + const int remaining = file.available(); + if (remaining < 0 || static_cast(remaining) < size) { + LOG_ERR("TXB", "Deserialization failed: truncated arena (%u bytes needed, %d available)", + static_cast(size), remaining); + return nullptr; } - for (auto& sx : wordBionicSuffixX) { - if (!serialization::tryReadPod(file, sx)) return nullptr; + block->arena = makeUniqueNoThrow(size); + if (!block->arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + return nullptr; } - } - uint8_t hasGuideDots = 0; - if (!serialization::tryReadPod(file, hasGuideDots) || hasGuideDots > 1) { - LOG_ERR("TXB", "Deserialization failed: invalid guide-dot metadata flag"); - return nullptr; - } - if (hasGuideDots) { - wordGuideDotXOffset.resize(wc); - for (auto& dx : wordGuideDotXOffset) { - if (!serialization::tryReadPod(file, dx)) return nullptr; + if (file.read(block->arena.get(), size) != static_cast(size)) { + LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast(size)); + return nullptr; + } + block->bindArenaPointers(); + + const uint16_t* textOff = block->textOffArr; + const char* text = block->textArr; + if (textOff[0] != 0 || text[textBytes - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt text layout"); + return nullptr; + } + for (uint16_t i = 1; i < wc; i++) { + if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i); + return nullptr; + } } - } - for (auto& bg : wordBackgroundBlack) { - if (!serialization::tryReadPod(file, bg)) return nullptr; } - // Style (alignment + margins/padding/indent) + BlockStyle& blockStyle = block->blockStyle; if (!serialization::tryReadPod(file, blockStyle.alignment) || !serialization::tryReadPod(file, blockStyle.textAlignDefined) || !serialization::tryReadPod(file, blockStyle.marginTop) || @@ -384,13 +411,5 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { return nullptr; } - auto* textBlock = new (std::nothrow) TextBlock( - std::move(words), std::move(wordXpos), std::move(wordStyles), std::move(wordBionicBoundary), - std::move(wordBionicSuffixX), std::move(wordGuideDotXOffset), std::move(wordBackgroundBlack), blockStyle); - if (!textBlock) { - LOG_ERR("TXB", "Deserialization failed: could not allocate TextBlock"); - return nullptr; - } - - return std::unique_ptr(textBlock); + return block; } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index a0f343ac1e..635e050b58 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -9,57 +9,63 @@ #include "Block.h" #include "BlockStyle.h" -// Represents a line of text on a page +// Represents one rendered line. Per-word data is packed into one heap arena +// instead of separate vectors/strings, which reduces heap fragmentation when +// pages are loaded and discarded repeatedly on the ESP32-C3. class TextBlock final : public Block { private: - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - // Per-word bionic boundary: N > 0 means the first N bytes of words[i] are rendered bold, - // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). - // Empty when no word in the block has a bionic split. - std::vector wordBionicBoundary; - // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. - // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. - // Empty in lockstep with wordBionicBoundary. - std::vector wordBionicSuffixX; - // Pre-computed pixel offset from word start to the guide dot that follows it. 0 = no dot. - // Eliminates the guide dot as a separate TextBlock entry, saving ~12 bytes per inter-word gap. - // Empty when no word in the block has a guide dot. - std::vector wordGuideDotXOffset; - // Per-word flags. Bit 0 paints a simple black CSS background behind this word/token. - // Bit 1 marks a visible hyphen inserted by layout/hyphenation, not the EPUB text. - std::vector wordBackgroundBlack; BlockStyle blockStyle; + uint16_t numWords = 0; + uint16_t textBytes = 0; // Total size of the text region, including NULs. + bool bionicPresent = false; + bool guideDotsPresent = false; + bool wordFlagsPresent = false; + bool isValid = true; + std::unique_ptr arena; + + const uint16_t* textOffArr = nullptr; + const int16_t* xposArr = nullptr; + const uint16_t* bionicSuffixXArr = nullptr; // null when !bionicPresent + const uint16_t* guideDotXOffsetArr = nullptr; // null when !guideDotsPresent + const uint8_t* stylesArr = nullptr; + const uint8_t* bionicBoundaryArr = nullptr; // null when !bionicPresent + const uint8_t* wordFlagsArr = nullptr; // null when !wordFlagsPresent + const char* textArr = nullptr; + + TextBlock() = default; // deserialize() fills the fields directly. + static size_t arenaSize(uint16_t wordCount, bool hasBionic, bool hasGuideDots, bool hasWordFlags, uint16_t textBytes); + void bindArenaPointers(); public: static constexpr uint8_t WORD_FLAG_BACKGROUND_BLACK = 0x01; static constexpr uint8_t WORD_FLAG_INSERTED_HYPHEN = 0x02; - explicit TextBlock(std::vector words, std::vector word_xpos, - std::vector word_styles, std::vector bionic_boundary, - std::vector bionic_suffix_x, std::vector guide_dot_x_offset, - std::vector background_black, const BlockStyle& blockStyle = BlockStyle()) - : words(std::move(words)), - wordXpos(std::move(word_xpos)), - wordStyles(std::move(word_styles)), - wordBionicBoundary(std::move(bionic_boundary)), - wordBionicSuffixX(std::move(bionic_suffix_x)), - wordGuideDotXOffset(std::move(guide_dot_x_offset)), - wordBackgroundBlack(std::move(background_black)), - blockStyle(blockStyle) {} + explicit TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& bionicBoundary, + const std::vector& bionicSuffixX, const std::vector& guideDotXOffset, + const std::vector& wordFlags, const BlockStyle& blockStyle = BlockStyle()); ~TextBlock() override = default; + TextBlock(const TextBlock&) = delete; + TextBlock& operator=(const TextBlock&) = delete; + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } - const std::vector& getWords() const { return words; } - const std::vector& getWordXpos() const { return wordXpos; } - const std::vector& getWordStyles() const { return wordStyles; } - bool wordEndsWithInsertedHyphen(size_t index) const { - return index < wordBackgroundBlack.size() && (wordBackgroundBlack[index] & WORD_FLAG_INSERTED_HYPHEN) != 0; + bool isEmpty() override { return numWords == 0; } + bool valid() const { return isValid; } + uint16_t wordCount() const { return numWords; } + const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; } + uint16_t wordTextLen(const uint16_t i) const { + const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes; + return end - textOffArr[i] - 1; } - bool isEmpty() override { return words.empty(); } - size_t wordCount() const { return words.size(); } - // given a renderer works out where to break the words into lines + int16_t wordXpos(const uint16_t i) const { return xposArr[i]; } + EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast(stylesArr[i]); } + uint8_t bionicBoundary(const uint16_t i) const { return bionicPresent ? bionicBoundaryArr[i] : 0; } + uint16_t bionicSuffixX(const uint16_t i) const { return bionicPresent ? bionicSuffixXArr[i] : 0; } + uint16_t guideDotXOffset(const uint16_t i) const { return guideDotsPresent ? guideDotXOffsetArr[i] : 0; } + uint8_t wordFlags(const uint16_t i) const { return wordFlagsPresent ? wordFlagsArr[i] : 0; } + bool wordEndsWithInsertedHyphen(const uint16_t i) const { return (wordFlags(i) & WORD_FLAG_INSERTED_HYPHEN) != 0; } + void render(const GfxRenderer& renderer, int fontId, int x, int y, bool foregroundBlack = true) const; BlockType getType() override { return TEXT_BLOCK; } bool serialize(HalFile& file) const; diff --git a/lib/Epub/Epub/converters/ImageDecoderFactory.cpp b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp index 6c7cde26aa..e5b7452750 100644 --- a/lib/Epub/Epub/converters/ImageDecoderFactory.cpp +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp @@ -1,6 +1,8 @@ #include "ImageDecoderFactory.h" +#include #include +#include #include #include @@ -25,12 +27,22 @@ ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& im if (JpegToFramebufferConverter::supportsFormat(ext)) { if (!jpegDecoder) { - jpegDecoder.reset(new JpegToFramebufferConverter()); + jpegDecoder = makeUniqueNoThrow(); + if (!jpegDecoder) { + LOG_ERR("DEC", "OOM: JPEG framebuffer decoder (%u free, %u max alloc)", ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); + return nullptr; + } } return jpegDecoder.get(); } else if (PngToFramebufferConverter::supportsFormat(ext)) { if (!pngDecoder) { - pngDecoder.reset(new PngToFramebufferConverter()); + pngDecoder = makeUniqueNoThrow(); + if (!pngDecoder) { + LOG_ERR("DEC", "OOM: PNG framebuffer decoder (%u free, %u max alloc)", ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); + return nullptr; + } } return pngDecoder.get(); } diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 89b922b765..db655f1c20 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -50,13 +51,16 @@ struct JpegContext { // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. void* jpegOpen(const char* filename, int32_t* size) { - FsFile* f = new FsFile(); + auto f = makeUniqueNoThrow(); + if (!f) { + LOG_ERR("JPG", "OOM: JPEG file handle (%u free, %u max alloc)", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + return nullptr; + } if (!Storage.openFileForRead("JPG", std::string(filename), *f)) { - delete f; return nullptr; } *size = f->size(); - return f; + return f.release(); // JPEGDEC owns this handle until jpegClose deletes it. } void jpegClose(void* handle) { diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 5f81b1fc1d..69ddeee0b6 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -42,13 +43,16 @@ struct PngContext { // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. void* pngOpenWithHandle(const char* filename, int32_t* size) { - FsFile* f = new FsFile(); + auto f = makeUniqueNoThrow(); + if (!f) { + LOG_ERR("PNG", "OOM: PNG file handle (%u free, %u max alloc)", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + return nullptr; + } if (!Storage.openFileForRead("PNG", std::string(filename), *f)) { - delete f; return nullptr; } *size = f->size(); - return f; + return f.release(); // PNGdec owns this handle until pngCloseWithHandle deletes it. } void pngCloseWithHandle(void* handle) { diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 3f877858f7..cefcae79c2 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -50,6 +50,13 @@ constexpr size_t MAX_DESCENDANT_RULES = CssParser::MAX_DESCENDANT_RULES; // If below this threshold, we skip CSS to avoid display artifacts. constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024; +// Hydrating cached CSS rules is an optimization, not a requirement. Keep a +// larger heap floor than basic CSS application so low-memory books can still +// fall back to the disk-backed selector index. +constexpr size_t MIN_FREE_HEAP_FOR_CSS_RULE_ARENA = 96 * 1024; +constexpr size_t CSS_RULE_ARENA_MIN_FREE_AFTER_ALLOC = 80 * 1024; +constexpr size_t CSS_RULE_ARENA_EXTRA_BYTES = 1024; + // Maximum length for a single selector string // Prevents parsing of extremely long or malformed selectors constexpr size_t MAX_SELECTOR_LENGTH = 256; @@ -78,13 +85,6 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe [](char a, char b) { return asciiToLower(a) == b; }); } -// Case-insensitive ASCII substring search. Only needed by text-decoration, -// which accepts multi-value strings like "underline solid red". -constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) { - return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(), - [](char a, char b) { return asciiToLower(a) == b; }) != value.end(); -} - // Walk s and invoke fn(token) for each non-empty run between delimiters. // Tokens are boundary-trimmed and yielded as string_views into s; no // allocation. Runs of consecutive delimiters coalesce — no empty tokens are @@ -327,14 +327,20 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) { } CssTextDecoration CssParser::interpretDecoration(std::string_view val) { - // text-decoration can have multiple space-separated values; check most specific first. - if (icontainsAscii(val, "line-through")) { - return CssTextDecoration::LineThrough; - } - if (icontainsAscii(val, "underline")) { - return CssTextDecoration::Underline; - } - return CssTextDecoration::None; + // text-decoration can have multiple space-separated values. Compare whole tokens + // so malformed values like "notunderline" do not accidentally enable a line. + CssTextDecoration result = CssTextDecoration::None; + bool explicitNone = false; + forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) { + if (iequalsAscii(token, "none")) { + explicitNone = true; + } else if (iequalsAscii(token, "underline")) { + result = result | CssTextDecoration::Underline; + } else if (iequalsAscii(token, "line-through")) { + result = result | CssTextDecoration::LineThrough; + } + }); + return explicitNone ? CssTextDecoration::None : result; } CssLength CssParser::interpretLength(std::string_view val) { @@ -862,6 +868,14 @@ uint32_t CssParser::selectorHash(std::string_view selector) { return h; } +uint32_t CssParser::selectorSecondaryHash(std::string_view selector) { + uint32_t h = 5381U; + for (char c : selector) { + h = ((h << 5U) + h) ^ static_cast(asciiToLower(c)); + } + return h; +} + bool CssParser::writeCssStylePayload(FsFile& file, const CssStyle& style) { auto writeBytes = [&file](const void* data, const size_t len) -> bool { return len == 0 || file.write(reinterpret_cast(data), len) == len; @@ -927,7 +941,7 @@ bool CssParser::readCssStylePayload(FsFile& file, CssStyle& style) { if (file.read(&enumVal, 1) != 1) return false; style.fontWeight = static_cast(enumVal); if (file.read(&enumVal, 1) != 1) return false; - style.textDecoration = static_cast(enumVal); + style.textDecoration = static_cast(enumVal & CSS_TEXT_DECORATION_MASK); if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) || @@ -995,6 +1009,26 @@ bool CssParser::readRuleFromDiskAtOffset(const uint32_t ruleOffset, std::string_ return ok; } +bool CssParser::lookupArenaRule(std::string_view selector, CssStyle& outStyle) const { + if (!cachedRules_ || cachedRuleTableCount_ == 0 || selector.empty() || selector.size() > MAX_SELECTOR_LENGTH) { + return false; + } + + const uint32_t h = selectorHash(selector); + const uint32_t secondaryHash = selectorSecondaryHash(selector); + auto* begin = cachedRules_; + auto* end = cachedRules_ + cachedRuleTableCount_; + auto* it = + std::lower_bound(begin, end, h, [](const CachedRule& rule, const uint32_t key) { return rule.hash < key; }); + for (; it != end && it->hash == h; ++it) { + if (it->secondaryHash == secondaryHash && it->selectorLen == selector.size()) { + outStyle = it->style; + return true; + } + } + return false; +} + bool CssParser::lookupRule(std::string_view selector, CssStyle& outStyle) const { if (auto it = rulesBySelector_.find(selector); it != rulesBySelector_.end()) { outStyle = it->second; @@ -1004,6 +1038,13 @@ bool CssParser::lookupRule(std::string_view selector, CssStyle& outStyle) const return false; } + if (lookupArenaRule(selector, outStyle)) { + return true; + } + if (cachedRuleTableCount_ == cachedRuleCount_) { + return false; + } + const uint32_t h = selectorHash(selector); auto it = std::lower_bound(cacheRuleOffsets_.begin(), cacheRuleOffsets_.end(), h, [](const SelectorEntry& e, const uint32_t key) { return e.hash < key; }); @@ -1251,8 +1292,33 @@ bool CssParser::loadFromCache() { cacheIndexLoaded_ = true; cachedRuleCount_ = cacheRuleOffsets_.size(); - // Skip each simple rule payload; simple styles stay disk-backed and are read on demand. + bool hydrateSimpleRules = false; + size_t hydratedRuleCount = 0; + const size_t freeHeapBeforeHydrate = ESP.getFreeHeap(); + const size_t arenaBytes = (static_cast(ruleCount) * sizeof(CachedRule)) + CSS_RULE_ARENA_EXTRA_BYTES; + if (ruleCount > 0 && freeHeapBeforeHydrate >= MIN_FREE_HEAP_FOR_CSS_RULE_ARENA && + freeHeapBeforeHydrate >= arenaBytes + CSS_RULE_ARENA_MIN_FREE_AFTER_ALLOC) { + if (cachedRuleArena_.init(arenaBytes)) { + cachedRules_ = arenaNewArray(cachedRuleArena_, ruleCount); + hydrateSimpleRules = cachedRules_ != nullptr; + if (!hydrateSimpleRules) { + cachedRuleArena_.release(); + cachedRules_ = nullptr; + } + } + } else if (ruleCount > 0) { + LOG_DBG("CSS", "Skipping CSS rule arena hydration (free heap=%u need free>=%u for %u-byte arena)", + static_cast(freeHeapBeforeHydrate), + static_cast(arenaBytes + CSS_RULE_ARENA_MIN_FREE_AFTER_ALLOC), static_cast(arenaBytes)); + } + + // Read each simple rule payload. When heap allows, hydrate into an arena-backed + // table so resolveStyle() can stay in RAM instead of seeking the SD cache for + // every selector lookup during page building. Selector text is only needed + // while computing the compact lookup fingerprints, so it stays on the stack. + char selectorBuf[MAX_SELECTOR_LENGTH]; for (uint16_t i = 0; i < ruleCount; ++i) { + const uint32_t recordStart = file.position(); uint16_t selectorLen = 0; if (!hasRemainingBytes(sizeof(selectorLen)) || file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { @@ -1265,11 +1331,49 @@ bool CssParser::loadFromCache() { cacheRuleOffsets_.clear(); return false; } - const uint32_t nextRecord = file.position() + selectorLen + CSS_FIXED_STYLE_BYTES; - if (!file.seek(nextRecord)) { - cacheRuleOffsets_.clear(); - return false; + const uint32_t nextRecord = recordStart + sizeof(selectorLen) + selectorLen + CSS_FIXED_STYLE_BYTES; + + if (hydrateSimpleRules) { + CssStyle style; + if (file.read(selectorBuf, selectorLen) != selectorLen || !readCssStylePayload(file, style)) { + LOG_DBG("CSS", "Truncated CSS cache while hydrating selector rule"); + cacheRuleOffsets_.clear(); + cachedRuleArena_.release(); + cachedRules_ = nullptr; + cachedRuleTableCount_ = 0; + return false; + } + const std::string_view selectorView(selectorBuf, selectorLen); + cachedRules_[hydratedRuleCount++] = {selectorHash(selectorView), selectorSecondaryHash(selectorView), selectorLen, + style}; + } else { + if (!file.seek(nextRecord)) { + cacheRuleOffsets_.clear(); + return false; + } + } + } + if (hydrateSimpleRules) { + cachedRuleTableCount_ = hydratedRuleCount; + std::sort(cachedRules_, cachedRules_ + cachedRuleTableCount_, [](const CachedRule& a, const CachedRule& b) { + if (a.hash != b.hash) return a.hash < b.hash; + if (a.secondaryHash != b.secondaryHash) return a.secondaryHash < b.secondaryHash; + return a.selectorLen < b.selectorLen; + }); + for (size_t i = 1; i < cachedRuleTableCount_; ++i) { + const auto& prev = cachedRules_[i - 1]; + const auto& current = cachedRules_[i]; + if (prev.hash == current.hash && prev.secondaryHash == current.secondaryHash && + prev.selectorLen == current.selectorLen) { + LOG_DBG("CSS", "CSS rule fingerprint collision; using disk-backed selector lookup"); + cachedRuleArena_.release(); + cachedRules_ = nullptr; + cachedRuleTableCount_ = 0; + break; + } } + LOG_DBG("CSS", "Hydrated %u CSS rules into arena (%u bytes)", static_cast(cachedRuleTableCount_), + static_cast(cachedRuleArena_.used())); } // Read descendant rules diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 8b171fd0cf..1da8347ec3 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -48,7 +49,7 @@ class CssParser { public: // Bump when CSS cache format or rules change; section caches are invalidated when this changes static constexpr uint32_t CSS_CACHE_MAGIC = 0x435843FF; // bytes: 0xFF, "CXC" - static constexpr uint8_t CSS_CACHE_VERSION = 12; + static constexpr uint8_t CSS_CACHE_VERSION = 14; static constexpr size_t MAX_DESCENDANT_RULES = 100; static constexpr size_t CSS_INDEX_BYTES_PER_RULE = 8; @@ -111,6 +112,9 @@ class CssParser { decltype(rulesBySelector_){}.swap(rulesBySelector_); decltype(descendantRules_){}.swap(descendantRules_); decltype(cacheRuleOffsets_){}.swap(cacheRuleOffsets_); + cachedRuleArena_.release(); + cachedRules_ = nullptr; + cachedRuleTableCount_ = 0; cacheIndexLoaded_ = false; cachedRuleCount_ = 0; cachePartial_ = false; @@ -198,6 +202,15 @@ class CssParser { }; static_assert(sizeof(SelectorEntry) == CSS_INDEX_BYTES_PER_RULE, "SelectorEntry size changed; update CSS_INDEX_BYTES_PER_RULE"); + struct CachedRule { + uint32_t hash; + uint32_t secondaryHash; + uint16_t selectorLen; + CssStyle style; + }; + Arena cachedRuleArena_; + CachedRule* cachedRules_ = nullptr; + size_t cachedRuleTableCount_ = 0; mutable bool cacheIndexLoaded_ = false; mutable size_t cachedRuleCount_ = 0; mutable std::vector cacheRuleOffsets_; @@ -217,7 +230,9 @@ class CssParser { /** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */ static bool tryInterpretLength(std::string_view val, CssLength& out); static uint32_t selectorHash(std::string_view selector); + static uint32_t selectorSecondaryHash(std::string_view selector); bool lookupRule(std::string_view selector, CssStyle& outStyle) const; + bool lookupArenaRule(std::string_view selector, CssStyle& outStyle) const; bool readRuleFromDiskAtOffset(uint32_t ruleOffset, std::string_view selector, CssStyle& outStyle) const; static bool readCssStylePayload(FsFile& file, CssStyle& style); static bool writeCssStylePayload(FsFile& file, const CssStyle& style); diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index d12ca41c0a..81fda3eefe 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -52,9 +52,20 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 }; // Font weight options - CSS supports 100-900, we simplify to normal/bold enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 }; -// Text decoration options +// Text decoration options. Values are bit flags so CSS can combine multiple line decorations. enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 }; +constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) { + return static_cast(static_cast(a) | static_cast(b)); +} + +constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) { + return static_cast(static_cast(a) & static_cast(b)); +} + +constexpr uint8_t CSS_TEXT_DECORATION_MASK = + static_cast(CssTextDecoration::Underline) | static_cast(CssTextDecoration::LineThrough); + // Display options - only None and Block are relevant for e-ink rendering enum class CssDisplay : uint8_t { Block = 0, None = 1 }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 5fe0d1e793..24e145ef67 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -16,6 +18,7 @@ #include #include #include +#include #include "Epub.h" #include "Epub/Page.h" @@ -39,11 +42,17 @@ constexpr size_t SINGLE_READING_AID_BUFFERED_WORDS_BEFORE_LAYOUT = 240; constexpr uint16_t SINGLE_READING_AID_TEXT_RUN_BYTES_BEFORE_LAYOUT = 1536; constexpr size_t COMBINED_READING_AID_BUFFERED_WORDS_BEFORE_LAYOUT = 175; constexpr uint16_t COMBINED_READING_AID_TEXT_RUN_BYTES_BEFORE_LAYOUT = 1024; +constexpr size_t SECTION_ADVANCE_PREWARM_READ_BUFFER_SIZE = 512; +constexpr uint32_t SECTION_ADVANCE_PREWARM_MAX_CODEPOINTS = 4096; constexpr uint8_t INITIAL_PAGE_ELEMENT_RESERVE = 8; constexpr uint8_t INITIAL_TABLE_FRAGMENT_ROW_RESERVE = 8; constexpr uint32_t PAGE_ELEMENT_RESERVE_MIN_MAX_ALLOC = 1024; +constexpr uint16_t WARNING_PAGE_SIDE_MARGIN = 24; +constexpr uint8_t WARNING_TITLE_MAX_LINES = 2; +constexpr uint8_t WARNING_BODY_MAX_LINES = 6; // Cap chapter anchors so converter-generated IDs do not grow memory without bound. constexpr size_t MAX_ANCHORS_PER_CHAPTER = 1024; +constexpr size_t MAX_PENDING_FOOTNOTES_BEFORE_LAYOUT = Page::MAX_FOOTNOTES_PER_PAGE * 3; static constexpr const char* const HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; static constexpr const char* const BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; @@ -58,6 +67,76 @@ bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c static char asciiLower(const char c) { return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; } +bool isSvgImagePath(const std::string_view path) { + const size_t end = path.find_first_of("?#"); + const std::string_view cleanPath = end == std::string_view::npos ? path : path.substr(0, end); + const size_t dot = cleanPath.rfind('.'); + if (dot == std::string_view::npos) return false; + + const std::string_view ext = cleanPath.substr(dot); + if (ext.size() == 4) { + return asciiLower(ext[0]) == '.' && asciiLower(ext[1]) == 's' && asciiLower(ext[2]) == 'v' && + asciiLower(ext[3]) == 'g'; + } + if (ext.size() == 5) { + return asciiLower(ext[0]) == '.' && asciiLower(ext[1]) == 's' && asciiLower(ext[2]) == 'v' && + asciiLower(ext[3]) == 'g' && asciiLower(ext[4]) == 'z'; + } + return false; +} + +bool appendUniquePrewarmCodepoint(const uint32_t cp, uint32_t* codepoints, uint32_t& cpCount, const uint32_t maxCount) { + if (cp == 0) return false; + for (uint32_t i = 0; i < cpCount; ++i) { + if (codepoints[i] == cp) return false; + } + if (cpCount >= maxCount) return true; + codepoints[cpCount++] = cp; + return false; +} + +void resetPrewarmUtf8(uint32_t& accumulator, uint8_t& remaining) { + accumulator = 0; + remaining = 0; +} + +bool feedPrewarmUtf8Byte(const uint8_t byte, uint32_t* codepoints, uint32_t& cpCount, uint32_t& accumulator, + uint8_t& remaining) { + if (remaining == 0) { + if (byte < 0x80) { + return appendUniquePrewarmCodepoint(byte, codepoints, cpCount, SECTION_ADVANCE_PREWARM_MAX_CODEPOINTS); + } + if ((byte & 0xE0) == 0xC0) { + accumulator = byte & 0x1F; + remaining = 1; + } else if ((byte & 0xF0) == 0xE0) { + accumulator = byte & 0x0F; + remaining = 2; + } else if ((byte & 0xF8) == 0xF0) { + accumulator = byte & 0x07; + remaining = 3; + } else { + return appendUniquePrewarmCodepoint(REPLACEMENT_GLYPH, codepoints, cpCount, + SECTION_ADVANCE_PREWARM_MAX_CODEPOINTS); + } + return false; + } + + if ((byte & 0xC0) != 0x80) { + resetPrewarmUtf8(accumulator, remaining); + return appendUniquePrewarmCodepoint(REPLACEMENT_GLYPH, codepoints, cpCount, SECTION_ADVANCE_PREWARM_MAX_CODEPOINTS); + } + + accumulator = (accumulator << 6U) | (byte & 0x3FU); + --remaining; + if (remaining == 0) { + const uint32_t cp = accumulator; + accumulator = 0; + return appendUniquePrewarmCodepoint(cp, codepoints, cpCount, SECTION_ADVANCE_PREWARM_MAX_CODEPOINTS); + } + return false; +} + static bool tokenEqualsIgnoreCase(const char* value, const char* token, const size_t tokenLen) { for (size_t i = 0; i < tokenLen; ++i) { if (asciiLower(value[i]) != asciiLower(token[i])) return false; @@ -160,10 +239,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold; effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic; - effectiveUnderline = - currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline; - effectiveStrikethrough = - currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::LineThrough; + effectiveUnderline = currentCssStyle.hasTextDecoration() && + (currentCssStyle.textDecoration & CssTextDecoration::Underline) != CssTextDecoration::None; + effectiveStrikethrough = currentCssStyle.hasTextDecoration() && + (currentCssStyle.textDecoration & CssTextDecoration::LineThrough) != CssTextDecoration::None; effectiveBackgroundBlack = honorsPublisherDecorations() && currentCssStyle.hasBackgroundBlack() && currentCssStyle.backgroundBlack; effectiveDirectionDefined = currentCssStyle.hasDirection(); @@ -258,9 +337,25 @@ bool ChapterHtmlSlimParser::startNewPage(const char* reason) { currentPage->elements.reserve(INITIAL_PAGE_ELEMENT_RESERVE); } currentPageNextY = 0; + currentPageParagraphIndex = 0; + currentPageListItemIndex = 0; return true; } +void ChapterHtmlSlimParser::markCurrentPageFromCurrentTextBlock() { + currentPageParagraphIndex = currentTextBlockParagraphIndex; + currentPageListItemIndex = currentTextBlockListItemIndex; +} + +void ChapterHtmlSlimParser::markCurrentPageFromCurrentElement() { + currentPageParagraphIndex = xpathParagraphIndex; + currentPageListItemIndex = xpathListItemIndex; +} + +void ChapterHtmlSlimParser::completeCurrentPage() { + completePageFn(std::move(currentPage), currentPageParagraphIndex, currentPageListItemIndex); +} + void ChapterHtmlSlimParser::flushPendingAnchor() { if (pendingAnchorId.empty()) return; @@ -268,7 +363,7 @@ void ChapterHtmlSlimParser::flushPendingAnchor() { // block is flushed so the chapter starts on a fresh page. if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) { if (currentPage && !currentPage->elements.empty()) { - completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); + completeCurrentPage(); completedPageCount++; stopPreviewIfPageLimitReached(); if (previewStopRequested) { @@ -283,6 +378,7 @@ void ChapterHtmlSlimParser::flushPendingAnchor() { // Record deferred anchor after previous block is flushed (and any TOC page break) anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); + pendingAnchorFromInlineA = false; } bool ChapterHtmlSlimParser::handlePreviewScanStart(const XML_Char** atts) { @@ -301,6 +397,7 @@ void ChapterHtmlSlimParser::startPreviewAtAnchor() { completedPageCount = 0; currentPageNextY = 0; pendingAnchorId.clear(); + pendingAnchorFromInlineA = false; anchorData.clear(); anchorData.push_back({previewAnchor, 0}); @@ -420,7 +517,7 @@ uint16_t ChapterHtmlSlimParser::textRunBytesBeforeLayoutLimit() const { return DEFAULT_TEXT_RUN_BYTES_BEFORE_LAYOUT; } -void ChapterHtmlSlimParser::flushLongTextRunIfNeeded() { +void ChapterHtmlSlimParser::flushLongTextRunIfNeeded(const bool force) { if (!currentTextBlock) { currentTextRunBytes = 0; return; @@ -429,13 +526,14 @@ void ChapterHtmlSlimParser::flushLongTextRunIfNeeded() { const size_t wordLimit = bufferedWordsBeforeLayoutLimit(); const uint16_t byteLimit = textRunBytesBeforeLayoutLimit(); const size_t wordCount = currentTextBlock->size(); - if (wordCount <= wordLimit && currentTextRunBytes <= byteLimit) { + if (!force && wordCount <= wordLimit && currentTextRunBytes <= byteLimit) { return; } - LOG_DBG("EHP", "Text block too long, splitting into multiple pages (words=%u/%u bytes=%u/%u)", + LOG_DBG("EHP", "Text block flush: force=%u words=%u/%u bytes=%u/%u footnotes=%u/%u", force ? 1U : 0U, static_cast(wordCount), static_cast(wordLimit), - static_cast(currentTextRunBytes), static_cast(byteLimit)); + static_cast(currentTextRunBytes), static_cast(byteLimit), + static_cast(pendingFootnotes.size()), static_cast(MAX_PENDING_FOOTNOTES_BEFORE_LAYOUT)); const int horizontalInset = currentTextBlock->getBlockStyle().totalHorizontalInset(); const uint16_t effectiveWidth = (horizontalInset < viewportWidth) ? static_cast(viewportWidth - horizontalInset) : viewportWidth; @@ -475,6 +573,8 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { auto combinedStyle = style.getCombinedBlockStyle(incoming, BlockStyle::CombineAxis::Vertical); combinedStyle.fromBrElement = incoming.fromBrElement; currentTextBlock->setBlockStyle(combinedStyle); + currentTextBlockParagraphIndex = xpathParagraphIndex; + currentTextBlockListItemIndex = xpathListItemIndex; flushPendingAnchor(); return; @@ -494,14 +594,16 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { lowMemoryAbort = true; return; } + currentTextBlockParagraphIndex = xpathParagraphIndex; + currentTextBlockListItemIndex = xpathListItemIndex; wordsExtractedInBlock = 0; } -void ChapterHtmlSlimParser::pushCssAncestor(const int depth, const char* tag, const std::string& classAttr) { +void ChapterHtmlSlimParser::pushCssAncestor(const int depth, const char* tag, const std::string_view classAttr) { if (usesSimpleCssLookup()) { return; } - ancestorStack_.push_back({depth, std::string(tag), classAttr}); + ancestorStack_.push_back({depth, std::string(tag), std::string(classAttr)}); } void ChapterHtmlSlimParser::finalizeCurrentTableCell() { @@ -590,8 +692,8 @@ void ChapterHtmlSlimParser::emitHorizontalRule(const BlockStyle& blockStyle) { const int16_t xPos = static_cast(blockStyle.leftInset() + ((availableWidth - width) / 2)); const int16_t totalHeight = static_cast(topSpacing + ruleThickness + bottomSpacing); - if (!currentPage->elements.empty() && currentPageNextY + totalHeight > viewportHeight) { - completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); + if (!headingOpenerActive && !currentPage->elements.empty() && currentPageNextY + totalHeight > viewportHeight) { + completeCurrentPage(); completedPageCount++; stopPreviewIfPageLimitReached(); if (previewStopRequested) { @@ -612,11 +714,14 @@ void ChapterHtmlSlimParser::emitHorizontalRule(const BlockStyle& blockStyle) { return; } currentPage->elements.push_back(pageRule); + markCurrentPageFromCurrentElement(); currentPageNextY = static_cast(currentPageNextY + ruleThickness + bottomSpacing); + headingOpenerActive = false; if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); + pendingAnchorFromInlineA = false; } } @@ -734,7 +839,7 @@ void ChapterHtmlSlimParser::emitBufferedTableAsFragments(BufferedTable& table) { destCell.isHeader = sourceCell.isHeader; if (sourceCell.text) { - if (!sourceCell.text->layoutAndExtractLines( + if (!sourceCell.text->layoutAndExtractLinesPreservingSource( renderer, fontId, innerColumnWidth, [&destCell](const std::shared_ptr& textBlock) { destCell.lines.push_back(textBlock); })) { LOG_DBG("EHP", "Table layout fallback: cell text layout failed"); @@ -823,7 +928,7 @@ void ChapterHtmlSlimParser::emitBufferedTableAsFragments(BufferedTable& table) { break; } if (fragmentRows.empty() && currentPageNextY + nextHeight > viewportHeight && !currentPage->elements.empty()) { - completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); + completeCurrentPage(); completedPageCount++; stopPreviewIfPageLimitReached(); if (previewStopRequested) { @@ -853,13 +958,14 @@ void ChapterHtmlSlimParser::emitBufferedTableAsFragments(BufferedTable& table) { currentPage->elements.push_back( std::make_shared(tableWidth, segment.columnCount, TABLE_CELL_PADDING, lineHeight, std::move(fragmentRows), table.blockStyle.leftInset(), currentPageNextY)); + markCurrentPageFromCurrentElement(); for (const auto& footnote : fragmentFootnotes) { currentPage->addFootnote(footnote.number, footnote.href); } currentPageNextY += fragmentHeight; if (nextRowIndex < segment.rows.size()) { - completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); + completeCurrentPage(); completedPageCount++; stopPreviewIfPageLimitReached(); if (previewStopRequested) { @@ -950,6 +1056,96 @@ void ChapterHtmlSlimParser::fallbackCurrentTableBufferIfNeeded(const char* stage } } +void ChapterHtmlSlimParser::flushMalformedPartialContent() { + if (partWordBufferIndex > 0) { + flushPartWordBuffer(); + } + + if (tableDepth > 0) { + if (tableDepth == 1 && currentTextBlock) { + finalizeCurrentTableCell(); + } + if (currentTableBuffer) { + fallbackCurrentTableBufferToParagraphs("malformed markup"); + } + tableDepth = 0; + tableRowIndex = 0; + tableColIndex = 0; + currentTableCellIsHeader = false; + currentTableCellColSpan = 1; + } +} + +bool ChapterHtmlSlimParser::appendMalformedMarkupWarningPage() { + if (currentPage && !currentPage->elements.empty()) { + completeCurrentPage(); + completedPageCount++; + } + currentPage.reset(); + + if (!startNewPage("malformed markup warning")) { + return false; + } + + const uint16_t textWidth = + viewportWidth > WARNING_PAGE_SIDE_MARGIN * 2 ? viewportWidth - WARNING_PAGE_SIDE_MARGIN * 2 : viewportWidth; + const auto titleLines = renderer.wrappedText(fontId, tr(STR_EPUB_CHAPTER_INCOMPLETE_TITLE), textWidth, + WARNING_TITLE_MAX_LINES, EpdFontFamily::BOLD); + const auto bodyLines = + renderer.wrappedText(fontId, tr(STR_EPUB_CHAPTER_INCOMPLETE_BODY), textWidth, WARNING_BODY_MAX_LINES); + const int lineHeight = effectiveLineHeight(); + const int titleBodyGap = lineHeight; + const int contentLineCount = static_cast(titleLines.size() + bodyLines.size()); + const int contentHeight = + contentLineCount * lineHeight + (!titleLines.empty() && !bodyLines.empty() ? titleBodyGap : 0); + int y = std::max(0, (static_cast(viewportHeight) - contentHeight) / 2); + + auto addLine = [this](const std::string& line, const EpdFontFamily::Style style, const int yPos) -> bool { + std::vector words; + std::vector xPos; + std::vector styles; + words.reserve(1); + xPos.reserve(1); + styles.reserve(1); + words.push_back(line); + xPos.push_back(0); + styles.push_back(style); + + auto block = + std::make_shared(std::move(words), std::move(xPos), std::move(styles), std::vector{}, + std::vector{}, std::vector{}, std::vector{}); + auto pageLine = std::make_shared( + std::move(block), + static_cast( + std::max(0, (static_cast(viewportWidth) - renderer.getTextWidth(fontId, line.c_str(), style)) / 2)), + static_cast(yPos)); + currentPage->elements.push_back(std::move(pageLine)); + return true; + }; + + for (const auto& line : titleLines) { + if (!addLine(line, EpdFontFamily::BOLD, y)) return false; + y += lineHeight; + } + if (!titleLines.empty() && !bodyLines.empty()) { + y += titleBodyGap; + } + for (const auto& line : bodyLines) { + if (!addLine(line, EpdFontFamily::REGULAR, y)) return false; + y += lineHeight; + } + + if (!currentPage || currentPage->elements.empty()) { + LOG_ERR("EHP", "Failed to create malformed markup warning page"); + return false; + } + + completeCurrentPage(); + completedPageCount++; + currentPage.reset(); + return true; +} + void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); if (self->isScanningForPreviewAnchor()) { @@ -977,39 +1173,39 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->xpathListItemIndex++; } - // Extract class, style, and id attributes - std::string classAttr; - std::string styleAttr; - std::string dirAttr; + // Borrow parser-owned attribute bytes during this callback; copy only when + // storing an ancestor/id beyond the current element start. + std::string_view classAttr; + std::string_view styleAttr; + const char* dirAttr = nullptr; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { + const char* attrValue = atts[i + 1] ? atts[i + 1] : ""; if (strcmp(atts[i], "class") == 0) { - classAttr = atts[i + 1]; + classAttr = attrValue; } else if (strcmp(atts[i], "style") == 0) { - styleAttr = atts[i + 1]; + styleAttr = attrValue; } else if (strcmp(atts[i], "id") == 0) { - if (self->isPreviewBuild() && self->previewAnchorFound && - strcmp(atts[i + 1], self->previewAnchor.c_str()) == 0) { + if (self->isPreviewBuild() && self->previewAnchorFound && strcmp(attrValue, self->previewAnchor.c_str()) == 0) { continue; } // Defer both anchor recording and TOC page breaks until startNewTextBlock, // after the previous block is flushed to pages via makePages(). - const char* idValue = atts[i + 1]; + const char* idValue = attrValue; const bool isTocAnchor = std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end(); if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) { - // Flush a displaced anchor before overwriting. Consecutive non-block elements - // (e.g.