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