Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973a902332 | ||
|
|
cb9b971c26 |
@@ -25,10 +25,3 @@ lib/EpdFont/scripts/output/
|
||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
/managed_components
|
||||
/.dummy
|
||||
/CMakeLists.txt
|
||||
/dependencies.lock
|
||||
/sdkconfig.default
|
||||
/sdkconfig.defaults
|
||||
sdkconfig.sticky
|
||||
|
||||
@@ -1,57 +1,38 @@
|
||||
# Project Vision & Scope: CrossPoint Reader
|
||||
|
||||
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for ESP32-based e-reader
|
||||
devices. Xteink hardware (X3, X4) is where the project started and remains a primary target, but CrossPoint is
|
||||
explicitly broadening to support the wider ecosystem of small ESP32 e-ink readers. We believe a dedicated e-reader
|
||||
should do one thing exceptionally well: **facilitate focused reading.**
|
||||
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for ESP32-based e-reader devices. Xteink hardware (X3, X4) is where the project started and remains a primary target, but CrossPoint is explicitly broadening to support the wider ecosystem of small ESP32 e-ink readers. We believe a dedicated e-reader should do one thing exceptionally well: **facilitate focused reading.**
|
||||
|
||||
## 1. Core Mission
|
||||
|
||||
To provide a lightweight, high-performance firmware that maximizes the potential of ESP32-based e-reader hardware,
|
||||
prioritizing legibility, performance, and usability over "swiss-army-knife" functionality.
|
||||
To provide a lightweight, high-performance firmware that maximizes the potential of ESP32-based e-reader hardware, prioritizing legibility, performance, and usability over "swiss-army-knife" functionality.
|
||||
|
||||
CrossPoint is **not** a kitchen-sink firmware, and it is **not** Xteink-only. We want clean, maintainable code that
|
||||
the community can build on, and that runs across the range of ESP32 e-reader devices (ESP32-C3, ESP32-S3, and
|
||||
adjacent variants). Every accepted change should make that goal easier, not harder. Device-specific code should live
|
||||
behind the HAL / SDK boundary so the reader core stays portable.
|
||||
CrossPoint is **not** a kitchen-sink firmware, and it is **not** Xteink-only. We want clean, maintainable code that the community can build on, and that runs across the range of ESP32 e-reader devices (ESP32-C3, ESP32-S3, and adjacent variants). Every accepted change should make that goal easier, not harder. Device-specific code should live behind the HAL / SDK boundary so the reader core stays portable.
|
||||
|
||||
## 2. Guiding Principle: Fill Gaps the Stock Firmware Leaves
|
||||
|
||||
CrossPoint exists to do the things the stock firmware does poorly or not at all. New work is evaluated against that
|
||||
delineator:
|
||||
CrossPoint exists to do the things the stock firmware does poorly or not at all. New work is evaluated against that delineator:
|
||||
|
||||
* **Does the stock firmware already do this well?** If yes, we will not duplicate it.
|
||||
* **Is another popular CrossPoint fork already solving this well?** If yes, we generally defer to that fork rather than
|
||||
fragmenting the ecosystem.
|
||||
* **Does this directly improve the reading experience or the firmware's long-term maintainability?** If no, it is out
|
||||
of scope.
|
||||
* **Does the stock firmware already do this well?** We should hit that bar or surpass it
|
||||
* **Is another popular CrossPoint fork already solving this well?** If yes, we generally defer to that fork if it's not part of the core reading experience. e.g. stats
|
||||
* **Does this directly improve the reading experience or the firmware's long-term maintainability?** If no, it is out of scope.
|
||||
|
||||
### Language Priority
|
||||
|
||||
English is the priority language for new features and UI work. Other languages are considered, with preference given
|
||||
to cases where the stock firmware fails (rendering, input, fonts, layout) or where existing CrossPoint forks have not
|
||||
addressed the gap.
|
||||
|
||||
## 3. Current Focus (Until Further Notice)
|
||||
## 3. Current Focus
|
||||
|
||||
We are intentionally narrowing scope to consolidate the codebase as we open it up to more ESP32 e-reader devices.
|
||||
|
||||
During this period, the priorities are:
|
||||
|
||||
* **Memory footprint:** Reducing DRAM usage and heap fragmentation. The ESP32-C3 is the tightest target and sets the
|
||||
ceiling, but the gains benefit every ESP32 variant we run on.
|
||||
* **Flash footprint:** Trimming binary size to leave room for additional device targets and features.
|
||||
* **Code cleanup:** Refactoring, removing dead code, tightening abstractions, and improving readability.
|
||||
* **Reading experience:** EPUB parsing and rendering, typography, hyphenation, line spacing, font handling, and
|
||||
legibility improvements.
|
||||
* **Memory footprint:** Reducing DRAM usage and heap fragmentation. The ESP32-C3 is the tightest target and sets the ceiling, but the gains benefit every ESP32 variant we run on.
|
||||
* **Flash footprint:** Trimming binary size to leave room for additional device targets and features.
|
||||
* **Code cleanup:** Refactoring, removing dead code, tightening abstractions, and improving readability.
|
||||
* **Reading experience:** EPUB parsing and rendering, typography, hyphenation, line spacing, font handling, and legibility improvements.
|
||||
|
||||
### Temporarily Closed Areas
|
||||
|
||||
PRs in the following areas will be closed until this notice is lifted. Adding these now makes the cleanup and
|
||||
multi-device work materially harder:
|
||||
PRs in the following areas will be closed until this notice is lifted. Adding these now makes the cleanup and multi-device work materially harder:
|
||||
|
||||
* **New themes.** The existing theming surface is frozen.
|
||||
* **New external network connectors.** This includes sync engines, cloud storage clients, OPDS extensions beyond what
|
||||
exists, remote file access, and any new "talk to a server" feature.
|
||||
* **New themes.** The existing theming surface is frozen.
|
||||
* **New external network connectors.** This includes sync engines, cloud storage clients, OPDS extensions beyond what exists, remote file access, and any new "talk to a server" feature. We now have our own CrossPoint KOSync server which gives us a way to sync to 3rd party systems like Hardcover at an API level instead of bloating the firmware. If you're interested in helping here, the sync server is also open source.
|
||||
|
||||
If you are unsure whether your idea falls into one of these categories, open a Discussion first.
|
||||
|
||||
@@ -61,114 +42,59 @@ If you are unsure whether your idea falls into one of these categories, open a D
|
||||
|
||||
*Features that directly improve the core reading experience or the firmware's maintainability.*
|
||||
|
||||
* **EPUB Rendering & Optimization:** Improvements to the rendering engine, CSS/image handling, and parsing
|
||||
performance.
|
||||
* **Typography & Legibility:** Custom font support, hyphenation, line and paragraph spacing, margins.
|
||||
* **E-Ink Driver Refinement:** Reducing full-screen flashes (ghosting management) and improving general rendering.
|
||||
* **Reading UX:** Bookmarks, progress tracking, button mapping, page navigation, and other in-reader interactions.
|
||||
* **Library Management:** Simple, intuitive ways to organize and navigate a local book collection.
|
||||
* **Local Transfer:** Simple, pull-based loading via the existing web server or widely used standards. New connectors
|
||||
are currently paused (see Section 3).
|
||||
* **Reference Tools:** Local, offline dictionary lookup.
|
||||
* **Memory, Flash, and Code Quality:** Refactors and cleanups that reduce resource use or improve maintainability,
|
||||
even without a user-visible feature.
|
||||
* **EPUB Rendering & Optimization:** Improvements to the rendering engine, CSS/image handling, and parsing performance.
|
||||
* **Typography & Legibility:** Custom font support, hyphenation, line and paragraph spacing, margins.
|
||||
* **E-Ink Driver Refinement:** Reducing full-screen flashes (ghosting management) and improving general rendering.
|
||||
* **Reading UX:** Bookmarks, progress tracking, button mapping, page navigation, and other in-reader interactions.
|
||||
* **Library Management:** Simple, intuitive ways to organize and navigate a local book collection.
|
||||
* **Memory, Flash, and Code Quality:** Refactors and cleanups that reduce resource use or improve maintainability, even without a user-visible feature.
|
||||
|
||||
### Out-of-Scope
|
||||
|
||||
*Rejected because they compromise the device's stability, maintainability, or core mission.*
|
||||
|
||||
* **Interactive Apps:** No notepads, calculators, or games. These belong in other forks and are not part of
|
||||
CrossPoint's focus.
|
||||
* **Writing / Authoring Tools:** No typed notes, journals, or editors. Input hardware and RAM are wrong for this, and
|
||||
other forks already explore this space.
|
||||
* **Active Connectivity:** No RSS readers, news aggregators, or web browsers. Background Wi-Fi drains the battery and
|
||||
complicates the single-core CPU.
|
||||
* **Media Playback:** No audio players or audiobooks.
|
||||
* **Complex Annotation:** No typed-out notes.
|
||||
* **Duplication of stock firmware behavior** that already works well.
|
||||
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather
|
||||
than reflowable text, resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
|
||||
Out of scope on the current hardware class.
|
||||
* **Interactive Apps:** No notepads, calculators, or games. These belong in other forks and are not part of CrossPoint's focus.
|
||||
* **Writing / Authoring Tools:** No typed notes, journals, or editors. Input hardware and RAM are wrong for this, and other forks already explore this space.
|
||||
* **Active Connectivity:** No RSS readers, news aggregators, or web browsers. Background Wi-Fi drains the battery and complicates the single-core CPU.
|
||||
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text, resulting in constant panning and zooming that makes for a poor reading experience on e-ink. Out of scope on the current hardware class.
|
||||
|
||||
## 5. Idea Evaluation
|
||||
## 5. Calls to Action
|
||||
|
||||
Before proposing a feature, ask:
|
||||
|
||||
1. Does the stock firmware already handle this well? We should hit that bar or surpass it.
|
||||
2. Does another popular CrossPoint fork already handle this well? If yes, we usually defer.
|
||||
3. Does it improve the core reading experience, or reduce memory / flash / code complexity?
|
||||
4. Is it in one of the temporarily closed areas (new themes, new network connectors)? If yes, wait.
|
||||
5. Would adding it make the codebase harder to clean up or harder to port to other devices? If yes, rework or defer.
|
||||
|
||||
If the idea passes those filters, it is a candidate. If you are not sure, open a **Discussion** before writing any
|
||||
code.
|
||||
|
||||
> **Note to Contributors:** CrossPoint is intentionally narrow. "It would be cool if..." features are not enough; the
|
||||
> bar is "this fixes something the stock firmware does poorly, or it makes the firmware leaner and easier to maintain."
|
||||
|
||||
## 6. Calls to Action
|
||||
|
||||
These are the areas where contributor help is most valuable right now. If you want to take one of these on, open a
|
||||
Discussion or issue first so we can coordinate.
|
||||
These are the areas where contributor help is most valuable right now. If you want to take one of these on, open a Discussion or issue first so we can coordinate.
|
||||
|
||||
### Theme System: Move Themes Off-Firmware
|
||||
|
||||
We want to abstract themes out of the firmware entirely so they no longer consume flash, and instead load from the SD
|
||||
card. This directly supports the current focus on flash footprint and code cleanup.
|
||||
We want to abstract themes out of the firmware entirely so they no longer consume flash, and instead load from the SD card. This directly supports the current focus on flash footprint and code cleanup.
|
||||
|
||||
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) plans to take this on eventually but is very open to
|
||||
someone else claiming it sooner.
|
||||
* **Why it matters:** Every built-in theme costs flash that we would rather spend on rendering, fonts, or future
|
||||
device support. SD-loaded themes also let users customize without rebuilding firmware.
|
||||
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) plans to take this on eventually but is very open to someone else claiming it sooner.
|
||||
* **Why it matters:** Every built-in theme costs flash that we would rather spend on rendering, fonts, or future device support. SD-loaded themes also let users customize without rebuilding firmware. It also leads to SD font loading for better language support in the UI.
|
||||
* **How to claim:** Comment on the relevant Discussion (or open one) before starting.
|
||||
|
||||
### SDK Abstraction: Generalize the Lower Layers for All ESP32 E-Readers
|
||||
|
||||
To support the broader range of ESP32-based e-reader hardware, the SDK layers (display, input, storage, battery,
|
||||
etc.) need to be abstracted away from Xteink-specific assumptions and made pluggable per device.
|
||||
|
||||
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) is actively working on this.
|
||||
* **How to help:** Coordinate with itsthisjustin before opening PRs that touch `freeink-sdk/` or `lib/hal/` so the
|
||||
work does not collide. Smaller adjacent cleanups (removing hardcoded assumptions, tightening HAL boundaries) are
|
||||
welcome if scoped against the in-flight direction.
|
||||
|
||||
### Identifying Other Stock-Firmware Gaps
|
||||
|
||||
We want help cataloguing things the stock firmware (and other popular CrossPoint forks) handle poorly or not at all,
|
||||
so future work has a clear target list. Particularly interested in:
|
||||
We want help cataloguing things the stock firmware (and other popular CrossPoint forks) handle poorly or not at all, so future work has a clear target list. Particularly interested in:
|
||||
|
||||
* **RTL (right-to-left) text support:** Arabic, Hebrew, Persian, and similar scripts.
|
||||
* **Languages with poor stock and fork coverage:** Especially those that need shaping, complex layout, or
|
||||
non-Latin font work that nobody is handling well today.
|
||||
* **Other gaps:** Rendering edge cases, accessibility issues, input quirks, anything stock does badly and existing
|
||||
forks have not fixed.
|
||||
* **RTL (right-to-left) text support:** Arabic, Hebrew, Persian, and similar scripts.
|
||||
* **Languages with poor stock and fork coverage:** Especially those that need shaping, complex layout, or non-Latin font work that nobody is handling well today.
|
||||
* **Other gaps:** Rendering edge cases, accessibility issues, input quirks, anything stock does badly and existing forks have not fixed.
|
||||
|
||||
* **Status:** [@uxjulia](https://github.com/uxjulia) is coordinating this effort.
|
||||
If you can read or use the device in one of these languages, your feedback (even without code) is genuinely useful. Open a Discussion with concrete examples (screenshots, sample EPUBs, expected vs actual behavior) and we will prioritize from there.
|
||||
|
||||
If you can read or use the device in one of these languages, your feedback (even without code) is genuinely useful.
|
||||
Open a Discussion with concrete examples (screenshots, sample EPUBs, expected vs actual behavior) and we will
|
||||
prioritize from there.
|
||||
## 6. Funding and Contributor Sustainability
|
||||
|
||||
## 7. Funding and Contributor Sustainability
|
||||
|
||||
CrossPoint uses [Royalty.dev](https://royalty.dev) (yes, a product built by [@itsthisjustin](https://github.com/itsthisjustin))
|
||||
to fund contributors. There has been some tension in the community around this, so the intent is being clarified
|
||||
here directly.
|
||||
CrossPoint uses [Royalty.dev](https://royalty.dev) (yes, a product built by [@itsthisjustin](https://github.com/itsthisjustin)) to fund contributors. There has been some tension in the community around this, so the intent is being clarified here directly.
|
||||
|
||||
**Why we do this:**
|
||||
|
||||
* To maintain long-term interest from contributors and maintainers, in direct response to substantial community
|
||||
requests for a way to give back.
|
||||
* To motivate contributors to invest in the *core* project rather than spinning up competing forks.
|
||||
* To help pay for new ESP32 devices so we can port CrossPoint to additional hardware.
|
||||
* To maintain long-term interest from contributors and maintainers, in direct response to substantial community requests for a way to give back.
|
||||
* To motivate contributors to invest in the *core* project rather than spinning up competing forks.
|
||||
* To help pay for new ESP32 devices so we can port CrossPoint to additional hardware.
|
||||
* To give the project a credible long-term path to sustainability.
|
||||
|
||||
**How it works:**
|
||||
|
||||
* Funds are distributed automatically to contributors based on impact to the codebase and tenure on the project.
|
||||
* Over **$600** was raised in the first few days after opening up funding, which is a signal the demand is real.
|
||||
* Funds are distributed automatically to contributors based on impact to the codebase and tenure on the project.
|
||||
* Over **$600** was raised in the first few hours after opening up funding, which is a signal the demand is real.
|
||||
* The exact scoring methodology is published at <https://app.royalty.dev/transparency>.
|
||||
|
||||
**This is not fixed in stone.** The weighting, eligibility, and distribution rules can be tweaked as we learn what
|
||||
works for this project. If you have concerns or suggestions about how funds are allocated, open a Discussion. The
|
||||
goal is a system that fairly recognizes the people doing the work, not a perfect one on day one.
|
||||
**This is not fixed in stone.** The weighting, eligibility, and distribution rules can be tweaked as we learn what works for this project. If you have concerns or suggestions about how funds are allocated, open a Discussion. The goal is a system that fairly recognizes the people doing the work, not a perfect one on day one.
|
||||
|
||||
+17
-44
@@ -68,22 +68,6 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||
const char* asCStr(const char* s) { return s; }
|
||||
|
||||
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||
// next page's need), eroding the largest contiguous block all session. With
|
||||
// reuse, capacities converge on the book's max page after a few turns and page
|
||||
// turns stop touching the allocator. Only three small instantiations exist
|
||||
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||
template <typename T, typename CapT>
|
||||
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||
if (buf && capacity >= needed) return true;
|
||||
delete[] buf;
|
||||
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SdCardFont::~SdCardFont() { freeAll(); }
|
||||
@@ -99,9 +83,6 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
||||
s.miniBitmap = nullptr;
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniIntervalCapacity = 0;
|
||||
s.miniGlyphCapacity = 0;
|
||||
s.miniBitmapCapacity = 0;
|
||||
freeStyleMiniKern(s);
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
@@ -128,9 +109,6 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
s.miniKernLeftCapacity = 0;
|
||||
s.miniKernRightCapacity = 0;
|
||||
s.miniKernMatrixCapacity = 0;
|
||||
}
|
||||
|
||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
@@ -333,13 +311,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||
}
|
||||
|
||||
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||
// non-coalescing holes in the heap every page turn).
|
||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||
matrixBytes);
|
||||
freeStyleMiniKern(s);
|
||||
@@ -815,19 +793,12 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
return missed;
|
||||
}
|
||||
|
||||
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniKernLeftEntryCount = 0;
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
// Build mini intervals from sorted codepoints
|
||||
freeStyleMiniData(s);
|
||||
|
||||
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||
uint32_t intervalCapacity = validCount;
|
||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
||||
if (!s.miniIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
return static_cast<int>(cpCount);
|
||||
@@ -845,14 +816,15 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
}
|
||||
}
|
||||
|
||||
// Mini glyph array (reused across pages when it fits)
|
||||
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||
// Allocate mini glyph array
|
||||
s.miniGlyphCount = validCount;
|
||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
||||
if (!s.miniGlyphs) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
freeStyleMiniData(s);
|
||||
return static_cast<int>(cpCount);
|
||||
}
|
||||
s.miniGlyphCount = validCount;
|
||||
|
||||
// Build sorted read order for sequential I/O
|
||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||
@@ -919,7 +891,8 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||
}
|
||||
|
||||
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
||||
if (!s.miniBitmap) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||
delete[] readOrder;
|
||||
delete[] mappings;
|
||||
|
||||
@@ -168,22 +168,13 @@ class SdCardFont {
|
||||
// Stub EpdFontData returned when not prewarmed
|
||||
EpdFontData stubData{};
|
||||
|
||||
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||
// After a few pages the capacities converge on the book's max and page turns
|
||||
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||
// zeroes capacities) for style eviction / font unload.
|
||||
// Mini EpdFontData built during prewarm
|
||||
EpdFontData miniData{};
|
||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||
EpdGlyph* miniGlyphs = nullptr;
|
||||
uint8_t* miniBitmap = nullptr;
|
||||
uint32_t miniIntervalCount = 0;
|
||||
uint32_t miniGlyphCount = 0;
|
||||
uint32_t miniIntervalCapacity = 0;
|
||||
uint32_t miniGlyphCapacity = 0;
|
||||
uint32_t miniBitmapCapacity = 0;
|
||||
|
||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||
@@ -198,10 +189,6 @@ class SdCardFont {
|
||||
uint8_t miniKernLeftClassCount = 0;
|
||||
uint8_t miniKernRightClassCount = 0;
|
||||
int8_t* miniKernMatrix = nullptr;
|
||||
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||
uint16_t miniKernLeftCapacity = 0;
|
||||
uint16_t miniKernRightCapacity = 0;
|
||||
uint32_t miniKernMatrixCapacity = 0;
|
||||
|
||||
// The EpdFont whose data pointer we manage
|
||||
EpdFont epdFont{&stubData};
|
||||
|
||||
+4
-56
@@ -256,44 +256,8 @@ void Epub::parseCssFiles() const {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some converters emit one byte-identical stylesheet per chapter (100+ .css
|
||||
// entries), and each parse costs a zip locate plus an SD extract round-trip.
|
||||
// Map every CSS path to its central-directory (CRC32, compressed size) in a
|
||||
// single scan and parse only the first of each identical pair. Rules merge
|
||||
// into one global set, so dropping exact duplicates cannot lose styles. A
|
||||
// path that never matches a directory entry keeps key 0 and always parses.
|
||||
std::vector<uint64_t> dedupKeys(cssFiles.size(), 0);
|
||||
if (cssFiles.size() > 1) {
|
||||
std::unordered_map<std::string, size_t> pathToIndex;
|
||||
pathToIndex.reserve(cssFiles.size());
|
||||
for (size_t i = 0; i < cssFiles.size(); i++) {
|
||||
pathToIndex.emplace(FsHelpers::normalisePath(cssFiles[i]), i);
|
||||
}
|
||||
ZipFile(filepath).enumerateFileEntries([&](std::string_view entryPath, uint32_t crc32, uint32_t compressedSize) {
|
||||
if (!FsHelpers::hasCssExtension(entryPath)) {
|
||||
return;
|
||||
}
|
||||
const auto it = pathToIndex.find(std::string{entryPath});
|
||||
if (it != pathToIndex.end()) {
|
||||
dedupKeys[it->second] = (static_cast<uint64_t>(crc32) << 32) | compressedSize;
|
||||
}
|
||||
});
|
||||
}
|
||||
std::vector<uint64_t> seenKeys;
|
||||
seenKeys.reserve(cssFiles.size());
|
||||
size_t skippedDuplicates = 0;
|
||||
|
||||
// No cache yet - parse CSS files
|
||||
for (size_t cssIndex = 0; cssIndex < cssFiles.size(); cssIndex++) {
|
||||
const auto& cssPath = cssFiles[cssIndex];
|
||||
const uint64_t dedupKey = dedupKeys[cssIndex];
|
||||
if (dedupKey != 0) {
|
||||
if (std::find(seenKeys.begin(), seenKeys.end(), dedupKey) != seenKeys.end()) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
seenKeys.push_back(dedupKey);
|
||||
}
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
@@ -348,8 +312,7 @@ void Epub::parseCssFiles() const {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files (%zu identical duplicates skipped)", cssParser->ruleCount(),
|
||||
cssFiles.size(), skippedDuplicates);
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
cssParser->clear();
|
||||
}
|
||||
|
||||
@@ -765,29 +728,14 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
return content;
|
||||
}
|
||||
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize,
|
||||
const bool allowEarlyStop) const {
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
|
||||
}
|
||||
|
||||
bool Epub::extractItemToFile(const std::string& itemHref, const std::string& destPath) const {
|
||||
HalFile out;
|
||||
if (!Storage.openFileForWrite("EBP", destPath, out)) {
|
||||
return false;
|
||||
}
|
||||
const bool ok = readItemContentsToStream(itemHref, out, 4096);
|
||||
out.flush();
|
||||
out.close();
|
||||
if (!ok) {
|
||||
Storage.remove(destPath.c_str());
|
||||
}
|
||||
return ok;
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
||||
}
|
||||
|
||||
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
||||
|
||||
+1
-4
@@ -59,10 +59,7 @@ class Epub {
|
||||
bool generateThumbBmp(int height) 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,
|
||||
bool allowEarlyStop = false) const;
|
||||
// Extract an item to a file on SD. On failure the partial file is removed.
|
||||
bool extractItemToFile(const std::string& itemHref, const std::string& destPath) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
bool getItemSize(const std::string& itemHref, size_t* size) const;
|
||||
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
|
||||
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
|
||||
|
||||
@@ -17,10 +17,7 @@ namespace {
|
||||
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
|
||||
// measures the shaped visual text); cached word positions from v29 no longer
|
||||
// match what drawText renders.
|
||||
// v32: ImageBlock serializes the book-internal source href after the cache path
|
||||
// (lazy extraction: images are header-probed at build time and extracted on
|
||||
// first render).
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 32;
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 31;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
#include "Epub/converters/DirectPixelWriter.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
@@ -18,16 +15,8 @@
|
||||
// - uint16_t height
|
||||
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), srcPath(srcPath), width(width), height(height) {}
|
||||
|
||||
void* ImageBlock::extractCtx = nullptr;
|
||||
ImageBlock::ExtractFn ImageBlock::extractFn = nullptr;
|
||||
|
||||
void ImageBlock::setExtractor(void* ctx, ExtractFn fn) {
|
||||
extractCtx = ctx;
|
||||
extractFn = fn;
|
||||
}
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), width(width), height(height) {}
|
||||
|
||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||
|
||||
@@ -90,113 +79,8 @@ void rememberImageFailure(const std::string& path) {
|
||||
failedImageHashes[failedImageCount++] = imagePathHash(path);
|
||||
}
|
||||
|
||||
// --- Per-page-render RAM slot for the pixel cache ----------------------------
|
||||
// The tiled grayscale flow re-renders an image page once for the BW
|
||||
// double-refresh and again for every band of both gray planes, and each pass
|
||||
// re-read the whole .pxc off SD (~100 ms for a full-page image, ~13 passes).
|
||||
// Column clipping cannot reduce the SD traffic: the row stride (~100 B) is
|
||||
// smaller than an SD sector, so every sector is touched regardless of the band
|
||||
// window. Instead the first pass loads the payload into RAM and later passes
|
||||
// render from it. Chunked allocation because a single full-image block (up to
|
||||
// 96 KB) rarely fits the fragmented mid-render heap; each chunk is heap-gated
|
||||
// and any failure falls back to the streaming path unchanged. The reader
|
||||
// releases the slot when the page render completes, so nothing stays resident
|
||||
// across page turns.
|
||||
constexpr size_t PXC_CHUNK_SHIFT = 14; // 16 KB chunks
|
||||
constexpr size_t PXC_CHUNK_SIZE = 1u << PXC_CHUNK_SHIFT;
|
||||
constexpr size_t PXC_MAX_CHUNKS = 6; // 96 KB: a full-screen 2bpp image
|
||||
constexpr size_t PXC_HEAP_RESERVE = 24 * 1024;
|
||||
constexpr size_t PXC_MAX_ALLOC_RESERVE = 8 * 1024;
|
||||
// Rows can straddle a chunk boundary; they are reassembled into a stack
|
||||
// buffer. (screenWidth + 3) / 4 caps at 200 B for an 800px panel.
|
||||
constexpr int PXC_MAX_BYTES_PER_ROW = 208;
|
||||
|
||||
std::unique_ptr<uint8_t[]> pxcChunks[PXC_MAX_CHUNKS];
|
||||
uint64_t pxcSlotHash = 0;
|
||||
uint16_t pxcSlotWidth = 0;
|
||||
uint16_t pxcSlotHeight = 0;
|
||||
|
||||
void releasePxcSlot() {
|
||||
for (auto& chunk : pxcChunks) chunk.reset();
|
||||
pxcSlotHash = 0;
|
||||
pxcSlotWidth = 0;
|
||||
pxcSlotHeight = 0;
|
||||
}
|
||||
|
||||
const uint8_t* pxcRowPtr(size_t rowStart, int bytesPerRow, uint8_t* tempRow) {
|
||||
const size_t chunk = rowStart >> PXC_CHUNK_SHIFT;
|
||||
const size_t offset = rowStart & (PXC_CHUNK_SIZE - 1);
|
||||
if (offset + bytesPerRow <= PXC_CHUNK_SIZE) {
|
||||
return pxcChunks[chunk].get() + offset;
|
||||
}
|
||||
const size_t firstPart = PXC_CHUNK_SIZE - offset;
|
||||
memcpy(tempRow, pxcChunks[chunk].get() + offset, firstPart);
|
||||
memcpy(tempRow + firstPart, pxcChunks[chunk + 1].get(), bytesPerRow - firstPart);
|
||||
return tempRow;
|
||||
}
|
||||
|
||||
// cacheFile is positioned just past the header. True when the slot holds the
|
||||
// full pixel payload for this cache path afterward.
|
||||
bool loadPxcSlot(uint64_t cacheHash, HalFile& cacheFile, uint16_t cachedWidth, uint16_t cachedHeight, int bytesPerRow) {
|
||||
releasePxcSlot();
|
||||
if (bytesPerRow > PXC_MAX_BYTES_PER_ROW) {
|
||||
return false;
|
||||
}
|
||||
size_t remaining = (size_t)bytesPerRow * cachedHeight;
|
||||
const size_t chunkCount = (remaining + PXC_CHUNK_SIZE - 1) >> PXC_CHUNK_SHIFT;
|
||||
if (chunkCount == 0 || chunkCount > PXC_MAX_CHUNKS) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < chunkCount; i++) {
|
||||
const size_t want = remaining < PXC_CHUNK_SIZE ? remaining : PXC_CHUNK_SIZE;
|
||||
if (ESP.getFreeHeap() < remaining + PXC_HEAP_RESERVE || ESP.getMaxAllocHeap() < want + PXC_MAX_ALLOC_RESERVE) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
pxcChunks[i] = makeUniqueNoThrow<uint8_t[]>(want);
|
||||
if (!pxcChunks[i] || cacheFile.read(pxcChunks[i].get(), want) != static_cast<int>(want)) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
remaining -= want;
|
||||
}
|
||||
pxcSlotHash = cacheHash;
|
||||
pxcSlotWidth = cachedWidth;
|
||||
pxcSlotHeight = cachedHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
void renderRowsFromPxcSlot(GfxRenderer& renderer, int x, int y) {
|
||||
const int bytesPerRow = (pxcSlotWidth + 3) / 4;
|
||||
uint8_t tempRow[PXC_MAX_BYTES_PER_ROW];
|
||||
|
||||
DirectPixelWriter pw;
|
||||
pw.init(renderer);
|
||||
|
||||
for (int row = 0; row < pxcSlotHeight; row++) {
|
||||
const uint8_t* rowBuffer = pxcRowPtr((size_t)row * bytesPerRow, bytesPerRow, tempRow);
|
||||
pw.beginRow(y + row);
|
||||
int colStart, colEnd;
|
||||
pw.bandColRange(x, pxcSlotWidth, colStart, colEnd);
|
||||
for (int col = colStart; col < colEnd; col++) {
|
||||
const int byteIdx = col >> 2; // col / 4
|
||||
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
|
||||
const uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||
pw.writePixel(x + col, pixelValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
int expectedHeight) {
|
||||
// A later pass of the same page render: the payload is already in RAM, skip
|
||||
// the file entirely.
|
||||
const uint64_t cacheHash = imagePathHash(cachePath);
|
||||
if (pxcSlotHash == cacheHash && pxcSlotWidth != 0) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
HalFile cacheFile;
|
||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||
return false;
|
||||
@@ -214,24 +98,12 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// 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
|
||||
|
||||
// First pass of a page render: try to pull the payload into the RAM slot so
|
||||
// the remaining ~12 passes skip SD entirely.
|
||||
if (loadPxcSlot(cacheHash, cacheFile, cachedWidth, cachedHeight, bytesPerRow)) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
LOG_DBG("IMG", "Cache render complete (payload now in RAM)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Streaming fallback (slot didn't fit). A failed slot load may have consumed
|
||||
// part of the payload; rewind to just past the header.
|
||||
cacheFile.seek(4);
|
||||
|
||||
// Read several rows per SD access. A one-row-per-read loop here means
|
||||
// cachedHeight (~728) tiny reads through the storage mutex + SdFat; batching
|
||||
// rows into a ~4KB buffer cuts that to ~20 reads per pass without holding the
|
||||
// whole image.
|
||||
int rowsPerRead = 4096 / bytesPerRow;
|
||||
if (rowsPerRead < 1) rowsPerRead = 1;
|
||||
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
|
||||
@@ -304,8 +176,6 @@ bool ImageBlock::needsDecode() const { return !imageFailedThisSession(imagePath)
|
||||
|
||||
void ImageBlock::clearSessionRenderFailures() { failedImageCount = 0; }
|
||||
|
||||
void ImageBlock::releaseRenderCache() { releasePxcSlot(); }
|
||||
|
||||
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
|
||||
renderer.fillRect(x, y, width, height, true);
|
||||
if (width > 2 && height > 2) {
|
||||
@@ -355,15 +225,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
return; // Successfully rendered from cache
|
||||
}
|
||||
|
||||
// The build only header-probed the image for dimensions; pull the actual
|
||||
// file out of the book now, on first visit to the page.
|
||||
if (!srcPath.empty() && extractFn && !Storage.exists(imagePath.c_str())) {
|
||||
LOG_DBG("IMG", "Lazy-extracting %s -> %s", srcPath.c_str(), imagePath.c_str());
|
||||
if (!extractFn(extractCtx, srcPath.c_str(), imagePath.c_str())) {
|
||||
LOG_ERR("IMG", "Lazy extraction failed: %s", srcPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// No cache - need to decode the image
|
||||
// Check if image file exists
|
||||
HalFile file;
|
||||
@@ -419,7 +280,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
|
||||
bool ImageBlock::serialize(HalFile& file) {
|
||||
serialization::writeString(file, imagePath);
|
||||
serialization::writeString(file, srcPath);
|
||||
serialization::writePod(file, width);
|
||||
serialization::writePod(file, height);
|
||||
return true;
|
||||
@@ -427,11 +287,9 @@ bool ImageBlock::serialize(HalFile& file) {
|
||||
|
||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
|
||||
std::string path;
|
||||
std::string src;
|
||||
serialization::readString(file, path);
|
||||
serialization::readString(file, src);
|
||||
int16_t w, h;
|
||||
serialization::readPod(file, w);
|
||||
serialization::readPod(file, h);
|
||||
return std::unique_ptr<ImageBlock>(new (std::nothrow) ImageBlock(path, src, w, h));
|
||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
class ImageBlock final : public Block {
|
||||
public:
|
||||
ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height);
|
||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||
~ImageBlock() override = default;
|
||||
|
||||
const std::string& getImagePath() const { return imagePath; }
|
||||
@@ -21,21 +21,6 @@ class ImageBlock final : public Block {
|
||||
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
|
||||
static void clearSessionRenderFailures();
|
||||
|
||||
// A page render draws its image up to ~13 times (BW double-refresh plus every
|
||||
// grayscale band pass), and each draw streams the whole .pxc off SD. The
|
||||
// first draw caches the pixel payload in RAM (chunked, heap-gated, falls back
|
||||
// to streaming when it doesn't fit); the reader calls this when the page
|
||||
// render completes so nothing stays resident between pages.
|
||||
static void releaseRenderCache();
|
||||
|
||||
// Lazy extraction hook: the section build only header-probes images for their
|
||||
// dimensions; the file at imagePath is extracted out of the book on first
|
||||
// render, via this callback (function pointer + context, not std::function —
|
||||
// this is render-loop code). Registered by the reader activity that owns the
|
||||
// Epub, cleared on its exit.
|
||||
using ExtractFn = bool (*)(void* ctx, const char* srcPath, const char* destPath);
|
||||
static void setExtractor(void* ctx, ExtractFn fn);
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
@@ -45,10 +30,6 @@ class ImageBlock final : public Block {
|
||||
|
||||
private:
|
||||
std::string imagePath;
|
||||
std::string srcPath; // book-internal source href; empty once known-extracted
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
|
||||
static void* extractCtx;
|
||||
static ExtractFn extractFn;
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#include "ImageDimsProbe.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace {
|
||||
// SOFn markers carry the frame dimensions. C4 (DHT), C8 (JPG extension) and
|
||||
// CC (DAC) share the 0xCn range but are not frame headers.
|
||||
bool isJpegSof(const uint8_t marker) {
|
||||
return marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||
}
|
||||
constexpr uint8_t PNG_SIG[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
} // namespace
|
||||
|
||||
bool ImageDimsProbe::feed(const uint8_t b) {
|
||||
switch (state) {
|
||||
case State::Sniff:
|
||||
if (b == 0xFF) {
|
||||
state = State::JpegSoi;
|
||||
} else if (b == PNG_SIG[0]) {
|
||||
state = State::PngHeader;
|
||||
} else {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
pos = 1;
|
||||
return true;
|
||||
|
||||
case State::PngHeader:
|
||||
// Bytes 1..7: signature; 8..11: IHDR length; 12..15: "IHDR"; 16..23: dims.
|
||||
if (pos < 8) {
|
||||
if (b != PNG_SIG[pos]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 12 && pos < 16) {
|
||||
if (b != "IHDR"[pos - 12]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 16 && pos < 20) {
|
||||
width = static_cast<uint16_t>((static_cast<uint32_t>(width) << 8) | b);
|
||||
} else if (pos >= 20 && pos < 24) {
|
||||
height = static_cast<uint16_t>((static_cast<uint32_t>(height) << 8) | b);
|
||||
if (pos == 23) {
|
||||
state = State::Done;
|
||||
pos++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
return true;
|
||||
|
||||
case State::JpegSoi:
|
||||
if (b != 0xD8) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegFf:
|
||||
if (b != 0xFF) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegMarker;
|
||||
return true;
|
||||
|
||||
case State::JpegMarker:
|
||||
if (b == 0xFF) return true; // fill bytes before a marker are legal
|
||||
if (b == 0x01 || (b >= 0xD0 && b <= 0xD8)) {
|
||||
// TEM / RSTn / SOI: standalone, no length field.
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
}
|
||||
if (b == 0xD9 || b == 0xDA) {
|
||||
// EOI or SOS before any SOF: no dimensions to be found.
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
sofPending = isJpegSof(b);
|
||||
state = State::JpegLenHi;
|
||||
return true;
|
||||
|
||||
case State::JpegLenHi:
|
||||
segLen = static_cast<uint16_t>(b << 8);
|
||||
state = State::JpegLenLo;
|
||||
return true;
|
||||
|
||||
case State::JpegLenLo:
|
||||
segLen = static_cast<uint16_t>(segLen | b);
|
||||
if (segLen < 2 || (sofPending && segLen < 7)) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
if (sofPending) {
|
||||
sofFill = 0;
|
||||
state = State::JpegSof;
|
||||
} else if (segLen == 2) {
|
||||
state = State::JpegFf;
|
||||
} else {
|
||||
skipLeft = static_cast<uint32_t>(segLen) - 2;
|
||||
state = State::JpegSkip;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::JpegSkip:
|
||||
if (--skipLeft == 0) state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegSof:
|
||||
sofBuf[sofFill++] = b;
|
||||
if (sofFill == 5) {
|
||||
height = static_cast<uint16_t>((sofBuf[1] << 8) | sofBuf[2]);
|
||||
width = static_cast<uint16_t>((sofBuf[3] << 8) | sofBuf[4]);
|
||||
state = State::Done;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::Done:
|
||||
case State::Failed:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t b) { return feed(b) ? 1 : 0; }
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t* data, const size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (!feed(data[i])) return i; // short write: polite early stop
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
bool ImageDimsProbe::getDimensions(ImageDimensions& out) const {
|
||||
if (state != State::Done || width == 0 || height == 0 || width > INT16_MAX || height > INT16_MAX) {
|
||||
return false;
|
||||
}
|
||||
out.width = static_cast<int16_t>(width);
|
||||
out.height = static_cast<int16_t>(height);
|
||||
return true;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#pragma once
|
||||
#include <Print.h>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
// Streaming JPEG/PNG header parser: finds image dimensions from the first few
|
||||
// KB of a compressed stream without inflating the whole image. Feed bytes via
|
||||
// the Print interface (e.g. Epub::readItemContentsToStream with
|
||||
// allowEarlyStop=true); write() returns short once the dimensions are known or
|
||||
// the stream is known to be unusable, which the zip layer treats as a polite
|
||||
// early stop rather than an error.
|
||||
//
|
||||
// JPEG: walks marker segments (skipping EXIF/APPn of any size statefully, so
|
||||
// nothing is buffered) until a SOFn frame header yields the dimensions.
|
||||
// PNG: reads the IHDR fields at their fixed offsets (bytes 16..23).
|
||||
class ImageDimsProbe : public Print {
|
||||
public:
|
||||
size_t write(uint8_t b) override;
|
||||
size_t write(const uint8_t* data, size_t len) override;
|
||||
|
||||
// True only when a valid header was found; fills `out`.
|
||||
bool getDimensions(ImageDimensions& out) const;
|
||||
|
||||
private:
|
||||
bool feed(uint8_t b); // returns false once parsing is finished (found or failed)
|
||||
|
||||
enum class State : uint8_t {
|
||||
Sniff, // first byte decides the format
|
||||
PngHeader, // PNG signature + IHDR at fixed offsets
|
||||
JpegSoi, // second SOI byte (0xD8)
|
||||
JpegFf, // expect a 0xFF marker prefix
|
||||
JpegMarker, // marker type byte (0xFF padding allowed)
|
||||
JpegLenHi, // segment length, high byte
|
||||
JpegLenLo, // segment length, low byte
|
||||
JpegSkip, // skipping a non-SOF segment body
|
||||
JpegSof, // collecting the 5 SOF bytes: precision, height(2), width(2)
|
||||
Done,
|
||||
Failed,
|
||||
};
|
||||
State state = State::Sniff;
|
||||
uint32_t pos = 0; // absolute stream offset (PNG fixed-offset parsing)
|
||||
uint32_t skipLeft = 0; // remaining segment bytes to skip
|
||||
uint16_t segLen = 0;
|
||||
bool sofPending = false; // current segment is a SOF frame header
|
||||
uint8_t sofBuf[5] = {0};
|
||||
uint8_t sofFill = 0;
|
||||
uint16_t width = 0;
|
||||
uint16_t height = 0;
|
||||
};
|
||||
@@ -15,7 +15,6 @@
|
||||
#include "Epub.h"
|
||||
#include "Epub/Page.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
#include "Epub/converters/ImageDimsProbe.h"
|
||||
#include "Epub/converters/ImageToFramebufferDecoder.h"
|
||||
#include "Epub/htmlEntities.h"
|
||||
|
||||
@@ -555,47 +554,28 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
|
||||
|
||||
{
|
||||
// Probe the dimensions from the entry's first bytes (early-aborted
|
||||
// inflate, a few KB) instead of extracting the whole image now —
|
||||
// extraction is deferred to the first render of the page (see
|
||||
// ImageBlock's lazy extractor). This is what keeps first-open of an
|
||||
// image-heavy chapter from stalling for seconds per image.
|
||||
// Extract image to cache file
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
|
||||
if (extractSuccess) {
|
||||
// Get image dimensions, retrying to absorb SD-card sync latency on slow
|
||||
// cards. Replaces a blanket delay(50) that cost ~50ms on every image, and
|
||||
// closes the silent-drop bug where a single getDimensions failure was fatal.
|
||||
ImageDimensions dims = {0, 0};
|
||||
ImageDimsProbe headerProbe;
|
||||
self->epub->readItemContentsToStream(resolvedPath, headerProbe, 1024, /*allowEarlyStop=*/true);
|
||||
bool gotDimensions = headerProbe.getDimensions(dims);
|
||||
|
||||
if (!gotDimensions) {
|
||||
// No header within the stream (rare) — fall back to extracting the
|
||||
// whole image and probing the file. That can take seconds, so
|
||||
// surface the indexing popup first (single-shot per parser).
|
||||
if (self->popupFn && !self->imagePopupFired) {
|
||||
self->imagePopupFired = true;
|
||||
self->popupFn();
|
||||
}
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
if (extractSuccess) {
|
||||
// Retry to absorb SD-card sync latency on slow cards, and to close
|
||||
// the silent-drop bug where a single getDimensions failure was fatal.
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
bool gotDimensions = false;
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
|
||||
if (gotDimensions) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
@@ -742,8 +722,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentPageNextY += imageMarginTop;
|
||||
|
||||
// Create ImageBlock and add to page
|
||||
auto imageBlock =
|
||||
std::make_shared<ImageBlock>(cachedImagePath, resolvedPath, displayWidth, displayHeight);
|
||||
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||
if (!imageBlock) {
|
||||
LOG_ERR("EHP", "Failed to create ImageBlock");
|
||||
return;
|
||||
@@ -774,6 +753,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
LOG_ERR("EHP", "Failed to get image dimensions");
|
||||
Storage.remove(cachedImagePath.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
} // isFormatSupported
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ class ChapterHtmlSlimParser {
|
||||
GfxRenderer& renderer;
|
||||
std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)> completePageFn;
|
||||
std::function<void()> popupFn; // Popup callback
|
||||
bool imagePopupFired = false; // popupFn fired for the first image probe (single-shot)
|
||||
int depth = 0;
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
|
||||
@@ -22,21 +22,7 @@ constexpr char DEVICE_ID[] = "crosspoint-reader";
|
||||
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
|
||||
// floor. Check both total free heap and largest contiguous block so fragmented
|
||||
// heap does not fall through into a failed TLS allocation path.
|
||||
// MEMFIX-PORT: TLS heap gate; portable
|
||||
// Field data (July 2026): launching sync from a reader session lands at
|
||||
// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles
|
||||
// allocation failure by returning MEMORY_E (no abort under -fno-exceptions),
|
||||
// so an optimistic attempt degrades to the same clean "sync failed" as the
|
||||
// gate — the gate only needs to keep out states where a doomed handshake
|
||||
// would waste tens of seconds, not guarantee success.
|
||||
//
|
||||
// Free and largest-block have separate requirements: with SP ECC
|
||||
// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so
|
||||
// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not
|
||||
// a run of fast-math bignums. A handshake was measured succeeding inside a
|
||||
// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit.
|
||||
constexpr uint32_t MIN_FREE_FOR_TLS = 50000;
|
||||
constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000;
|
||||
constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
|
||||
|
||||
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native
|
||||
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
|
||||
@@ -53,9 +39,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) {
|
||||
bool insufficientHeap() {
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
|
||||
if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap,
|
||||
MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS);
|
||||
if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
|
||||
maxAllocHeap, MIN_HEAP_FOR_TLS);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -437,7 +437,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize, const bool allowEarlyStop) {
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
|
||||
const ScopedOpenClose zip{*this};
|
||||
if (!zip) return false;
|
||||
|
||||
@@ -469,9 +469,8 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (out.write(buffer, dataRead) != dataRead) {
|
||||
free(buffer);
|
||||
if (allowEarlyStop) return true; // sink has what it needs
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
free(buffer);
|
||||
return false;
|
||||
}
|
||||
remaining -= dataRead;
|
||||
@@ -526,11 +525,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
|
||||
if (produced > 0) {
|
||||
if (out.write(outputBuffer, produced) != produced) {
|
||||
if (allowEarlyStop) {
|
||||
success = true; // sink has what it needs
|
||||
} else {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
}
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -69,10 +69,7 @@ class ZipFile {
|
||||
// Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple
|
||||
// These functions will open and close the zip as needed
|
||||
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false);
|
||||
// allowEarlyStop: a short write from `out` is treated as the sink asking to
|
||||
// stop (returns true) instead of a write failure — used by header probes
|
||||
// that only need the first bytes of an entry.
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize, bool allowEarlyStop = false);
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize);
|
||||
|
||||
template <typename F>
|
||||
bool enumerateFilePaths(F&& callback) {
|
||||
@@ -83,14 +80,6 @@ class ZipFile {
|
||||
return true;
|
||||
}
|
||||
|
||||
return enumerateFileEntries([&callback](std::string_view path, uint32_t, uint32_t) { callback(path); });
|
||||
}
|
||||
|
||||
// Callback receives (path, crc32, compressedSize) for each central-directory
|
||||
// entry. Always scans the central directory: the slim-stat cache does not
|
||||
// hold CRCs.
|
||||
template <typename F>
|
||||
bool enumerateFileEntries(F&& callback) {
|
||||
const bool wasOpen = isOpen();
|
||||
if (!wasOpen && !open()) {
|
||||
return false;
|
||||
@@ -114,11 +103,7 @@ class ZipFile {
|
||||
break;
|
||||
}
|
||||
|
||||
file.seekCur(12);
|
||||
uint32_t crc32, compressedSize;
|
||||
file.read(&crc32, 4);
|
||||
file.read(&compressedSize, 4);
|
||||
file.seekCur(4);
|
||||
file.seekCur(24);
|
||||
uint16_t nameLen, m, k;
|
||||
file.read(&nameLen, 2);
|
||||
file.read(&m, 2);
|
||||
@@ -128,7 +113,7 @@ class ZipFile {
|
||||
if (nameLen < sizeof(itemName)) {
|
||||
file.read(itemName, nameLen);
|
||||
itemName[nameLen] = '\0';
|
||||
callback(std::string_view{itemName, nameLen}, crc32, compressedSize);
|
||||
callback(std::string_view{itemName, nameLen});
|
||||
} else {
|
||||
file.seekCur(nameLen);
|
||||
}
|
||||
|
||||
+2
-57
@@ -13,10 +13,7 @@ framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
check_tool = cppcheck
|
||||
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||
; project header as missing (~400 information-level lines) and fails the job.
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
check_skip_packages = yes
|
||||
|
||||
board_upload.flash_size = 16MB
|
||||
@@ -43,14 +40,7 @@ build_flags =
|
||||
-DWOLFSSL_OPTIONS_H
|
||||
-DWOLFSSL_CLIENT_EXAMPLE
|
||||
-DWOLFSSL_TLS13
|
||||
# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation
|
||||
# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums
|
||||
# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of
|
||||
# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session
|
||||
# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster.
|
||||
# SP_SMALL trades the large precomputed point tables for smaller flash.
|
||||
-DWOLFSSL_HAVE_SP_ECC
|
||||
-DWOLFSSL_SP_SMALL
|
||||
-DWOLFSSL_SP_RISCV32
|
||||
-DHAVE_TLS_EXTENSIONS
|
||||
-DHAVE_SUPPORTED_CURVES
|
||||
-DHAVE_HKDF
|
||||
@@ -71,51 +61,6 @@ board_build.flash_mode = dio
|
||||
board_build.flash_size = 16MB
|
||||
board_build.partitions = partitions.csv
|
||||
|
||||
; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the
|
||||
; Arduino core libs on first build (slower once, cached after; needs the CMake
|
||||
; pin in platformio.local.ini on macOS).
|
||||
;
|
||||
; If an interrupted rebuild fails with "multiple definition of 'app_main'"
|
||||
; (stale generated scaffold), clean it up with:
|
||||
; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default
|
||||
; Do NOT use `git clean -fdX` — it deletes platformio.local.ini.
|
||||
custom_sdkconfig =
|
||||
; Task stack right-sizing from measured high-water marks (heap block map +
|
||||
; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across
|
||||
; every capture; the FreeRTOS timer service used ~0.5 KB
|
||||
; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap.
|
||||
CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560
|
||||
; Move the WiFi stack's non-critical hot paths out of IRAM into flash.
|
||||
; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this
|
||||
; frees lands directly in the heap — paid for with lower WiFi throughput
|
||||
; during transfers (occasional sync/OTA use, not streaming: acceptable).
|
||||
; IRAM cost is static, so the heap gain applies even with WiFi off.
|
||||
CONFIG_ESP_WIFI_IRAM_OPT=n
|
||||
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
|
||||
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||
; the core source list; all other bundled libraries default to enabled.
|
||||
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||
; ESP_SR (speech recognition) only exists in the S3 core; its wrapper includes
|
||||
; ESP_I2S.h, which the isolated core rebuild can't resolve. Unused here anyway,
|
||||
; so drop it or the sticky env fails to build.
|
||||
CONFIG_ARDUINO_SELECTIVE_ESP_SR=n
|
||||
|
||||
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||
; require embedded server certs the lib builder can't generate
|
||||
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||
custom_component_remove =
|
||||
espressif/esp_insights
|
||||
espressif/esp_rainmaker
|
||||
espressif/esp_diagnostics
|
||||
espressif/esp_diag_data_store
|
||||
espressif/esp_schedule
|
||||
espressif/esp_rcp_update
|
||||
espressif/esp_secure_cert_mgr
|
||||
espressif/cbor
|
||||
|
||||
extra_scripts =
|
||||
pre:scripts/patch_wolfssl.py
|
||||
pre:scripts/build_html.py
|
||||
|
||||
@@ -12,12 +12,8 @@ OVERRIDES = f"""
|
||||
#ifndef HAVE_FFDHE_2048
|
||||
#define HAVE_FFDHE_2048
|
||||
#endif
|
||||
/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum,
|
||||
ISRG Root X1 included) with half the per-bignum heap of 16384: with
|
||||
WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the
|
||||
heap, and TLS cert verification allocates dozens at once. */
|
||||
#undef FP_MAX_BITS
|
||||
#define FP_MAX_BITS 8192
|
||||
#define FP_MAX_BITS 16384
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -157,11 +157,6 @@ void EpubReaderActivity::onEnter() {
|
||||
}
|
||||
|
||||
ImageBlock::clearSessionRenderFailures();
|
||||
// Lazy image extraction: section builds only header-probe images, so the first
|
||||
// render of an image page pulls the file out of the EPUB through this hook.
|
||||
ImageBlock::setExtractor(epub.get(), [](void* ctx, const char* src, const char* dest) {
|
||||
return static_cast<Epub*>(ctx)->extractItemToFile(src, dest);
|
||||
});
|
||||
|
||||
// Configure screen orientation based on settings
|
||||
// NOTE: This affects layout math and must be applied before any render calls.
|
||||
@@ -214,10 +209,6 @@ void EpubReaderActivity::onEnter() {
|
||||
void EpubReaderActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// The extractor holds a raw pointer to this activity's epub; drop it before
|
||||
// the activity (and the shared_ptr) goes away.
|
||||
ImageBlock::setExtractor(nullptr, nullptr);
|
||||
|
||||
// Reset orientation back to portrait for the rest of the UI
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
@@ -267,30 +258,6 @@ void EpubReaderActivity::openReaderMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::buildTickHeapGate() {
|
||||
const size_t freeHeap = ESP.getFreeHeap();
|
||||
const size_t maxBlock = ESP.getMaxAllocHeap();
|
||||
// Below the floors: just wait. The tick is deferrable — page-turn transients
|
||||
// free up between turns and the tick retries every loop pass. Track the
|
||||
// paused state so skipLoopDelay() stops pinning the CPU at full speed while
|
||||
// no build work is actually happening (the gate can stay closed for a long
|
||||
// stretch if the retained build context itself holds the heap down).
|
||||
buildHeapPaused = freeHeap < BACKGROUND_BUILD_MIN_FREE_HEAP || maxBlock < BACKGROUND_BUILD_MIN_MAX_ALLOC;
|
||||
return !buildHeapPaused;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::showBuildPopup() {
|
||||
// Mid-build indexing popup: only during onEnter's blocking build-to-target phase
|
||||
// (buildPopupPending), at most once, and only when the framebuffer isn't on loan.
|
||||
// If it fires while the loan is active (e.g. the parser's size-based call during
|
||||
// startBuild), pending stays set and the deadline check retries after the loan.
|
||||
if (!buildPopupPending || !renderer.hasFrameBuffer()) return;
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts.
|
||||
pagesUntilFullRefresh = 1;
|
||||
buildPopupPending = false;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openDictionaryWordSelect() {
|
||||
if (SETTINGS.dictionaryName[0] == '\0') {
|
||||
showDictionaryMessage = true;
|
||||
@@ -321,40 +288,6 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
|
||||
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
|
||||
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
|
||||
// cached. Debounced past rapid page-flipping, one attempt per position, and
|
||||
// deferred while a render/build owns the CPU or the heap is at the render
|
||||
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
|
||||
// section isn't loaded).
|
||||
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
|
||||
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
|
||||
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
|
||||
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC &&
|
||||
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
|
||||
RenderLock lock; // the page table must not change under the scan
|
||||
// Re-check under the lock: peek() and acquisition are not atomic, so the render
|
||||
// task may have reset/replaced the section or moved the page in between.
|
||||
if (section && !section->isBuilding() &&
|
||||
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
|
||||
idlePrewarmSpine = currentSpineIndex;
|
||||
idlePrewarmPage = section->currentPage;
|
||||
const int nextPage = section->currentPage + 1;
|
||||
if (nextPage < static_cast<int>(section->pageCount)) {
|
||||
if (const auto p = section->loadPage(nextPage)) {
|
||||
if (auto* fcm = renderer.getFontCacheManager()) {
|
||||
const auto t0 = millis();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
|
||||
scope.endScanAndPrewarm();
|
||||
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
|
||||
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
|
||||
// session, so reopening a partial deliberately does NOT start it (see the deferral in
|
||||
@@ -390,17 +323,14 @@ void EpubReaderActivity::loop() {
|
||||
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
|
||||
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
|
||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
|
||||
buildTickHeapGate()) {
|
||||
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) {
|
||||
RenderLock lock;
|
||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||
// buildSomeMore() would fail and wrongly reset the section. The heap gate must be re-read
|
||||
// too: a render that won the lock race can expand retained glyph buffers, invalidating the
|
||||
// pre-lock heap reading. cppcheck can't see the cross-task mutation, so it flags this as
|
||||
// always true.
|
||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
||||
// mutation, so it flags this as always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding() && buildTickHeapGate()) {
|
||||
if (section->isBuilding()) {
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
@@ -1204,29 +1134,18 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
// Mid-build popup surfacing for slow builds the predictive gates can't
|
||||
// see (image extraction/probing inside a single page, or any chunk
|
||||
// overrunning the deadline). The parser fires the callback before the
|
||||
// first image probe; buildPopupPending gates it to this blocking phase
|
||||
// so a background build in loop() can never draw over a displayed page.
|
||||
buildPopupPending = !showPopup;
|
||||
const unsigned long buildStartMs = millis();
|
||||
bool started;
|
||||
{
|
||||
// Lend the framebuffer's 48 KB to startBuild only (the spine HTML
|
||||
// inflation peak). The chunk loop below runs without it so the popup
|
||||
// can draw mid-build; background chunks never had the loan either.
|
||||
GfxRenderer::FrameBufferLoan loan(renderer);
|
||||
started = section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled,
|
||||
[this] { showBuildPopup(); });
|
||||
}
|
||||
if (!started) {
|
||||
// Lend the framebuffer's 48 KB to the blocking pre-render burst
|
||||
// (startBuild inflates the whole spine HTML — the memory peak). The
|
||||
// background buildSomeMore chunks in loop() do NOT get the loan: they
|
||||
// deliberately interleave with page renders. Restored before render.
|
||||
GfxRenderer::FrameBufferLoan loan(renderer);
|
||||
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed to start section build");
|
||||
section.reset();
|
||||
buildPopupPending = false;
|
||||
loan.end(); // restore before anything draws (showBuildError renders a popup)
|
||||
showBuildError();
|
||||
return;
|
||||
}
|
||||
@@ -1235,19 +1154,15 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
||||
if (buildPopupPending && millis() - buildStartMs >= BUILD_POPUP_DEADLINE_MS) {
|
||||
// The predictive gates guessed fast but the build blew the silent budget.
|
||||
showBuildPopup();
|
||||
}
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
buildPopupPending = false;
|
||||
loan.end(); // restore before anything draws (showBuildError renders a popup)
|
||||
showBuildError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
buildPopupPending = false;
|
||||
loan.end();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1408,7 +1323,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
const auto start = millis();
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
lastRenderCompleteMs = millis();
|
||||
}
|
||||
// Only persist when the position actually changed. render() also runs on menu,
|
||||
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
|
||||
@@ -1471,13 +1385,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const auto t0 = millis();
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
|
||||
// The image pixel-cache RAM slot lives for exactly one page render (it feeds
|
||||
// the BW double-refresh and every grayscale band pass); release it on every
|
||||
// exit so nothing stays resident across page turns.
|
||||
struct PxcSlotGuard {
|
||||
~PxcSlotGuard() { ImageBlock::releaseRenderCache(); }
|
||||
} pxcSlotGuard;
|
||||
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
@@ -1491,11 +1398,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
|
||||
const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale();
|
||||
// Whole-plane buffering only pays when the BW refresh genuinely runs async
|
||||
// underneath it; on blocking panels (X3) it would just spend ~50 KB for the
|
||||
// identical serial timing. Image pages take the blocking double-FAST path
|
||||
// below (no async refresh is ever started), so they'd spend the buffers with
|
||||
// nothing in flight to overlap.
|
||||
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh() && !pageHasImages;
|
||||
// underneath it; on blocking panels it would just spend ~50 KB for the
|
||||
// identical serial timing.
|
||||
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh();
|
||||
auto renderGrayscalePass = [&]() {
|
||||
if (needsTextGrayscale) {
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
@@ -1541,9 +1446,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// regardless of residue.
|
||||
pagesUntilFullRefresh = 1;
|
||||
} else {
|
||||
// Async form: start the waveform and return so the grayscale plane rendering
|
||||
// below overlaps the panel's refresh time instead of following it.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, overlapRefresh);
|
||||
// Deferred when a tiled grayscale pass follows: the plane rendering below
|
||||
// then overlaps the panel's refresh time instead of following it.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh);
|
||||
}
|
||||
const auto tDisplay = millis();
|
||||
|
||||
@@ -1578,25 +1483,12 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// Tiered on heap pressure: two plane buffers hide both plane renders
|
||||
// inside the refresh wait; one hides the LSB render (its buffer is reused
|
||||
// for MSB after streaming); none falls back to the strip-scratch flow with
|
||||
// no overlap. Each buffer is only attempted when it leaves ~60 KB free so
|
||||
// the pass never starves concurrent allocations: the next page re-render
|
||||
// allocates through throwing std::string paths that abort() on OOM under
|
||||
// -fno-exceptions, so a plane buffer that "fits" but eats the render
|
||||
// headroom is worse than the strip fallback. Blocking panels skip the
|
||||
// buffers entirely (nothing to overlap).
|
||||
constexpr size_t PLANE_BUF_HEADROOM = 60000;
|
||||
// Free-heap alone ignores fragmentation: taking the largest block for a
|
||||
// plane can leave only slivers behind even when total headroom looks fine.
|
||||
// Require the block to fit the plane with 16 KB contiguous to spare, which
|
||||
// also keeps the advance-table batch scratch viable mid-render (same
|
||||
// rationale as BACKGROUND_BUILD_MIN_MAX_ALLOC).
|
||||
constexpr size_t PLANE_BUF_MAX_ALLOC_RESERVE = 16 * 1024;
|
||||
const auto planeBufFits = [planeBytes] {
|
||||
return ESP.getFreeHeap() >= planeBytes + PLANE_BUF_HEADROOM &&
|
||||
ESP.getMaxAllocHeap() >= planeBytes + PLANE_BUF_MAX_ALLOC_RESERVE;
|
||||
};
|
||||
auto lsbPlaneBuf = (overlapRefresh && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
auto msbPlaneBuf = (lsbPlaneBuf && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
// no overlap. The MSB buffer is only attempted when it leaves ~60 KB free
|
||||
// so the pass never starves concurrent allocations. Blocking panels skip
|
||||
// the buffers entirely (nothing to overlap).
|
||||
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
auto msbPlaneBuf =
|
||||
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
|
||||
if (lsbPlaneBuf) {
|
||||
renderPlaneToBuffer(true, lsbPlaneBuf.get());
|
||||
@@ -1630,20 +1522,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
|
||||
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
|
||||
} else {
|
||||
// Per-strip scratch tier: blocking panels (X3) and the OOM fallback.
|
||||
// The strip writes below need the panel idle, so wait out any pending
|
||||
// async refresh first (no-op on blocking panels).
|
||||
// Per-strip scratch tier: blocking panels and the OOM fallback. The
|
||||
// strip writes below need the panel idle, so wait out any pending async
|
||||
// refresh first (no-op on blocking panels).
|
||||
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
|
||||
renderer.waitRefreshComplete();
|
||||
if (!scratch) {
|
||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||
if (overlapRefresh) {
|
||||
// The BW refresh ran the shadow-free async path, so controller RAM's
|
||||
// differential baseline was never rebuilt. Even with AA skipped it must
|
||||
// be re-synced from the intact BW framebuffer, or the next differential
|
||||
// update diffs against stale contents.
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
}
|
||||
} else {
|
||||
// Bands may be streamed in any order: X4 windows each via setRamArea,
|
||||
// X3 via PTL.
|
||||
|
||||
@@ -44,13 +44,6 @@ class EpubReaderActivity final : public Activity {
|
||||
unsigned long dictionaryMessageTime = 0UL;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
|
||||
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
|
||||
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
|
||||
// SD reads on the page-turn critical path. One attempt per position.
|
||||
int idlePrewarmSpine = -1;
|
||||
int idlePrewarmPage = -1;
|
||||
unsigned long lastRenderCompleteMs = 0;
|
||||
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
|
||||
std::vector<BookmarkEntry> cachedBookmarks;
|
||||
// Tracks whether this book is currently removed from Recent Books by the
|
||||
@@ -96,33 +89,6 @@ class EpubReaderActivity final : public Activity {
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
|
||||
// MEMFIX-PORT: background-build heap floor; portable
|
||||
// Skip background build ticks below this free-heap floor. The parse path grows
|
||||
// word vectors of heap strings — throwing allocations that abort() on OOM under
|
||||
// -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a
|
||||
// background tick under heap pressure). The tick is deferrable work:
|
||||
// page-turn transients free up between turns and the build resumes; the render
|
||||
// path still builds the page it actually needs regardless of this floor.
|
||||
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024;
|
||||
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
|
||||
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
|
||||
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
|
||||
// any single allocation can actually have it. 16 KB also keeps the advance-table
|
||||
// batch path (16 KB scratch) viable during builds.
|
||||
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
|
||||
// Gate for a background build tick: true when the heap can take parse allocations.
|
||||
// Updates buildHeapPaused as a side effect.
|
||||
bool buildTickHeapGate();
|
||||
// True while the background build is gated on the heap floors. Lets skipLoopDelay()
|
||||
// return the loop to normal delay/power-saving during the pause: isBuilding() stays
|
||||
// true the whole time, and without this the loop would spin at full CPU speed doing
|
||||
// no build work — indefinitely, if the build context itself keeps the heap low.
|
||||
bool buildHeapPaused = false;
|
||||
// Heap floor for optional render-adjacent work (idle prewarm). Page
|
||||
// deserialization (TextBlock word vectors/strings) and glyph caching allocate
|
||||
// through throwing paths that abort() on OOM; skip deferrable work below it.
|
||||
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
@@ -146,16 +112,6 @@ class EpubReaderActivity final : public Activity {
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Deadline backstop for the predictive gates above: if the blocking build-to-target still
|
||||
// hasn't produced the landing page this long after the build started, surface the popup
|
||||
// mid-build. Builds that finish under the deadline stay popup-free.
|
||||
static constexpr unsigned long BUILD_POPUP_DEADLINE_MS = 1000;
|
||||
// True only during onEnter's blocking build-to-target phase, until the popup has been
|
||||
// drawn. Gates showBuildPopup() so the parser's popup callback (which persists into
|
||||
// background buildSomeMore chunks) can never draw over a displayed page.
|
||||
bool buildPopupPending = false;
|
||||
// Draw the indexing popup mid-build (parser image-probe callback and deadline backstop).
|
||||
void showBuildPopup();
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
@@ -191,10 +147,8 @@ class EpubReaderActivity final : public Activity {
|
||||
// Full CPU speed + fast loop ticks while a section build runs: at the low-power
|
||||
// frequency a giant chapter's background rebuild stretches from ~40s to many
|
||||
// minutes, so the reader exits before it can finalize and the next open restarts
|
||||
// it from page 0. Reverts to normal power behavior the moment the build finishes,
|
||||
// and while the build is heap-paused (no work is happening, so spinning at full
|
||||
// speed would only burn battery; the paused gate still retries every loop pass).
|
||||
bool skipLoopDelay() override { return section && section->isBuilding() && !buildHeapPaused; }
|
||||
// it from page 0. Reverts to normal power behavior the moment the build finishes.
|
||||
bool skipLoopDelay() override { return section && section->isBuilding(); }
|
||||
bool isReaderActivity() const override { return true; }
|
||||
ScreenshotInfo getScreenshotInfo() const override;
|
||||
CrossPointPosition getCurrentPosition() const;
|
||||
|
||||
@@ -205,14 +205,6 @@ void CrossPointWebServer::begin() {
|
||||
udpActive = udp.begin(LOCAL_UDP_PORT);
|
||||
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
|
||||
|
||||
// All request handlers run on the task that calls handleClient(). Register
|
||||
// that task before any handler can call esp_task_wdt_reset().
|
||||
const esp_err_t watchdogResult = esp_task_wdt_add(nullptr);
|
||||
watchdogTaskRegistered = watchdogResult == ESP_OK;
|
||||
if (!watchdogTaskRegistered) {
|
||||
LOG_ERR("WEB", "Failed to register web server task with watchdog: %s", esp_err_to_name(watchdogResult));
|
||||
}
|
||||
|
||||
running = true;
|
||||
|
||||
LOG_DBG("WEB", "Web server started on port %d", port);
|
||||
@@ -242,10 +234,6 @@ void CrossPointWebServer::abortWsUpload(const char* tag) {
|
||||
void CrossPointWebServer::stop() {
|
||||
if (!running || !server) {
|
||||
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
|
||||
if (watchdogTaskRegistered) {
|
||||
esp_task_wdt_delete(nullptr);
|
||||
watchdogTaskRegistered = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,11 +274,6 @@ void CrossPointWebServer::stop() {
|
||||
LOG_DBG("WEB", "Web server stopped and deleted");
|
||||
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
if (watchdogTaskRegistered) {
|
||||
esp_task_wdt_delete(nullptr);
|
||||
watchdogTaskRegistered = false;
|
||||
}
|
||||
|
||||
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
|
||||
// later in the file and will be cleared when they go out of scope or on next upload
|
||||
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -72,7 +72,6 @@ class CrossPointWebServer {
|
||||
std::unique_ptr<WebServer> server = nullptr;
|
||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||
bool running = false;
|
||||
bool watchdogTaskRegistered = false;
|
||||
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||
uint16_t port = 80;
|
||||
uint16_t wsPort = 81; // WebSocket port
|
||||
|
||||
@@ -58,7 +58,10 @@ HttpDownloader::DownloadError runGetWolf(const std::string& startUrl, const std:
|
||||
LOG_ERR("HTTP", "wolfSSL bad URL: %s", url.c_str());
|
||||
return HttpDownloader::HTTP_ERROR;
|
||||
}
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
// setUserAgent replaces SecureHttpClient's built-in UA; addHeader would
|
||||
// append a second User-Agent header, which strict servers reject (aiohttp
|
||||
// answers 400 "Duplicate 'User-Agent' header found").
|
||||
http.setUserAgent("CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
if (!username.empty() && !password.empty()) {
|
||||
const std::string credentials = username + ":" + password;
|
||||
const String encoded = base64::encode(credentials.c_str());
|
||||
|
||||
Reference in New Issue
Block a user