16 Commits

Author SHA1 Message Date
cottongin
f90aebc891 fix: Defer low-power mode during section indexing and book loading
Prevent the device from dropping to 10MHz CPU during first-time chapter
indexing, cover prerendering, and other CPU-intensive reader operations.

Three issues addressed:
- ActivityWithSubactivity now delegates preventAutoSleep() and
  skipLoopDelay() to the active subactivity, so EpubReaderActivity's
  signal is visible through the ReaderActivity wrapper
- Added post-loop() re-check of preventAutoSleep() in main.cpp to
  catch activity transitions that happen mid-loop
- EpubReaderActivity uses both !section and a loadingSection flag to
  cover the full duration from activity entry through section file
  creation; TxtReaderActivity uses !initialized similarly

Also syncs HalPowerManager.cpp log messages with upstream PR #852.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 16:42:27 -05:00
cottongin
3096d6066b feat: Add column-aligned table rendering for EPUBs
Replace the "[Table omitted]" placeholder with full table rendering:

- Two-pass layout: buffer table content during SAX parsing, then
  calculate column widths and lay out cells after </table> closes
- Colspan support for cells spanning multiple columns
- Forced line breaks within cells (<br>, <p>, <div> etc.)
- Center-align full-width spanning rows (section headers/titles)
- Width hints from HTML attributes and CSS (col, td, th width)
- Two-pass fair-share column width distribution that prevents
  narrow columns from being excessively squeezed
- Double-encoded &nbsp; entity handling
- PageTableRow with grid-line rendering and serialization support
- Asymmetric vertical cell padding to balance font leading

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 14:40:36 -05:00
cottongin
1383d75c84 feat: Add per-family font and per-language hyphenation build flags
Add OMIT_BOOKERLY, OMIT_NOTOSANS, OMIT_OPENDYSLEXIC flags to
selectively exclude font families, and OMIT_HYPH_DE/EN/ES/FR/IT/RU
flags to exclude individual hyphenation language tries.

The mod build environment excludes OpenDyslexic (~1.03 MB) and all
hyphenation tries (~282 KB), reducing flash usage by ~1.3 MB.

Font Family setting switched from Enum to DynamicEnum with
index-to-value mapping to handle arbitrary font exclusion without
breaking the settings UI or persisted values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 00:48:23 -05:00
cottongin
632b76c9ed feat: Add placeholder cover generator for books without covers
Generate styled placeholder covers (title, author, book icon) when a
book has no embedded cover image, instead of showing a blank rectangle.

- Add PlaceholderCoverGenerator lib with 1-bit BMP rendering, scaled
  fonts, word-wrap, and a book icon bitmap
- Integrate as fallback in Epub/Xtc/Txt reader activities and
  SleepActivity after format-specific cover generation fails
- Add fallback in HomeActivity::loadRecentCovers() so the home screen
  also shows placeholder thumbnails when cache is cleared
- Add Txt::getThumbBmpPath() for TXT thumbnail support
- Add helper scripts for icon and layout preview generation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 23:38:47 -05:00
cottongin
5dc9d21bdb feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).

- Add morphological stemming (getStemVariants), Levenshtein edit
  distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
  exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
  with cascading lookup (exact → stems → suggestions → not found),
  en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
  inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
cottongin
c1dfe92ea3 Merge remote-tracking branch 'upstream/master' into mod/master 2026-02-14 19:53:57 -05:00
Xuan-Son Nguyen
5816ab2a47 feat: use pre-compressed HTML pages (#861)
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
## Summary

Pre-compress the HTML file to save flash space. I'm using `gzip` because
it's supported everywhere (indeed, we are using the same optimization on
[llama.cpp server](https://github.com/ggml-org/llama.cpp), our HTML page
is huge 😅 ).

This free up ~40KB flash space.

Some users suggested using `brotli` which is known to further reduce 20%
in size, but it doesn't supported by firefox (only supports if served
via HTTPS), and some reverse proxy like nginx doesn't support it out of
the box (unrelated in this context, but just mention for completeness)

```
PR:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  95.5% (used 6259244 bytes from 6553600 bytes)

master:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  96.2% (used 6302416 bytes from 6553600 bytes)
```

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **PARTIALLY**, only the
python part
2026-02-14 18:49:39 +03:00
Max Stoller
2c0a105550 docs: Add requirement device be on when flashing (#877)
## Summary
Flashing requires the device to be unlocked/awake

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO
>**_
2026-02-14 18:46:27 +03:00
cottongin
82bfbd8fa6 merge upstream/master: logging pragma, screenshot retrieval, nbsp fix
Merge 3 upstream commits into mod/master:
- feat: Allow screenshot retrieval from device (#820)
- feat: Add central logging pragma (#843)
- fix: Account for nbsp character as non-breaking space (#757)

Conflict resolution:
- src/main.cpp: kept mod's HalPowerManager + upstream's Logging/screenshot
- SleepActivity.cpp: kept mod's letterbox fill rework, applied LOG_* pattern

Additional changes for logging compatibility:
- Converted remaining Serial.printf calls in mod files to LOG_* macros
  (HalPowerManager, BookSettings, BookmarkStore, GfxRenderer)
- Added ENABLE_SERIAL_LOG and LOG_LEVEL=2 to [env:mod] build flags

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:27:58 -05:00
cottongin
6aa0b865c2 feat: Add per-book letterbox fill override
Introduce BookSettings utility for per-book settings stored in
the book's cache directory (book_settings.bin). Add "Letterbox Fill"
option to the EPUB reader menu that cycles Default/Dithered/Solid/None.
At sleep time, the per-book override is loaded and takes precedence
over the global setting for all book types (EPUB, XTC, TXT).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:07:38 -05:00
cottongin
0c71e0b13f fix: Use hash-based block dithering for BW-boundary letterbox fills
Pixel-level Bayer dithering in the 171-254 gray range creates a
high-frequency checkerboard in the BW pass that causes e-ink display
crosstalk during HALF_REFRESH, washing out cover images. Replace with
2x2 hash-based block dithering for this specific gray range — each
block gets a uniform level (2 or 3) via a spatial hash, avoiding
single-pixel alternation while approximating the target gray. Standard
Bayer dithering remains for all other gray ranges.

Also removes all debug instrumentation from the investigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 14:49:42 -05:00
cottongin
ea11d2f7d3 refactor: Revert letterbox fill to Dithered/Solid/None with edge caching
Simplify letterbox fill modes back to Dithered (default), Solid, and
None. Remove the Extend Edges mode and all per-pixel edge replication
code. Restore Bayer ordered dithering for the Dithered fill mode.

Re-introduce edge average caching so cover edge computations persist
across sleep cycles, stored as a small binary file alongside the cover
BMP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 11:12:27 -05:00
Jake Kenneally
6e51afb977 fix: Account for nbsp; character as non-breaking space (#757)
## Summary

Closes #743.

**What is the goal of this PR?**

- Add back handling for HTML entities in expat. This was originally part
of the code that got removed
[here](https://github.com/crosspoint-reader/crosspoint-reader/pull/274)
- Handle `&nbsp;` characters to resolve issue #743 

**What changes are included?**

- Brought back HTML entity table from previous commit and refactored it
to use a static const char * table with linear lookup to reduce heap
allocations.
- Used `XML_SetDefaultHandlerExpand` in expat to parse out the entities
correctly, without needing them defined in DOCTYPE
- Added handling for `&nbsp;` so that the text stays together and
doesn't break onto a new line with text separated by an `&nbsp;`

## Additional Context

- This supersedes [this
PR](https://github.com/crosspoint-reader/crosspoint-reader/pull/751)
that simply handled `nbsp;` as whitespace. Instead, we want that
character to serve its true purpose and affect the line-breaking
algorithm.
- Updated my test EPUB [here](https://github.com/jdk2pq/css-test-epub)
with `&nbsp;` characters examples at the end of the book

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-13 15:46:46 +01:00
jpirnay
cb24947477 feat: Add central logging pragma (#843)
## Summary

* Definition and use of a central LOG function, that can later be
extended or completely be removed (for public use where debugging
information may not be required) to save flash by suppressing the
-DENABLE_SERIAL_LOG like in the slim branch

* **What changes are included?**

## Additional Context
* By using the central logger the usual:
```
#include <HardwareSerial.h>
...
  Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size());
```
would then become
```
#include <Logging.h>
...
  LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size());
```
You do have ``LOG_DBG`` for debug messages, ``LOG_ERR`` for error
messages and ``LOG_INF`` for informational messages. Depending on the
verbosity level defined (see below) soe of these message types will be
suppressed/not-compiled.

* The normal compilation (default) will create a firmware.elf file of
42.194.356 bytes, the same code via slim will create 42.024.048 bytes -
170.308 bytes less
* Firmware.bin : 6.469.984 bytes for default, 6.418.672 bytes for slim -
51.312 bytes less


### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _NO_

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
2026-02-13 12:16:39 +01:00
cottongin
31878a77bc feat: Add mod build environment with version + git hash
Add `env:mod` PlatformIO environment that sets CROSSPOINT_VERSION to
"{version}-mod+{git_hash}" via a pre-build script. Usage: `pio run -e mod -t upload`

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 20:57:55 -05:00
jpirnay
7a385d78a4 feat: Allow screenshot retrieval from device (#820)
## Summary

* Add a small loop in main to be able to receive external commands,
currently being sent via the debugging_monitor
* Implemented command: cmd:SCREENSHOT sends the currently displayed
screen to the monitor, which will then store it to screenshot.bmp

## Additional Context

I was getting annoyed with taking tilted/unsharp photos of the device
screen, so I added the ability to press Enter during the monitor
execution and type SCREENSHOT to send a command. Could be extended in
the future

[screenshot.bmp](https://github.com/user-attachments/files/25213230/screenshot.bmp)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-13 02:31:15 +03:00
93 changed files with 4383 additions and 1191 deletions

View File

@@ -51,7 +51,7 @@ For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) do
### Web (latest firmware) ### Web (latest firmware)
1. Connect your Xteink X4 to your computer via USB-C 1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware" 2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#ifndef OMIT_BOOKERLY
#include <builtinFonts/bookerly_12_bold.h> #include <builtinFonts/bookerly_12_bold.h>
#include <builtinFonts/bookerly_12_bolditalic.h> #include <builtinFonts/bookerly_12_bolditalic.h>
#include <builtinFonts/bookerly_12_italic.h> #include <builtinFonts/bookerly_12_italic.h>
@@ -16,7 +17,10 @@
#include <builtinFonts/bookerly_18_bolditalic.h> #include <builtinFonts/bookerly_18_bolditalic.h>
#include <builtinFonts/bookerly_18_italic.h> #include <builtinFonts/bookerly_18_italic.h>
#include <builtinFonts/bookerly_18_regular.h> #include <builtinFonts/bookerly_18_regular.h>
#endif // OMIT_BOOKERLY
#include <builtinFonts/notosans_8_regular.h> #include <builtinFonts/notosans_8_regular.h>
#ifndef OMIT_NOTOSANS
#include <builtinFonts/notosans_12_bold.h> #include <builtinFonts/notosans_12_bold.h>
#include <builtinFonts/notosans_12_bolditalic.h> #include <builtinFonts/notosans_12_bolditalic.h>
#include <builtinFonts/notosans_12_italic.h> #include <builtinFonts/notosans_12_italic.h>
@@ -33,6 +37,9 @@
#include <builtinFonts/notosans_18_bolditalic.h> #include <builtinFonts/notosans_18_bolditalic.h>
#include <builtinFonts/notosans_18_italic.h> #include <builtinFonts/notosans_18_italic.h>
#include <builtinFonts/notosans_18_regular.h> #include <builtinFonts/notosans_18_regular.h>
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
#include <builtinFonts/opendyslexic_10_bold.h> #include <builtinFonts/opendyslexic_10_bold.h>
#include <builtinFonts/opendyslexic_10_bolditalic.h> #include <builtinFonts/opendyslexic_10_bolditalic.h>
#include <builtinFonts/opendyslexic_10_italic.h> #include <builtinFonts/opendyslexic_10_italic.h>
@@ -49,6 +56,8 @@
#include <builtinFonts/opendyslexic_8_bolditalic.h> #include <builtinFonts/opendyslexic_8_bolditalic.h>
#include <builtinFonts/opendyslexic_8_italic.h> #include <builtinFonts/opendyslexic_8_italic.h>
#include <builtinFonts/opendyslexic_8_regular.h> #include <builtinFonts/opendyslexic_8_regular.h>
#endif // OMIT_OPENDYSLEXIC
#include <builtinFonts/ubuntu_10_bold.h> #include <builtinFonts/ubuntu_10_bold.h>
#include <builtinFonts/ubuntu_10_regular.h> #include <builtinFonts/ubuntu_10_regular.h>
#include <builtinFonts/ubuntu_12_bold.h> #include <builtinFonts/ubuntu_12_bold.h>

View File

@@ -2,8 +2,8 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <Logging.h>
#include <ZipFile.h> #include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"
@@ -17,7 +17,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// Get file size without loading it all into heap // Get file size without loading it all into heap
if (!getItemSize(containerPath, &containerSize)) { if (!getItemSize(containerPath, &containerSize)) {
Serial.printf("[%lu] [EBP] Could not find or size META-INF/container.xml\n", millis()); LOG_ERR("EBP", "Could not find or size META-INF/container.xml");
return false; return false;
} }
@@ -29,13 +29,13 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// Stream read (reusing your existing stream logic) // Stream read (reusing your existing stream logic)
if (!readItemContentsToStream(containerPath, containerParser, 512)) { if (!readItemContentsToStream(containerPath, containerParser, 512)) {
Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis()); LOG_ERR("EBP", "Could not read META-INF/container.xml");
return false; return false;
} }
// Extract the result // Extract the result
if (containerParser.fullPath.empty()) { if (containerParser.fullPath.empty()) {
Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis()); LOG_ERR("EBP", "Could not find valid rootfile in container.xml");
return false; return false;
} }
@@ -46,28 +46,28 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
std::string contentOpfFilePath; std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) { if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); LOG_ERR("EBP", "Could not find content.opf in zip");
return false; return false;
} }
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); LOG_DBG("EBP", "Parsing content.opf: %s", contentOpfFilePath.c_str());
size_t contentOpfSize; size_t contentOpfSize;
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) { if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis()); LOG_ERR("EBP", "Could not get size of content.opf");
return false; return false;
} }
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get()); ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
if (!opfParser.setup()) { if (!opfParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); LOG_ERR("EBP", "Could not setup content.opf parser");
return false; return false;
} }
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) { if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis()); LOG_ERR("EBP", "Could not read content.opf");
return false; return false;
} }
@@ -90,18 +90,18 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
cssFiles = opfParser.cssFiles; cssFiles = opfParser.cssFiles;
} }
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); LOG_DBG("EBP", "Successfully parsed content.opf");
return true; return true;
} }
bool Epub::parseTocNcxFile() const { bool Epub::parseTocNcxFile() const {
// the ncx file should have been specified in the content.opf file // the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) { if (tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); LOG_DBG("EBP", "No ncx file specified");
return false; return false;
} }
Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx"; const auto tmpNcxPath = getCachePath() + "/toc.ncx";
FsFile tempNcxFile; FsFile tempNcxFile;
@@ -118,14 +118,14 @@ bool Epub::parseTocNcxFile() const {
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get()); TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
if (!ncxParser.setup()) { if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); LOG_ERR("EBP", "Could not setup toc ncx parser");
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
} }
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024)); const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) { if (!ncxBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis()); LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
} }
@@ -136,7 +136,7 @@ bool Epub::parseTocNcxFile() const {
const auto processedSize = ncxParser.write(ncxBuffer, readSize); const auto processedSize = ncxParser.write(ncxBuffer, readSize);
if (processedSize != readSize) { if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis()); LOG_ERR("EBP", "Could not process all toc ncx data");
free(ncxBuffer); free(ncxBuffer);
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
@@ -147,18 +147,18 @@ bool Epub::parseTocNcxFile() const {
tempNcxFile.close(); tempNcxFile.close();
Storage.remove(tmpNcxPath.c_str()); Storage.remove(tmpNcxPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis()); LOG_DBG("EBP", "Parsed TOC items");
return true; return true;
} }
bool Epub::parseTocNavFile() const { bool Epub::parseTocNavFile() const {
// the nav file should have been specified in the content.opf file (EPUB 3) // the nav file should have been specified in the content.opf file (EPUB 3)
if (tocNavItem.empty()) { if (tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] No nav file specified\n", millis()); LOG_DBG("EBP", "No nav file specified");
return false; return false;
} }
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str()); LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav"; const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile; FsFile tempNavFile;
@@ -178,13 +178,13 @@ bool Epub::parseTocNavFile() const {
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get()); TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
if (!navParser.setup()) { if (!navParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); LOG_ERR("EBP", "Could not setup toc nav parser");
return false; return false;
} }
const auto navBuffer = static_cast<uint8_t*>(malloc(1024)); const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
if (!navBuffer) { if (!navBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis()); LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
return false; return false;
} }
@@ -193,7 +193,7 @@ bool Epub::parseTocNavFile() const {
const auto processedSize = navParser.write(navBuffer, readSize); const auto processedSize = navParser.write(navBuffer, readSize);
if (processedSize != readSize) { if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis()); LOG_ERR("EBP", "Could not process all toc nav data");
free(navBuffer); free(navBuffer);
tempNavFile.close(); tempNavFile.close();
return false; return false;
@@ -204,7 +204,7 @@ bool Epub::parseTocNavFile() const {
tempNavFile.close(); tempNavFile.close();
Storage.remove(tmpNavPath.c_str()); Storage.remove(tmpNavPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis()); LOG_DBG("EBP", "Parsed TOC nav items");
return true; return true;
} }
@@ -215,35 +215,35 @@ bool Epub::loadCssRulesFromCache() const {
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) { if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) { if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close(); cssCacheFile.close();
Serial.printf("[%lu] [EBP] Loaded CSS rules from cache\n", millis()); LOG_DBG("EBP", "Loaded CSS rules from cache");
return true; return true;
} }
cssCacheFile.close(); cssCacheFile.close();
Serial.printf("[%lu] [EBP] CSS cache invalid, reparsing\n", millis()); LOG_DBG("EBP", "CSS cache invalid, reparsing");
} }
return false; return false;
} }
void Epub::parseCssFiles() const { void Epub::parseCssFiles() const {
if (cssFiles.empty()) { if (cssFiles.empty()) {
Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis()); LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
} }
// Try to load from CSS cache first // Try to load from CSS cache first
if (!loadCssRulesFromCache()) { if (!loadCssRulesFromCache()) {
// Cache miss - parse CSS files // Cache miss - parse CSS files
for (const auto& cssPath : cssFiles) { for (const auto& cssPath : cssFiles) {
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str()); LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Extract CSS file to temp location // Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css"; const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile; FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis()); LOG_ERR("EBP", "Could not create temp CSS file");
continue; continue;
} }
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str()); LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close(); tempCssFile.close();
Storage.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
continue; continue;
@@ -252,7 +252,7 @@ void Epub::parseCssFiles() const {
// Parse the CSS file // Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis()); LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
continue; continue;
} }
@@ -268,14 +268,13 @@ void Epub::parseCssFiles() const {
cssCacheFile.close(); cssCacheFile.close();
} }
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(), LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
cssFiles.size());
} }
} }
// load in the meta data for the epub file // load in the meta data for the epub file
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
// Initialize spine/TOC cache // Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
@@ -285,15 +284,15 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try to load existing cache first // Try to load existing cache first
if (bookMetadataCache->load()) { if (bookMetadataCache->load()) {
if (!skipLoadingCss && !loadCssRulesFromCache()) { if (!skipLoadingCss && !loadCssRulesFromCache()) {
Serial.printf("[%lu] [EBP] Warning: CSS rules cache not found, attempting to parse CSS files\n", millis()); LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list // to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf from cached bookMetadata for CSS files\n", millis()); LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS // continue anyway - book will work without CSS and we'll still load any inline style CSS
} }
parseCssFiles(); parseCssFiles();
} }
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true; return true;
} }
@@ -303,14 +302,14 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
} }
// Cache doesn't exist or is invalid, build it // Cache doesn't exist or is invalid, build it
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); LOG_DBG("EBP", "Cache not found, building spine/TOC cache");
setupCacheDir(); setupCacheDir();
const uint32_t indexingStart = millis(); const uint32_t indexingStart = millis();
// Begin building cache - stream entries to disk immediately // Begin building cache - stream entries to disk immediately
if (!bookMetadataCache->beginWrite()) { if (!bookMetadataCache->beginWrite()) {
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); LOG_ERR("EBP", "Could not begin writing cache");
return false; return false;
} }
@@ -318,23 +317,23 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
const uint32_t opfStart = millis(); const uint32_t opfStart = millis();
BookMetadataCache::BookMetadata bookMetadata; BookMetadataCache::BookMetadata bookMetadata;
if (!bookMetadataCache->beginContentOpfPass()) { if (!bookMetadataCache->beginContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis()); LOG_ERR("EBP", "Could not begin writing content.opf pass");
return false; return false;
} }
if (!parseContentOpf(bookMetadata)) { if (!parseContentOpf(bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); LOG_ERR("EBP", "Could not parse content.opf");
return false; return false;
} }
if (!bookMetadataCache->endContentOpfPass()) { if (!bookMetadataCache->endContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis()); LOG_ERR("EBP", "Could not end writing content.opf pass");
return false; return false;
} }
Serial.printf("[%lu] [EBP] OPF pass completed in %lu ms\n", millis(), millis() - opfStart); LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
// TOC Pass - try EPUB 3 nav first, fall back to NCX // TOC Pass - try EPUB 3 nav first, fall back to NCX
const uint32_t tocStart = millis(); const uint32_t tocStart = millis();
if (!bookMetadataCache->beginTocPass()) { if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); LOG_ERR("EBP", "Could not begin writing toc pass");
return false; return false;
} }
@@ -342,50 +341,50 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try EPUB 3 nav document first (preferred) // Try EPUB 3 nav document first (preferred)
if (!tocNavItem.empty()) { if (!tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis()); LOG_DBG("EBP", "Attempting to parse EPUB 3 nav document");
tocParsed = parseTocNavFile(); tocParsed = parseTocNavFile();
} }
// Fall back to NCX if nav parsing failed or wasn't available // Fall back to NCX if nav parsing failed or wasn't available
if (!tocParsed && !tocNcxItem.empty()) { if (!tocParsed && !tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis()); LOG_DBG("EBP", "Falling back to NCX TOC");
tocParsed = parseTocNcxFile(); tocParsed = parseTocNcxFile();
} }
if (!tocParsed) { if (!tocParsed) {
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis()); LOG_ERR("EBP", "Warning: Could not parse any TOC format");
// Continue anyway - book will work without TOC // Continue anyway - book will work without TOC
} }
if (!bookMetadataCache->endTocPass()) { if (!bookMetadataCache->endTocPass()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); LOG_ERR("EBP", "Could not end writing toc pass");
return false; return false;
} }
Serial.printf("[%lu] [EBP] TOC pass completed in %lu ms\n", millis(), millis() - tocStart); LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
// Close the cache files // Close the cache files
if (!bookMetadataCache->endWrite()) { if (!bookMetadataCache->endWrite()) {
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); LOG_ERR("EBP", "Could not end writing cache");
return false; return false;
} }
// Build final book.bin // Build final book.bin
const uint32_t buildStart = millis(); const uint32_t buildStart = millis();
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) { if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); LOG_ERR("EBP", "Could not update mappings and sizes");
return false; return false;
} }
Serial.printf("[%lu] [EBP] buildBookBin completed in %lu ms\n", millis(), millis() - buildStart); LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
Serial.printf("[%lu] [EBP] Total indexing completed in %lu ms\n", millis(), millis() - indexingStart); LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
if (!bookMetadataCache->cleanupTmpFiles()) { if (!bookMetadataCache->cleanupTmpFiles()) {
Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis()); LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
} }
// Reload the cache from disk so it's in the correct state // Reload the cache from disk so it's in the correct state
bookMetadataCache.reset(new BookMetadataCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!bookMetadataCache->load()) { if (!bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); LOG_ERR("EBP", "Failed to reload cache after writing");
return false; return false;
} }
@@ -394,22 +393,22 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
parseCssFiles(); parseCssFiles();
} }
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true; return true;
} }
bool Epub::clearCache() const { bool Epub::clearCache() const {
if (!Storage.exists(cachePath.c_str())) { if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis()); LOG_DBG("EPB", "Cache does not exist, no action needed");
return true; return true;
} }
if (!Storage.removeDir(cachePath.c_str())) { if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis()); LOG_ERR("EPB", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [EPB] Cache cleared successfully\n", millis()); LOG_DBG("EPB", "Cache cleared successfully");
return true; return true;
} }
@@ -464,19 +463,19 @@ bool Epub::generateCoverBmp(bool cropped) const {
} }
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis()); LOG_ERR("EBP", "Cannot generate cover BMP, cache not loaded");
return false; return false;
} }
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image\n", millis()); LOG_ERR("EBP", "No known cover image");
return false; return false;
} }
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit"); LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg; FsFile coverJpg;
@@ -501,13 +500,13 @@ bool Epub::generateCoverBmp(bool cropped) const {
Storage.remove(coverJpgTempPath.c_str()); Storage.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); LOG_ERR("EBP", "Failed to generate BMP from cover image");
Storage.remove(getCoverBmpPath(cropped).c_str()); Storage.remove(getCoverBmpPath(cropped).c_str());
} }
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
return success; return success;
} else { } else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis()); LOG_ERR("EBP", "Cover image is not a supported format, skipping");
} }
return false; return false;
@@ -523,16 +522,16 @@ bool Epub::generateThumbBmp(int height) const {
} }
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded");
return false; return false;
} }
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); LOG_DBG("EBP", "No known cover image for thumbnail");
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg; FsFile coverJpg;
@@ -562,14 +561,13 @@ bool Epub::generateThumbBmp(int height) const {
Storage.remove(coverJpgTempPath.c_str()); Storage.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
Storage.remove(getThumbBmpPath(height).c_str()); Storage.remove(getThumbBmpPath(height).c_str());
} }
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
success ? "yes" : "no");
return success; return success;
} else { } else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
} }
// Write an empty bmp file to avoid generation attempts in the future // Write an empty bmp file to avoid generation attempts in the future
@@ -581,7 +579,7 @@ bool Epub::generateThumbBmp(int height) const {
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) { if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); LOG_DBG("EBP", "Failed to read item, empty href");
return nullptr; return nullptr;
} }
@@ -589,7 +587,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte); const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
if (!content) { if (!content) {
Serial.printf("[%lu] [EBP] Failed to read item %s\n", millis(), path.c_str()); LOG_DBG("EBP", "Failed to read item %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -598,7 +596,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const { bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
if (itemHref.empty()) { if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); LOG_DBG("EBP", "Failed to read item, empty href");
return false; return false;
} }
@@ -622,12 +620,12 @@ size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return get
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineItem called but cache not loaded");
return {}; return {};
} }
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); LOG_ERR("EBP", "getSpineItem index:%d is out of range", spineIndex);
return bookMetadataCache->getSpineEntry(0); return bookMetadataCache->getSpineEntry(0);
} }
@@ -636,12 +634,12 @@ BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); LOG_DBG("EBP", "getTocItem called but cache not loaded");
return {}; return {};
} }
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
return {}; return {};
} }
@@ -659,18 +657,18 @@ int Epub::getTocItemsCount() const {
// work out the section index for a toc index // work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const { int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineIndexForTocIndex called but cache not loaded");
return 0; return 0;
} }
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
return 0; return 0;
} }
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex; const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
if (spineIndex < 0) { if (spineIndex < 0) {
Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); LOG_DBG("EBP", "Section not found for TOC index %d", tocIndex);
return 0; return 0;
} }
@@ -688,14 +686,13 @@ size_t Epub::getBookSize() const {
int Epub::getSpineIndexForTextReference() const { int Epub::getSpineIndexForTextReference() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineIndexForTextReference called but cache not loaded");
return 0; return 0;
} }
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(), LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
bookMetadataCache->coreMetadata.coverItemHref.c_str(), bookMetadataCache->coreMetadata.textReferenceHref.size(),
bookMetadataCache->coreMetadata.textReferenceHref.size(), bookMetadataCache->coreMetadata.textReferenceHref.c_str());
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) { if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
// there was no textReference in epub, so we return 0 (the first chapter) // there was no textReference in epub, so we return 0 (the first chapter)
@@ -705,13 +702,13 @@ int Epub::getSpineIndexForTextReference() const {
// loop through spine items to get the correct index matching the text href // loop through spine items to get the correct index matching the text href
for (size_t i = 0; i < getSpineItemsCount(); i++) { for (size_t i = 0; i < getSpineItemsCount(); i++) {
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) { if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(), LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i); i);
return i; return i;
} }
} }
// This should not happen, as we checked for empty textReferenceHref earlier // This should not happen, as we checked for empty textReferenceHref earlier
Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis()); LOG_DBG("EBP", "Section not found for text reference");
return 0; return 0;
} }

View File

@@ -1,6 +1,6 @@
#include "BookMetadataCache.h" #include "BookMetadataCache.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <ZipFile.h> #include <ZipFile.h>
@@ -21,12 +21,12 @@ bool BookMetadataCache::beginWrite() {
buildMode = true; buildMode = true;
spineCount = 0; spineCount = 0;
tocCount = 0; tocCount = 0;
Serial.printf("[%lu] [BMC] Entering write mode\n", millis()); LOG_DBG("BMC", "Entering write mode");
return true; return true;
} }
bool BookMetadataCache::beginContentOpfPass() { bool BookMetadataCache::beginContentOpfPass() {
Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis()); LOG_DBG("BMC", "Beginning content opf pass");
// Open spine file for writing // Open spine file for writing
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile); return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
@@ -38,7 +38,7 @@ bool BookMetadataCache::endContentOpfPass() {
} }
bool BookMetadataCache::beginTocPass() { bool BookMetadataCache::beginTocPass() {
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis()); LOG_DBG("BMC", "Beginning toc pass");
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
return false; return false;
@@ -66,7 +66,7 @@ bool BookMetadataCache::beginTocPass() {
}); });
spineFile.seek(0); spineFile.seek(0);
useSpineHrefIndex = true; useSpineHrefIndex = true;
Serial.printf("[%lu] [BMC] Using fast index for %d spine items\n", millis(), spineCount); LOG_DBG("BMC", "Using fast index for %d spine items", spineCount);
} else { } else {
useSpineHrefIndex = false; useSpineHrefIndex = false;
} }
@@ -87,12 +87,12 @@ bool BookMetadataCache::endTocPass() {
bool BookMetadataCache::endWrite() { bool BookMetadataCache::endWrite() {
if (!buildMode) { if (!buildMode) {
Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis()); LOG_DBG("BMC", "endWrite called but not in build mode");
return false; return false;
} }
buildMode = false; buildMode = false;
Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount); LOG_DBG("BMC", "Wrote %d spine, %d TOC entries", spineCount, tocCount);
return true; return true;
} }
@@ -167,7 +167,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
ZipFile zip(epubPath); ZipFile zip(epubPath);
// Pre-open zip file to speed up size calculations // Pre-open zip file to speed up size calculations
if (!zip.open()) { if (!zip.open()) {
Serial.printf("[%lu] [BMC] Could not open EPUB zip for size calculations\n", millis()); LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
bookFile.close(); bookFile.close();
spineFile.close(); spineFile.close();
tocFile.close(); tocFile.close();
@@ -185,7 +185,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
bool useBatchSizes = false; bool useBatchSizes = false;
if (spineCount >= LARGE_SPINE_THRESHOLD) { if (spineCount >= LARGE_SPINE_THRESHOLD) {
Serial.printf("[%lu] [BMC] Using batch size lookup for %d spine items\n", millis(), spineCount); LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
std::vector<ZipFile::SizeTarget> targets; std::vector<ZipFile::SizeTarget> targets;
targets.reserve(spineCount); targets.reserve(spineCount);
@@ -208,7 +208,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
spineSizes.resize(spineCount, 0); spineSizes.resize(spineCount, 0);
int matched = zip.fillUncompressedSizes(targets, spineSizes); int matched = zip.fillUncompressedSizes(targets, spineSizes);
Serial.printf("[%lu] [BMC] Batch lookup matched %d/%d spine items\n", millis(), matched, spineCount); LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount);
targets.clear(); targets.clear();
targets.shrink_to_fit(); targets.shrink_to_fit();
@@ -227,9 +227,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
// Logging here is for debugging // Logging here is for debugging
if (spineEntry.tocIndex == -1) { if (spineEntry.tocIndex == -1) {
Serial.printf( LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i,
"[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s, using title from last section\n", spineEntry.href.c_str());
millis(), i, spineEntry.href.c_str());
spineEntry.tocIndex = lastSpineTocIndex; spineEntry.tocIndex = lastSpineTocIndex;
} }
lastSpineTocIndex = spineEntry.tocIndex; lastSpineTocIndex = spineEntry.tocIndex;
@@ -240,13 +239,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
if (itemSize == 0) { if (itemSize == 0) {
const std::string path = FsHelpers::normalisePath(spineEntry.href); const std::string path = FsHelpers::normalisePath(spineEntry.href);
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
} }
} }
} else { } else {
const std::string path = FsHelpers::normalisePath(spineEntry.href); const std::string path = FsHelpers::normalisePath(spineEntry.href);
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
} }
} }
@@ -270,7 +269,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
spineFile.close(); spineFile.close();
tocFile.close(); tocFile.close();
Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis()); LOG_DBG("BMC", "Successfully built book.bin");
return true; return true;
} }
@@ -306,7 +305,7 @@ uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) c
// this is because in this function we're marking positions of the items // this is because in this function we're marking positions of the items
void BookMetadataCache::createSpineEntry(const std::string& href) { void BookMetadataCache::createSpineEntry(const std::string& href) {
if (!buildMode || !spineFile) { if (!buildMode || !spineFile) {
Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis()); LOG_DBG("BMC", "createSpineEntry called but not in build mode");
return; return;
} }
@@ -318,7 +317,7 @@ void BookMetadataCache::createSpineEntry(const std::string& href) {
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
const uint8_t level) { const uint8_t level) {
if (!buildMode || !tocFile || !spineFile) { if (!buildMode || !tocFile || !spineFile) {
Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis()); LOG_DBG("BMC", "createTocEntry called but not in build mode");
return; return;
} }
@@ -340,7 +339,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
} }
if (spineIndex == -1) { if (spineIndex == -1) {
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
} }
} else { } else {
spineFile.seek(0); spineFile.seek(0);
@@ -352,7 +351,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
} }
} }
if (spineIndex == -1) { if (spineIndex == -1) {
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
} }
} }
@@ -371,7 +370,7 @@ bool BookMetadataCache::load() {
uint8_t version; uint8_t version;
serialization::readPod(bookFile, version); serialization::readPod(bookFile, version);
if (version != BOOK_CACHE_VERSION) { if (version != BOOK_CACHE_VERSION) {
Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version); LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
bookFile.close(); bookFile.close();
return false; return false;
} }
@@ -387,18 +386,18 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.textReferenceHref); serialization::readString(bookFile, coreMetadata.textReferenceHref);
loaded = true; loaded = true;
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
return true; return true;
} }
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
if (!loaded) { if (!loaded) {
Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis()); LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
return {}; return {};
} }
if (index < 0 || index >= static_cast<int>(spineCount)) { if (index < 0 || index >= static_cast<int>(spineCount)) {
Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index); LOG_ERR("BMC", "getSpineEntry index %d out of range", index);
return {}; return {};
} }
@@ -412,12 +411,12 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
if (!loaded) { if (!loaded) {
Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis()); LOG_ERR("BMC", "getTocEntry called but cache not loaded");
return {}; return {};
} }
if (index < 0 || index >= static_cast<int>(tocCount)) { if (index < 0 || index >= static_cast<int>(tocCount)) {
Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index); LOG_ERR("BMC", "getTocEntry index %d out of range", index);
return {}; return {};
} }

View File

@@ -1,8 +1,17 @@
#include "Page.h" #include "Page.h"
#include <HardwareSerial.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
// Cell padding in pixels (must match TABLE_CELL_PAD_* in ChapterHtmlSlimParser.cpp)
static constexpr int TABLE_CELL_PADDING_X = 4;
static constexpr int TABLE_CELL_PADDING_TOP = 1;
// ---------------------------------------------------------------------------
// PageLine
// ---------------------------------------------------------------------------
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset); block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
} }
@@ -25,6 +34,115 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
} }
// ---------------------------------------------------------------------------
// PageTableRow
// ---------------------------------------------------------------------------
void PageTableRow::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
const int baseX = xPos + xOffset;
const int baseY = yPos + yOffset;
// Draw horizontal borders (top and bottom of this row)
renderer.drawLine(baseX, baseY, baseX + totalWidth, baseY);
renderer.drawLine(baseX, baseY + rowHeight, baseX + totalWidth, baseY + rowHeight);
// Draw vertical borders and render cell contents
// Left edge
renderer.drawLine(baseX, baseY, baseX, baseY + rowHeight);
for (const auto& cell : cells) {
// Right vertical border for this cell
const int cellRightX = baseX + cell.xOffset + cell.columnWidth;
renderer.drawLine(cellRightX, baseY, cellRightX, baseY + rowHeight);
// Render each text line within the cell
const int cellTextX = baseX + cell.xOffset + TABLE_CELL_PADDING_X;
int cellLineY = baseY + 1 + TABLE_CELL_PADDING_TOP; // 1px border + top padding
for (const auto& line : cell.lines) {
line->render(renderer, fontId, cellTextX, cellLineY);
cellLineY += lineHeight;
}
}
}
bool PageTableRow::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
serialization::writePod(file, rowHeight);
serialization::writePod(file, totalWidth);
serialization::writePod(file, lineHeight);
const uint16_t cellCount = static_cast<uint16_t>(cells.size());
serialization::writePod(file, cellCount);
for (const auto& cell : cells) {
serialization::writePod(file, cell.xOffset);
serialization::writePod(file, cell.columnWidth);
const uint16_t lineCount = static_cast<uint16_t>(cell.lines.size());
serialization::writePod(file, lineCount);
for (const auto& line : cell.lines) {
if (!line->serialize(file)) {
return false;
}
}
}
return true;
}
std::unique_ptr<PageTableRow> PageTableRow::deserialize(FsFile& file) {
int16_t xPos, yPos, rowHeight, totalWidth, lineHeight;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
serialization::readPod(file, rowHeight);
serialization::readPod(file, totalWidth);
serialization::readPod(file, lineHeight);
uint16_t cellCount;
serialization::readPod(file, cellCount);
// Sanity check
if (cellCount > 100) {
LOG_ERR("PTR", "Deserialization failed: cell count %u exceeds maximum", cellCount);
return nullptr;
}
std::vector<PageTableCellData> cells;
cells.resize(cellCount);
for (uint16_t c = 0; c < cellCount; ++c) {
serialization::readPod(file, cells[c].xOffset);
serialization::readPod(file, cells[c].columnWidth);
uint16_t lineCount;
serialization::readPod(file, lineCount);
if (lineCount > 1000) {
LOG_ERR("PTR", "Deserialization failed: line count %u in cell %u exceeds maximum", lineCount, c);
return nullptr;
}
cells[c].lines.reserve(lineCount);
for (uint16_t l = 0; l < lineCount; ++l) {
auto tb = TextBlock::deserialize(file);
if (!tb) {
return nullptr;
}
cells[c].lines.push_back(std::move(tb));
}
}
return std::unique_ptr<PageTableRow>(
new PageTableRow(std::move(cells), rowHeight, totalWidth, lineHeight, xPos, yPos));
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) { for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset); element->render(renderer, fontId, xOffset, yOffset);
@@ -36,8 +154,7 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
// Only PageLine exists currently serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
if (!el->serialize(file)) { if (!el->serialize(file)) {
return false; return false;
} }
@@ -59,8 +176,15 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) { if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file); auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl)); page->elements.push_back(std::move(pl));
} else if (tag == TAG_PageTableRow) {
auto tr = PageTableRow::deserialize(file);
if (!tr) {
LOG_ERR("PGE", "Deserialization failed for PageTableRow at element %u", i);
return nullptr;
}
page->elements.push_back(std::move(tr));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr; return nullptr;
} }
} }

View File

@@ -8,6 +8,7 @@
enum PageElementTag : uint8_t { enum PageElementTag : uint8_t {
TAG_PageLine = 1, TAG_PageLine = 1,
TAG_PageTableRow = 2,
}; };
// represents something that has been added to a page // represents something that has been added to a page
@@ -17,6 +18,7 @@ class PageElement {
int16_t yPos; int16_t yPos;
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual PageElementTag getTag() const = 0;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0; virtual bool serialize(FsFile& file) = 0;
}; };
@@ -29,11 +31,42 @@ class PageLine final : public PageElement {
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
const std::shared_ptr<TextBlock>& getBlock() const { return block; } const std::shared_ptr<TextBlock>& getBlock() const { return block; }
PageElementTag getTag() const override { return TAG_PageLine; }
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override; bool serialize(FsFile& file) override;
static std::unique_ptr<PageLine> deserialize(FsFile& file); static std::unique_ptr<PageLine> deserialize(FsFile& file);
}; };
/// Data for a single cell within a PageTableRow.
struct PageTableCellData {
std::vector<std::shared_ptr<TextBlock>> lines; // Laid-out text lines for this cell
uint16_t columnWidth = 0; // Width of this column in pixels
uint16_t xOffset = 0; // X offset of this cell within the row
};
/// A table row element that renders cells in a column-aligned grid with borders.
class PageTableRow final : public PageElement {
std::vector<PageTableCellData> cells;
int16_t rowHeight; // Total row height in pixels
int16_t totalWidth; // Total table width in pixels
int16_t lineHeight; // Height of one text line (for vertical positioning of cell lines)
public:
PageTableRow(std::vector<PageTableCellData> cells, int16_t rowHeight, int16_t totalWidth, int16_t lineHeight,
int16_t xPos, int16_t yPos)
: PageElement(xPos, yPos),
cells(std::move(cells)),
rowHeight(rowHeight),
totalWidth(totalWidth),
lineHeight(lineHeight) {}
int16_t getHeight() const { return rowHeight; }
PageElementTag getTag() const override { return TAG_PageTableRow; }
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
static std::unique_ptr<PageTableRow> deserialize(FsFile& file);
};
class Page { class Page {
public: public:
// the list of block index and line numbers on this page // the list of block index and line numbers on this page

View File

@@ -31,6 +31,9 @@ void stripSoftHyphensInPlace(std::string& word) {
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen. // Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
const EpdFontFamily::Style style, const bool appendHyphen = false) { const EpdFontFamily::Style style, const bool appendHyphen = false) {
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
return renderer.getSpaceWidth(fontId);
}
const bool hasSoftHyphen = containsSoftHyphen(word); const bool hasSoftHyphen = containsSoftHyphen(word);
if (!hasSoftHyphen && !appendHyphen) { if (!hasSoftHyphen && !appendHyphen) {
return renderer.getTextWidth(fontId, word.c_str(), style); return renderer.getTextWidth(fontId, word.c_str(), style);
@@ -59,6 +62,13 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
} }
wordStyles.push_back(combinedStyle); wordStyles.push_back(combinedStyle);
wordContinues.push_back(attachToPrevious); wordContinues.push_back(attachToPrevious);
forceBreakAfter.push_back(false);
}
void ParsedText::addLineBreak() {
if (!words.empty()) {
forceBreakAfter.back() = true;
}
} }
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
@@ -145,6 +155,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
for (size_t j = i; j < totalWordCount; ++j) { for (size_t j = i; j < totalWordCount; ++j) {
// If the previous word has a forced line break, this line cannot include word j
if (j > static_cast<size_t>(i) && !forceBreakAfter.empty() && forceBreakAfter[j - 1]) {
break;
}
// Add space before word j, unless it's the first word on the line or a continuation // Add space before word j, unless it's the first word on the line or a continuation
const int gap = j > static_cast<size_t>(i) && !continuesVec[j] ? spaceWidth : 0; const int gap = j > static_cast<size_t>(i) && !continuesVec[j] ? spaceWidth : 0;
currlen += wordWidths[j] + gap; currlen += wordWidths[j] + gap;
@@ -153,8 +168,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
break; break;
} }
// Cannot break after word j if the next word attaches to it (continuation group) // Forced line break after word j overrides continuation (must end line here)
if (j + 1 < totalWordCount && continuesVec[j + 1]) { const bool mustBreakHere = !forceBreakAfter.empty() && forceBreakAfter[j];
// Cannot break after word j if the next word attaches to it (unless forced)
if (!mustBreakHere && j + 1 < totalWordCount && continuesVec[j + 1]) {
continue; continue;
} }
@@ -177,6 +195,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
dp[i] = cost; dp[i] = cost;
ans[i] = j; // j is the index of the last word in this optimal line ans[i] = j; // j is the index of the last word in this optimal line
} }
// After evaluating cost, enforce forced break - no more words on this line
if (mustBreakHere) {
break;
}
} }
// Handle oversized word: if no valid configuration found, force single-word line // Handle oversized word: if no valid configuration found, force single-word line
@@ -251,6 +274,11 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// Consume as many words as possible for current line, splitting when prefixes fit // Consume as many words as possible for current line, splitting when prefixes fit
while (currentIndex < wordWidths.size()) { while (currentIndex < wordWidths.size()) {
// If the previous word has a forced line break, stop - this word starts a new line
if (currentIndex > lineStart && !forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
break;
}
const bool isFirstWord = currentIndex == lineStart; const bool isFirstWord = currentIndex == lineStart;
const int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth; const int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth;
const int candidateWidth = spacing + wordWidths[currentIndex]; const int candidateWidth = spacing + wordWidths[currentIndex];
@@ -259,6 +287,11 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
if (lineWidth + candidateWidth <= effectivePageWidth) { if (lineWidth + candidateWidth <= effectivePageWidth) {
lineWidth += candidateWidth; lineWidth += candidateWidth;
++currentIndex; ++currentIndex;
// If the word we just added has a forced break, end this line now
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
break;
}
continue; continue;
} }
@@ -284,7 +317,12 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// Don't break before a continuation word (e.g., orphaned "?" after "question"). // Don't break before a continuation word (e.g., orphaned "?" after "question").
// Backtrack to the start of the continuation group so the whole group moves to the next line. // Backtrack to the start of the continuation group so the whole group moves to the next line.
// But don't backtrack past a forced break point.
while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) { while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) {
// Don't backtrack past a forced break
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
break;
}
--currentIndex; --currentIndex;
} }
@@ -358,6 +396,13 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
wordContinues[wordIndex] = false; wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext); wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
if (!forceBreakAfter.empty()) {
const bool originalForceBreak = forceBreakAfter[wordIndex];
forceBreakAfter[wordIndex] = false; // prefix doesn't force break
forceBreakAfter.insert(forceBreakAfter.begin() + wordIndex + 1, originalForceBreak);
}
// Update cached widths to reflect the new prefix/remainder pairing. // Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth); wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style); const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
@@ -444,3 +489,22 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
processLine( processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle)); std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
} }
uint16_t ParsedText::getNaturalWidth(const GfxRenderer& renderer, const int fontId) const {
if (words.empty()) {
return 0;
}
const int spaceWidth = renderer.getSpaceWidth(fontId);
int totalWidth = 0;
for (size_t i = 0; i < words.size(); ++i) {
totalWidth += measureWordWidth(renderer, fontId, words[i], wordStyles[i]);
// Add a space before this word unless it's the first word or a continuation
if (i > 0 && !wordContinues[i]) {
totalWidth += spaceWidth;
}
}
return static_cast<uint16_t>(std::min(totalWidth, static_cast<int>(UINT16_MAX)));
}

View File

@@ -15,7 +15,8 @@ class GfxRenderer;
class ParsedText { class ParsedText {
std::vector<std::string> words; std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles; std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it) std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
std::vector<bool> forceBreakAfter; // true = mandatory line break after this word (e.g. <br> in table cells)
BlockStyle blockStyle; BlockStyle blockStyle;
bool extraParagraphSpacing; bool extraParagraphSpacing;
bool hyphenationEnabled; bool hyphenationEnabled;
@@ -40,6 +41,10 @@ class ParsedText {
~ParsedText() = default; ~ParsedText() = default;
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false); void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
/// Mark a forced line break after the last word (e.g. for <br> within table cells).
/// If no words have been added yet, this is a no-op.
void addLineBreak();
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
BlockStyle& getBlockStyle() { return blockStyle; } BlockStyle& getBlockStyle() { return blockStyle; }
size_t size() const { return words.size(); } size_t size() const { return words.size(); }
@@ -47,4 +52,9 @@ class ParsedText {
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true); bool includeLastLine = true);
/// Returns the "natural" width of the content if it were laid out on a single line
/// (sum of word widths + space widths between non-continuation words).
/// Used by table layout to determine column widths before line-breaking.
uint16_t getNaturalWidth(const GfxRenderer& renderer, int fontId) const;
}; };

View File

@@ -1,6 +1,7 @@
#include "Section.h" #include "Section.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include "Page.h" #include "Page.h"
@@ -16,16 +17,16 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) +
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) { uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
if (!file) { if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing page %d\n", millis(), pageCount); LOG_ERR("SCT", "File not open for writing page %d", pageCount);
return 0; return 0;
} }
const uint32_t position = file.position(); const uint32_t position = file.position();
if (!page->serialize(file)) { if (!page->serialize(file)) {
Serial.printf("[%lu] [SCT] Failed to serialize page %d\n", millis(), pageCount); LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
return 0; return 0;
} }
Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount); LOG_DBG("SCT", "Page %d processed", pageCount);
pageCount++; pageCount++;
return position; return position;
@@ -36,7 +37,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool embeddedStyle) { const bool embeddedStyle) {
if (!file) { if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis()); LOG_DBG("SCT", "File not open for writing header");
return; return;
} }
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
@@ -70,7 +71,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) { if (version != SECTION_FILE_VERSION) {
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
clearCache(); clearCache();
return false; return false;
} }
@@ -96,7 +97,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) { hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache(); clearCache();
return false; return false;
} }
@@ -104,23 +105,23 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, pageCount); serialization::readPod(file, pageCount);
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount); LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
return true; return true;
} }
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem) // Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const { bool Section::clearCache() const {
if (!Storage.exists(filePath.c_str())) { if (!Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis()); LOG_DBG("SCT", "Cache does not exist, no action needed");
return true; return true;
} }
if (!Storage.remove(filePath.c_str())) { if (!Storage.remove(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis()); LOG_ERR("SCT", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [SCT] Cache cleared successfully\n", millis()); LOG_DBG("SCT", "Cache cleared successfully");
return true; return true;
} }
@@ -142,7 +143,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
uint32_t fileSize = 0; uint32_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) { for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) { if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1); LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
delay(50); // Brief delay before retry delay(50); // Brief delay before retry
} }
@@ -162,16 +163,16 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
// If streaming failed, remove the incomplete file immediately // If streaming failed, remove the incomplete file immediately
if (!success && Storage.exists(tmpHtmlPath.c_str())) { if (!success && Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str()); Storage.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis()); LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
} }
} }
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis()); LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
return false; return false;
} }
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
if (!Storage.openFileForWrite("SCT", filePath, file)) { if (!Storage.openFileForWrite("SCT", filePath, file)) {
return false; return false;
@@ -190,7 +191,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Storage.remove(tmpHtmlPath.c_str()); Storage.remove(tmpHtmlPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis()); LOG_ERR("SCT", "Failed to parse XML and build pages");
file.close(); file.close();
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
return false; return false;
@@ -208,7 +209,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
} }
if (hasFailedLutRecords) { if (hasFailedLutRecords) {
Serial.printf("[%lu] [SCT] Failed to write LUT due to invalid page positions\n", millis()); LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
file.close(); file.close();
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
return false; return false;

29
lib/Epub/Epub/TableData.h Normal file
View File

@@ -0,0 +1,29 @@
#pragma once
#include <memory>
#include <vector>
#include "ParsedText.h"
#include "css/CssStyle.h"
/// A single cell in a table row.
struct TableCell {
std::unique_ptr<ParsedText> content;
bool isHeader = false; // true for <th>, false for <td>
int colspan = 1; // number of logical columns this cell spans
CssLength widthHint; // width hint from HTML attribute or CSS (if hasWidthHint)
bool hasWidthHint = false;
};
/// A single row in a table.
struct TableRow {
std::vector<TableCell> cells;
};
/// Buffered table data collected during SAX parsing.
/// The entire table must be buffered before layout because column widths
/// depend on content across all rows.
struct TableData {
std::vector<TableRow> rows;
std::vector<CssLength> colWidthHints; // width hints from <col> tags, indexed by logical column
};

View File

@@ -1,13 +1,14 @@
#include "TextBlock.h" #include "TextBlock.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering // Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size()); (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return; return;
} }
@@ -42,8 +43,8 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
bool TextBlock::serialize(FsFile& file) const { bool TextBlock::serialize(FsFile& file) const {
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
words.size(), wordXpos.size(), wordStyles.size()); wordXpos.size(), wordStyles.size());
return false; return false;
} }
@@ -82,7 +83,7 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block) // Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
if (wc > 10000) { if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc); LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
return nullptr; return nullptr;
} }

View File

@@ -1,6 +1,6 @@
#include "CssParser.h" #include "CssParser.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -413,6 +413,9 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
style.defined.paddingLeft = 1; style.defined.paddingLeft = 1;
} }
} else if (propName == "width") {
style.width = interpretLength(propValue);
style.defined.width = 1;
} }
} }
@@ -449,7 +452,7 @@ void CssParser::processRuleBlock(const std::string& selectorGroup, const std::st
bool CssParser::loadFromStream(FsFile& source) { bool CssParser::loadFromStream(FsFile& source) {
if (!source) { if (!source) {
Serial.printf("[%lu] [CSS] Cannot read from invalid file\n", millis()); LOG_ERR("CSS", "Cannot read from invalid file");
return false; return false;
} }
@@ -470,7 +473,7 @@ bool CssParser::loadFromStream(FsFile& source) {
processRuleBlock(selector, body); processRuleBlock(selector, body);
} }
Serial.printf("[%lu] [CSS] Parsed %zu rules\n", millis(), rulesBySelector_.size()); LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
return true; return true;
} }
@@ -582,7 +585,7 @@ bool CssParser::saveToCache(FsFile& file) const {
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits)); file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
} }
Serial.printf("[%lu] [CSS] Saved %u rules to cache\n", millis(), ruleCount); LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
return true; return true;
} }
@@ -597,7 +600,7 @@ bool CssParser::loadFromCache(FsFile& file) {
// Read and verify version // Read and verify version
uint8_t version = 0; uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) { if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
Serial.printf("[%lu] [CSS] Cache version mismatch (got %u, expected %u)\n", millis(), version, CSS_CACHE_VERSION); LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
return false; return false;
} }
@@ -694,6 +697,6 @@ bool CssParser::loadFromCache(FsFile& file) {
rulesBySelector_[selector] = style; rulesBySelector_[selector] = style;
} }
Serial.printf("[%lu] [CSS] Loaded %u rules from cache\n", millis(), ruleCount); LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
return true; return true;
} }

View File

@@ -69,6 +69,7 @@ struct CssPropertyFlags {
uint16_t paddingBottom : 1; uint16_t paddingBottom : 1;
uint16_t paddingLeft : 1; uint16_t paddingLeft : 1;
uint16_t paddingRight : 1; uint16_t paddingRight : 1;
uint16_t width : 1;
CssPropertyFlags() CssPropertyFlags()
: textAlign(0), : textAlign(0),
@@ -83,17 +84,19 @@ struct CssPropertyFlags {
paddingTop(0), paddingTop(0),
paddingBottom(0), paddingBottom(0),
paddingLeft(0), paddingLeft(0),
paddingRight(0) {} paddingRight(0),
width(0) {}
[[nodiscard]] bool anySet() const { [[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight; marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width;
} }
void clearAll() { void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0; marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0; paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
width = 0;
} }
}; };
@@ -115,6 +118,7 @@ struct CssStyle {
CssLength paddingBottom; // Padding after CssLength paddingBottom; // Padding after
CssLength paddingLeft; // Padding left CssLength paddingLeft; // Padding left
CssLength paddingRight; // Padding right CssLength paddingRight; // Padding right
CssLength width; // Element width (used for table columns/cells)
CssPropertyFlags defined; // Tracks which properties were explicitly set CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -173,6 +177,10 @@ struct CssStyle {
paddingRight = base.paddingRight; paddingRight = base.paddingRight;
defined.paddingRight = 1; defined.paddingRight = 1;
} }
if (base.hasWidth()) {
width = base.width;
defined.width = 1;
}
} }
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; } [[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -188,6 +196,7 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; } [[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; } [[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; } [[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasWidth() const { return defined.width; }
void reset() { void reset() {
textAlign = CssTextAlign::Left; textAlign = CssTextAlign::Left;
@@ -197,6 +206,7 @@ struct CssStyle {
textIndent = CssLength{}; textIndent = CssLength{};
marginTop = marginBottom = marginLeft = marginRight = CssLength{}; marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
width = CssLength{};
defined.clearAll(); defined.clearAll();
} }
}; };

View File

@@ -0,0 +1,76 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#include "htmlEntities.h"
#include <cstring>
struct EntityPair {
const char* key;
const char* value;
};
static const EntityPair ENTITY_LOOKUP[] = {
{"&quot;", "\""}, {"&frasl;", ""}, {"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
{"&Agrave;", "À"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"},
{"&Aring;", "Å"}, {"&AElig;", "Æ"}, {"&Ccedil;", "Ç"}, {"&Egrave;", "È"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Euml;", "Ë"}, {"&Igrave;", "Ì"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"},
{"&Iuml;", "Ï"}, {"&ETH;", "Ð"}, {"&Ntilde;", "Ñ"}, {"&Ograve;", "Ò"}, {"&Oacute;", "Ó"},
{"&Ocirc;", "Ô"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"}, {"&Oslash;", "Ø"}, {"&Ugrave;", "Ù"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Uuml;", "Ü"}, {"&Yacute;", "Ý"}, {"&THORN;", "Þ"},
{"&szlig;", "ß"}, {"&agrave;", "à"}, {"&aacute;", "á"}, {"&acirc;", "â"}, {"&atilde;", "ã"},
{"&auml;", "ä"}, {"&aring;", "å"}, {"&aelig;", "æ"}, {"&ccedil;", "ç"}, {"&egrave;", "è"},
{"&eacute;", "é"}, {"&ecirc;", "ê"}, {"&euml;", "ë"}, {"&igrave;", "ì"}, {"&iacute;", "í"},
{"&icirc;", "î"}, {"&iuml;", "ï"}, {"&eth;", "ð"}, {"&ntilde;", "ñ"}, {"&ograve;", "ò"},
{"&oacute;", "ó"}, {"&ocirc;", "ô"}, {"&otilde;", "õ"}, {"&ouml;", "ö"}, {"&oslash;", "ø"},
{"&ugrave;", "ù"}, {"&uacute;", "ú"}, {"&ucirc;", "û"}, {"&uuml;", "ü"}, {"&yacute;", "ý"},
{"&thorn;", "þ"}, {"&yuml;", "ÿ"}, {"&nbsp;", "\xC2\xA0"}, {"&iexcl;", "¡"}, {"&cent;", "¢"},
{"&pound;", "£"}, {"&curren;", "¤"}, {"&yen;", "¥"}, {"&brvbar;", "¦"}, {"&sect;", "§"},
{"&uml;", "¨"}, {"&copy;", "©"}, {"&ordf;", "ª"}, {"&laquo;", "«"}, {"&not;", "¬"},
{"&shy;", "­"}, {"&reg;", "®"}, {"&macr;", "¯"}, {"&deg;", "°"}, {"&plusmn;", "±"},
{"&sup2;", "²"}, {"&sup3;", "³"}, {"&acute;", "´"}, {"&micro;", "µ"}, {"&para;", ""},
{"&cedil;", "¸"}, {"&sup1;", "¹"}, {"&ordm;", "º"}, {"&raquo;", "»"}, {"&frac14;", "¼"},
{"&frac12;", "½"}, {"&frac34;", "¾"}, {"&iquest;", "¿"}, {"&times;", "×"}, {"&divide;", "÷"},
{"&forall;", ""}, {"&part;", ""}, {"&exist;", ""}, {"&empty;", ""}, {"&nabla;", ""},
{"&isin;", ""}, {"&notin;", ""}, {"&ni;", ""}, {"&prod;", ""}, {"&sum;", ""},
{"&minus;", ""}, {"&lowast;", ""}, {"&radic;", ""}, {"&prop;", ""}, {"&infin;", ""},
{"&ang;", ""}, {"&and;", ""}, {"&or;", ""}, {"&cap;", ""}, {"&cup;", ""},
{"&int;", ""}, {"&there4;", ""}, {"&sim;", ""}, {"&cong;", ""}, {"&asymp;", ""},
{"&ne;", ""}, {"&equiv;", ""}, {"&le;", ""}, {"&ge;", ""}, {"&sub;", ""},
{"&sup;", ""}, {"&nsub;", ""}, {"&sube;", ""}, {"&supe;", ""}, {"&oplus;", ""},
{"&otimes;", ""}, {"&perp;", ""}, {"&sdot;", ""}, {"&Alpha;", "Α"}, {"&Beta;", "Β"},
{"&Gamma;", "Γ"}, {"&Delta;", "Δ"}, {"&Epsilon;", "Ε"}, {"&Zeta;", "Ζ"}, {"&Eta;", "Η"},
{"&Theta;", "Θ"}, {"&Iota;", "Ι"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"},
{"&Nu;", "Ν"}, {"&Xi;", "Ξ"}, {"&Omicron;", "Ο"}, {"&Pi;", "Π"}, {"&Rho;", "Ρ"},
{"&Sigma;", "Σ"}, {"&Tau;", "Τ"}, {"&Upsilon;", "Υ"}, {"&Phi;", "Φ"}, {"&Chi;", "Χ"},
{"&Psi;", "Ψ"}, {"&Omega;", "Ω"}, {"&alpha;", "α"}, {"&beta;", "β"}, {"&gamma;", "γ"},
{"&delta;", "δ"}, {"&epsilon;", "ε"}, {"&zeta;", "ζ"}, {"&eta;", "η"}, {"&theta;", "θ"},
{"&iota;", "ι"}, {"&kappa;", "κ"}, {"&lambda;", "λ"}, {"&mu;", "μ"}, {"&nu;", "ν"},
{"&xi;", "ξ"}, {"&omicron;", "ο"}, {"&pi;", "π"}, {"&rho;", "ρ"}, {"&sigmaf;", "ς"},
{"&sigma;", "σ"}, {"&tau;", "τ"}, {"&upsilon;", "υ"}, {"&phi;", "φ"}, {"&chi;", "χ"},
{"&psi;", "ψ"}, {"&omega;", "ω"}, {"&thetasym;", "ϑ"}, {"&upsih;", "ϒ"}, {"&piv;", "ϖ"},
{"&OElig;", "Œ"}, {"&oelig;", "œ"}, {"&Scaron;", "Š"}, {"&scaron;", "š"}, {"&Yuml;", "Ÿ"},
{"&fnof;", "ƒ"}, {"&circ;", "ˆ"}, {"&tilde;", "˜"}, {"&ensp;", ""}, {"&emsp;", ""},
{"&thinsp;", ""}, {"&zwnj;", ""}, {"&zwj;", ""}, {"&lrm;", ""}, {"&rlm;", ""},
{"&ndash;", ""}, {"&mdash;", ""}, {"&lsquo;", ""}, {"&rsquo;", ""}, {"&sbquo;", ""},
{"&ldquo;", ""}, {"&rdquo;", ""}, {"&bdquo;", ""}, {"&dagger;", ""}, {"&Dagger;", ""},
{"&bull;", ""}, {"&hellip;", ""}, {"&permil;", ""}, {"&prime;", ""}, {"&Prime;", ""},
{"&lsaquo;", ""}, {"&rsaquo;", ""}, {"&oline;", ""}, {"&euro;", ""}, {"&trade;", ""},
{"&larr;", ""}, {"&uarr;", ""}, {"&rarr;", ""}, {"&darr;", ""}, {"&harr;", ""},
{"&crarr;", ""}, {"&lceil;", ""}, {"&rceil;", ""}, {"&lfloor;", ""}, {"&rfloor;", ""},
{"&loz;", ""}, {"&spades;", ""}, {"&clubs;", ""}, {"&hearts;", ""}, {"&diams;", ""}};
static const size_t ENTITY_LOOKUP_COUNT = sizeof(ENTITY_LOOKUP) / sizeof(ENTITY_LOOKUP[0]);
// Lookup a single HTML entity and return its UTF-8 value
const char* lookupHtmlEntity(const char* entity, int len) {
for (size_t i = 0; i < ENTITY_LOOKUP_COUNT; i++) {
const char* key = ENTITY_LOOKUP[i].key;
const size_t keyLen = strlen(key);
if (static_cast<size_t>(len) == keyLen && memcmp(entity, key, keyLen) == 0) {
return ENTITY_LOOKUP[i].value;
}
}
return nullptr; // Entity not found
}

View File

@@ -0,0 +1,9 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#pragma once
#include <string>
// Lookup a single HTML entity (including & and ;) and return its UTF-8 value
// Returns nullptr if entity is not found
const char* lookupHtmlEntity(const char* entity, int len);

View File

@@ -1,48 +1,84 @@
#include "LanguageRegistry.h" #include "LanguageRegistry.h"
#include <algorithm> #include <algorithm>
#include <array> #include <vector>
#include "HyphenationCommon.h" #include "HyphenationCommon.h"
#ifndef OMIT_HYPH_DE
#include "generated/hyph-de.trie.h" #include "generated/hyph-de.trie.h"
#endif
#ifndef OMIT_HYPH_EN
#include "generated/hyph-en.trie.h" #include "generated/hyph-en.trie.h"
#endif
#ifndef OMIT_HYPH_ES
#include "generated/hyph-es.trie.h" #include "generated/hyph-es.trie.h"
#endif
#ifndef OMIT_HYPH_FR
#include "generated/hyph-fr.trie.h" #include "generated/hyph-fr.trie.h"
#endif
#ifndef OMIT_HYPH_IT
#include "generated/hyph-it.trie.h" #include "generated/hyph-it.trie.h"
#endif
#ifndef OMIT_HYPH_RU
#include "generated/hyph-ru.trie.h" #include "generated/hyph-ru.trie.h"
#endif
namespace { namespace {
#ifndef OMIT_HYPH_EN
// English hyphenation patterns (3/3 minimum prefix/suffix length) // English hyphenation patterns (3/3 minimum prefix/suffix length)
LanguageHyphenator englishHyphenator(en_us_patterns, isLatinLetter, toLowerLatin, 3, 3); LanguageHyphenator englishHyphenator(en_us_patterns, isLatinLetter, toLowerLatin, 3, 3);
#endif
#ifndef OMIT_HYPH_FR
LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_DE
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_RU
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic); LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
#endif
#ifndef OMIT_HYPH_ES
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_IT
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
#endif
using EntryArray = std::array<LanguageEntry, 6>; const LanguageEntryView entries() {
static const std::vector<LanguageEntry> kEntries = {
const EntryArray& entries() { #ifndef OMIT_HYPH_EN
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator}, {"english", "en", &englishHyphenator},
{"french", "fr", &frenchHyphenator}, #endif
{"german", "de", &germanHyphenator}, #ifndef OMIT_HYPH_FR
{"russian", "ru", &russianHyphenator}, {"french", "fr", &frenchHyphenator},
{"spanish", "es", &spanishHyphenator}, #endif
{"italian", "it", &italianHyphenator}}}; #ifndef OMIT_HYPH_DE
return kEntries; {"german", "de", &germanHyphenator},
#endif
#ifndef OMIT_HYPH_RU
{"russian", "ru", &russianHyphenator},
#endif
#ifndef OMIT_HYPH_ES
{"spanish", "es", &spanishHyphenator},
#endif
#ifndef OMIT_HYPH_IT
{"italian", "it", &italianHyphenator},
#endif
};
static const LanguageEntryView view{kEntries.data(), kEntries.size()};
return view;
} }
} // namespace } // namespace
const LanguageHyphenator* getLanguageHyphenatorForPrimaryTag(const std::string& primaryTag) { const LanguageHyphenator* getLanguageHyphenatorForPrimaryTag(const std::string& primaryTag) {
const auto& allEntries = entries(); const auto allEntries = entries();
const auto it = std::find_if(allEntries.begin(), allEntries.end(), const auto it = std::find_if(allEntries.begin(), allEntries.end(),
[&primaryTag](const LanguageEntry& entry) { return primaryTag == entry.primaryTag; }); [&primaryTag](const LanguageEntry& entry) { return primaryTag == entry.primaryTag; });
return (it != allEntries.end()) ? it->hyphenator : nullptr; return (it != allEntries.end()) ? it->hyphenator : nullptr;
} }
LanguageEntryView getLanguageEntries() { LanguageEntryView getLanguageEntries() {
const auto& allEntries = entries(); return entries();
return LanguageEntryView{allEntries.data(), allEntries.size()};
} }

View File

@@ -2,10 +2,13 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <expat.h> #include <expat.h>
#include <algorithm>
#include "../Page.h" #include "../Page.h"
#include "../htmlEntities.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
@@ -31,8 +34,30 @@ constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
const char* SKIP_TAGS[] = {"head"}; const char* SKIP_TAGS[] = {"head"};
constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]);
// Table tags that are transparent containers (just depth tracking, no special handling)
const char* TABLE_TRANSPARENT_TAGS[] = {"thead", "tbody", "tfoot", "colgroup"};
constexpr int NUM_TABLE_TRANSPARENT_TAGS = sizeof(TABLE_TRANSPARENT_TAGS) / sizeof(TABLE_TRANSPARENT_TAGS[0]);
// Table tags to skip entirely (their children produce no useful output)
const char* TABLE_SKIP_TAGS[] = {"caption"};
constexpr int NUM_TABLE_SKIP_TAGS = sizeof(TABLE_SKIP_TAGS) / sizeof(TABLE_SKIP_TAGS[0]);
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
// Parse an HTML width attribute value into a CssLength.
// "200" -> 200px, "50%" -> 50 percent. Returns false if the value can't be parsed.
static bool parseHtmlWidthAttr(const char* value, CssLength& out) {
char* end = nullptr;
const float num = strtof(value, &end);
if (end == value || num < 0) return false;
if (*end == '%') {
out = CssLength(num, CssUnit::Percent);
} else {
out = CssLength(num, CssUnit::Pixels);
}
return true;
}
// given the start and end of a tag, check to see if it matches a known tag // given the start and end of a tag, check to see if it matches a known tag
bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) { bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) {
for (int i = 0; i < possible_tag_count; i++) { for (int i = 0; i < possible_tag_count; i++) {
@@ -90,13 +115,37 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
// flush the buffer // flush the buffer
partWordBuffer[partWordBufferIndex] = '\0'; partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
// Handle double-encoded &nbsp; entities (e.g. &amp;nbsp; in source -> literal "&nbsp;" after
// XML parsing). Common in Wikipedia and other generated EPUBs. Replace with a space so the text
// renders cleanly. The space stays within the word, preserving non-breaking behavior.
std::string flushedWord(partWordBuffer);
size_t entityPos = 0;
while ((entityPos = flushedWord.find("&nbsp;", entityPos)) != std::string::npos) {
flushedWord.replace(entityPos, 6, " ");
entityPos += 1;
}
currentTextBlock->addWord(flushedWord, fontStyle, false, nextWordContinues);
partWordBufferIndex = 0; partWordBufferIndex = 0;
nextWordContinues = false; nextWordContinues = false;
} }
// start a new text block if needed // start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
// When inside a table cell, don't lay out to the page -- insert a forced line break
// within the cell's ParsedText so that block elements (p, div, br) create visual breaks.
if (inTable) {
if (partWordBufferIndex > 0) {
flushPartWordBuffer();
}
if (currentTextBlock && !currentTextBlock->isEmpty()) {
currentTextBlock->addLineBreak();
}
nextWordContinues = false;
return;
}
nextWordContinues = false; // New block = new paragraph, no continuation nextWordContinues = false; // New block = new paragraph, no continuation
if (currentTextBlock) { if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it // already have a text block running and it is empty - just reuse it
@@ -139,21 +188,184 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
centeredBlockStyle.textAlignDefined = true; centeredBlockStyle.textAlignDefined = true;
centeredBlockStyle.alignment = CssTextAlign::Center; centeredBlockStyle.alignment = CssTextAlign::Center;
// Special handling for tables - show placeholder text instead of dropping silently // --- Table handling ---
if (strcmp(name, "table") == 0) { if (strcmp(name, "table") == 0) {
// Add placeholder text if (self->inTable) {
self->startNewTextBlock(centeredBlockStyle); // Nested table: skip it entirely for v1
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
// Flush any pending content before the table
if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) {
self->makePages();
}
self->inTable = true;
self->tableData.reset(new TableData());
// Create a safe empty currentTextBlock so character data outside cells
// (e.g. whitespace between tags) doesn't crash
auto tableBlockStyle = BlockStyle();
tableBlockStyle.alignment = CssTextAlign::Left;
self->currentTextBlock.reset(new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, tableBlockStyle));
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
// Advance depth before processing character data (like you would for an element with text)
self->depth += 1; self->depth += 1;
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
// Skip table contents (skip until parent as we pre-advanced depth above)
self->skipUntilDepth = self->depth - 1;
return; return;
} }
// Table structure tags (only when inside a table)
if (self->inTable) {
if (strcmp(name, "tr") == 0) {
self->tableData->rows.push_back(TableRow());
self->depth += 1;
return;
}
// <col> — capture width hint for column sizing
if (strcmp(name, "col") == 0) {
CssLength widthHint;
bool hasHint = false;
// Parse HTML width attribute
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "width") == 0) {
hasHint = parseHtmlWidthAttr(atts[i + 1], widthHint);
break;
}
}
}
// CSS width (inline style) overrides HTML attribute
if (self->cssParser) {
std::string styleAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
break;
}
}
}
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
if (inlineStyle.hasWidth()) {
widthHint = inlineStyle.width;
hasHint = true;
}
}
}
if (hasHint) {
self->tableData->colWidthHints.push_back(widthHint);
} else {
// Push a zero-value placeholder to maintain index alignment
self->tableData->colWidthHints.push_back(CssLength());
}
self->depth += 1;
return;
}
if (strcmp(name, "td") == 0 || strcmp(name, "th") == 0) {
const bool isHeader = strcmp(name, "th") == 0;
// Parse colspan and width attributes
int colspan = 1;
CssLength cellWidthHint;
bool hasCellWidthHint = false;
std::string cellStyleAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "colspan") == 0) {
colspan = atoi(atts[i + 1]);
if (colspan < 1) colspan = 1;
} else if (strcmp(atts[i], "width") == 0) {
hasCellWidthHint = parseHtmlWidthAttr(atts[i + 1], cellWidthHint);
} else if (strcmp(atts[i], "style") == 0) {
cellStyleAttr = atts[i + 1];
}
}
}
// CSS width (inline style or stylesheet) overrides HTML attribute
if (self->cssParser) {
std::string classAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "class") == 0) {
classAttr = atts[i + 1];
break;
}
}
}
CssStyle cellCssStyle = self->cssParser->resolveStyle(name, classAttr);
if (!cellStyleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(cellStyleAttr);
cellCssStyle.applyOver(inlineStyle);
}
if (cellCssStyle.hasWidth()) {
cellWidthHint = cellCssStyle.width;
hasCellWidthHint = true;
}
}
// Ensure there's a row to add cells to
if (self->tableData->rows.empty()) {
self->tableData->rows.push_back(TableRow());
}
// Create a new ParsedText for this cell (characterData will flow into it)
auto cellBlockStyle = BlockStyle();
cellBlockStyle.alignment = CssTextAlign::Left;
cellBlockStyle.textAlignDefined = true;
// Explicitly disable paragraph indent for table cells
cellBlockStyle.textIndent = 0;
cellBlockStyle.textIndentDefined = true;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, cellBlockStyle));
self->nextWordContinues = false;
// Track the cell
auto& currentRow = self->tableData->rows.back();
currentRow.cells.push_back(TableCell());
currentRow.cells.back().isHeader = isHeader;
currentRow.cells.back().colspan = colspan;
if (hasCellWidthHint) {
currentRow.cells.back().widthHint = cellWidthHint;
currentRow.cells.back().hasWidthHint = true;
}
// Apply bold for header cells
if (isHeader) {
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
}
self->depth += 1;
return;
}
// Transparent table container tags
if (matches(name, TABLE_TRANSPARENT_TAGS, NUM_TABLE_TRANSPARENT_TAGS)) {
self->depth += 1;
return;
}
// Skip colgroup, col, caption
if (matches(name, TABLE_SKIP_TAGS, NUM_TABLE_SKIP_TAGS)) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
// Other tags inside table cells (p, div, span, b, i, etc.) fall through
// to the normal handling below. startNewTextBlock is a no-op when inTable.
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags // TODO: Start processing image tags
std::string alt = "[Image]"; std::string alt = "[Image]";
@@ -168,7 +380,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); LOG_DBG("EHP", "Image alt: %s", alt.c_str());
self->startNewTextBlock(centeredBlockStyle); self->startNewTextBlock(centeredBlockStyle);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth); self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
@@ -359,6 +571,28 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
continue; continue;
} }
// Detect U+00A0 (non-breaking space): UTF-8 encoding is 0xC2 0xA0
// Render a visible space without allowing a line break around it.
if (static_cast<uint8_t>(s[i]) == 0xC2 && i + 1 < len && static_cast<uint8_t>(s[i + 1]) == 0xA0) {
// Flush any pending text so style is applied correctly.
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Add a standalone space that attaches to the previous word.
self->partWordBuffer[0] = ' ';
self->partWordBuffer[1] = '\0';
self->partWordBufferIndex = 1;
self->nextWordContinues = true; // Attach space to previous word (no break).
self->flushPartWordBuffer();
// Ensure the next real word attaches to this space (no break).
self->nextWordContinues = true;
i++; // Skip the second byte (0xA0)
continue;
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF); const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF);
const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB); const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB);
@@ -385,14 +619,31 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of // There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory. // memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there. // Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) { // Skip this when inside a table - cell content is buffered for later layout.
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis()); if (!self->inTable && self->currentTextBlock->size() > 750) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
self->currentTextBlock->layoutAndExtractLines( self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth, self->renderer, self->fontId, self->viewportWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false); [self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
} }
} }
void XMLCALL ChapterHtmlSlimParser::defaultHandlerExpand(void* userData, const XML_Char* s, const int len) {
// Check if this looks like an entity reference (&...;)
if (len >= 3 && s[0] == '&' && s[len - 1] == ';') {
const char* utf8Value = lookupHtmlEntity(s, len);
if (utf8Value != nullptr) {
// Known entity: expand to its UTF-8 value
characterData(userData, utf8Value, strlen(utf8Value));
return;
}
// Unknown entity: preserve original &...; sequence
characterData(userData, s, len);
return;
}
// Not an entity we recognize - skip it
}
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
@@ -407,15 +658,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline; const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
const bool headerOrBlockTag = isHeaderOrBlock(name); const bool headerOrBlockTag = isHeaderOrBlock(name);
const bool isTableCellTag = strcmp(name, "td") == 0 || strcmp(name, "th") == 0;
const bool isTableTag = strcmp(name, "table") == 0;
// Flush buffer with current style BEFORE any style changes // Flush buffer with current style BEFORE any style changes
if (self->partWordBufferIndex > 0) { if (self->partWordBufferIndex > 0) {
// Flush if style will change OR if we're closing a block/structural element // Flush if style will change OR if we're closing a block/structural element
const bool isInlineTag = !headerOrBlockTag && strcmp(name, "table") != 0 && const bool isInlineTag = !headerOrBlockTag && !isTableTag && !isTableCellTag &&
!matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1; !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1;
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 || matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || isTableTag || isTableCellTag ||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
if (shouldFlush) { if (shouldFlush) {
@@ -427,6 +680,57 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
} }
} }
// --- Table cell/row/table close handling ---
if (self->inTable) {
if (isTableCellTag) {
// Save the current cell content into the table data
if (self->tableData && !self->tableData->rows.empty()) {
auto& currentRow = self->tableData->rows.back();
if (!currentRow.cells.empty()) {
currentRow.cells.back().content = std::move(self->currentTextBlock);
}
}
// Create a safe empty ParsedText so character data between cells doesn't crash
auto safeBlockStyle = BlockStyle();
safeBlockStyle.alignment = CssTextAlign::Left;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, safeBlockStyle));
self->nextWordContinues = false;
}
if (isTableTag) {
// Process the entire buffered table
self->depth -= 1;
// Clean up style state for this depth
if (self->skipUntilDepth == self->depth) self->skipUntilDepth = INT_MAX;
if (self->boldUntilDepth == self->depth) self->boldUntilDepth = INT_MAX;
if (self->italicUntilDepth == self->depth) self->italicUntilDepth = INT_MAX;
if (self->underlineUntilDepth == self->depth) self->underlineUntilDepth = INT_MAX;
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
self->inlineStyleStack.pop_back();
self->updateEffectiveInlineStyle();
}
self->processTable();
self->inTable = false;
self->tableData.reset();
// Restore a fresh text block for content after the table
auto paragraphAlignmentBlockStyle = BlockStyle();
paragraphAlignmentBlockStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
paragraphAlignmentBlockStyle.alignment = align;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, paragraphAlignmentBlockStyle));
return; // depth already decremented, skip the normal endElement cleanup
}
}
self->depth -= 1; self->depth -= 1;
// Leaving skip // Leaving skip
@@ -477,10 +781,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
int done; int done;
if (!parser) { if (!parser) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis()); LOG_ERR("EHP", "Couldn't allocate memory for parser");
return false; return false;
} }
// Handle HTML entities (like &nbsp;) that aren't in XML spec or DTD
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
FsFile file; FsFile file;
if (!Storage.openFileForRead("EHP", filepath, file)) { if (!Storage.openFileForRead("EHP", filepath, file)) {
XML_ParserFree(parser); XML_ParserFree(parser);
@@ -499,7 +807,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
do { do {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); LOG_ERR("EHP", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -511,7 +819,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
const size_t len = file.read(buf, 1024); const size_t len = file.read(buf, 1024);
if (len == 0 && file.available() > 0) { if (len == 0 && file.available() > 0) {
Serial.printf("[%lu] [EHP] File read error\n", millis()); LOG_ERR("EHP", "File read error");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -523,8 +831,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -568,7 +876,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
void ChapterHtmlSlimParser::makePages() { void ChapterHtmlSlimParser::makePages() {
if (!currentTextBlock) { if (!currentTextBlock) {
Serial.printf("[%lu] [EHP] !! No text block to make pages for !!\n", millis()); LOG_ERR("EHP", "!! No text block to make pages for !!");
return; return;
} }
@@ -610,3 +918,335 @@ void ChapterHtmlSlimParser::makePages() {
currentPageNextY += lineHeight / 2; currentPageNextY += lineHeight / 2;
} }
} }
// ---------------------------------------------------------------------------
// Table processing
// ---------------------------------------------------------------------------
// Cell padding in pixels (horizontal space between grid line and cell text)
static constexpr int TABLE_CELL_PAD_X = 4;
// Vertical cell padding — asymmetric because font metrics include internal leading (whitespace
// above glyphs), so the top already has built-in visual space. Less explicit padding on top,
// more on bottom, produces visually balanced results.
static constexpr int TABLE_CELL_PAD_TOP = 1;
static constexpr int TABLE_CELL_PAD_BOTTOM = 3;
// Minimum usable column width in pixels (below this text is unreadable)
static constexpr int TABLE_MIN_COL_WIDTH = 30;
// Grid line width in pixels
static constexpr int TABLE_GRID_LINE_PX = 1;
void ChapterHtmlSlimParser::addTableRowToPage(std::shared_ptr<PageTableRow> row) {
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
const int16_t rowH = row->getHeight();
// If this row doesn't fit on the current page, start a new one
if (currentPageNextY + rowH > viewportHeight) {
completePageFn(std::move(currentPage));
currentPage.reset(new Page());
currentPageNextY = 0;
}
row->xPos = 0;
row->yPos = currentPageNextY;
currentPage->elements.push_back(std::move(row));
currentPageNextY += rowH;
}
void ChapterHtmlSlimParser::processTable() {
if (!tableData || tableData->rows.empty()) {
return;
}
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
const int lh = static_cast<int>(renderer.getLineHeight(fontId) * lineCompression);
// 1. Determine logical column count using colspan.
// Each cell occupies cell.colspan logical columns. The total for a row is the sum of colspans.
size_t numCols = 0;
for (const auto& row : tableData->rows) {
size_t rowLogicalCols = 0;
for (const auto& cell : row.cells) {
rowLogicalCols += static_cast<size_t>(cell.colspan);
}
numCols = std::max(numCols, rowLogicalCols);
}
if (numCols == 0) {
return;
}
// 2. Measure natural width of each cell and compute per-column max natural width.
// Only non-spanning cells (colspan==1) contribute to individual column widths.
// Spanning cells use the combined width of their spanned columns.
std::vector<uint16_t> colNaturalWidth(numCols, 0);
for (const auto& row : tableData->rows) {
size_t logicalCol = 0;
for (const auto& cell : row.cells) {
if (cell.colspan == 1 && cell.content && !cell.content->isEmpty()) {
if (logicalCol < numCols) {
const uint16_t w = cell.content->getNaturalWidth(renderer, fontId);
if (w > colNaturalWidth[logicalCol]) {
colNaturalWidth[logicalCol] = w;
}
}
}
logicalCol += static_cast<size_t>(cell.colspan);
}
}
// 3. Calculate column widths to fit viewport.
// Available width = viewport - outer borders - internal column borders - cell padding
const int totalGridLines = static_cast<int>(numCols) + 1; // left + between columns + right
const int totalPadding = static_cast<int>(numCols) * TABLE_CELL_PAD_X * 2;
const int availableForContent = viewportWidth - totalGridLines * TABLE_GRID_LINE_PX - totalPadding;
// 3a. Resolve width hints per column.
// Priority: <col> hints > max cell hint (colspan=1 only).
// Percentages are relative to availableForContent.
const float emSize = static_cast<float>(lh);
const float containerW = static_cast<float>(std::max(availableForContent, 0));
std::vector<int> colHintedWidth(numCols, -1); // -1 = no hint
// From <col> tags
for (size_t c = 0; c < numCols && c < tableData->colWidthHints.size(); ++c) {
const auto& hint = tableData->colWidthHints[c];
if (hint.value > 0) {
int px = static_cast<int>(hint.toPixels(emSize, containerW));
if (px > 0) {
colHintedWidth[c] = std::max(px, TABLE_MIN_COL_WIDTH);
}
}
}
// From <td>/<th> cell width hints (only override if no <col> hint exists for this column)
for (const auto& row : tableData->rows) {
size_t logicalCol = 0;
for (const auto& cell : row.cells) {
if (cell.colspan == 1 && cell.hasWidthHint && logicalCol < numCols) {
if (colHintedWidth[logicalCol] < 0) { // no <col> hint yet
int px = static_cast<int>(cell.widthHint.toPixels(emSize, containerW));
if (px > colHintedWidth[logicalCol]) {
colHintedWidth[logicalCol] = std::max(px, TABLE_MIN_COL_WIDTH);
}
}
}
logicalCol += static_cast<size_t>(cell.colspan);
}
}
// 3b. Distribute column widths: hinted columns get their hint, unhinted use auto-sizing.
std::vector<uint16_t> colWidths(numCols, 0);
if (availableForContent <= 0) {
const uint16_t equalWidth = static_cast<uint16_t>(viewportWidth / numCols);
for (size_t c = 0; c < numCols; ++c) {
colWidths[c] = equalWidth;
}
} else {
// First, assign hinted columns and track how much space they consume
int hintedSpaceUsed = 0;
size_t unhintedCount = 0;
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] > 0) {
hintedSpaceUsed += colHintedWidth[c];
} else {
unhintedCount++;
}
}
// If hinted columns exceed available space, scale them down proportionally
if (hintedSpaceUsed > availableForContent && hintedSpaceUsed > 0) {
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] > 0) {
colHintedWidth[c] = colHintedWidth[c] * availableForContent / hintedSpaceUsed;
colHintedWidth[c] = std::max(colHintedWidth[c], TABLE_MIN_COL_WIDTH);
}
}
// Recalculate
hintedSpaceUsed = 0;
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] > 0) {
hintedSpaceUsed += colHintedWidth[c];
}
}
}
// Assign hinted columns
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] > 0) {
colWidths[c] = static_cast<uint16_t>(colHintedWidth[c]);
}
}
// Distribute remaining space among unhinted columns using the existing algorithm
const int remainingForUnhinted = std::max(availableForContent - hintedSpaceUsed, 0);
if (unhintedCount > 0 && remainingForUnhinted > 0) {
// Compute total natural width of unhinted columns
int totalNaturalUnhinted = 0;
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] <= 0) {
totalNaturalUnhinted += colNaturalWidth[c];
}
}
if (totalNaturalUnhinted <= remainingForUnhinted) {
// All unhinted content fits — distribute extra space equally among unhinted columns
const int extraSpace = remainingForUnhinted - totalNaturalUnhinted;
const int perColExtra = extraSpace / static_cast<int>(unhintedCount);
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] <= 0) {
colWidths[c] = static_cast<uint16_t>(colNaturalWidth[c] + perColExtra);
}
}
} else {
// Unhinted content exceeds remaining space — two-pass fair-share among unhinted columns
const int equalShare = remainingForUnhinted / static_cast<int>(unhintedCount);
int spaceUsedByFitting = 0;
int naturalOfWide = 0;
size_t wideCount = 0;
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] <= 0) {
if (static_cast<int>(colNaturalWidth[c]) <= equalShare) {
colWidths[c] = colNaturalWidth[c];
spaceUsedByFitting += colNaturalWidth[c];
} else {
naturalOfWide += colNaturalWidth[c];
wideCount++;
}
}
}
const int wideSpace = remainingForUnhinted - spaceUsedByFitting;
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] <= 0 && static_cast<int>(colNaturalWidth[c]) > equalShare) {
if (naturalOfWide > 0 && wideCount > 1) {
int proportional = static_cast<int>(colNaturalWidth[c]) * wideSpace / naturalOfWide;
colWidths[c] = static_cast<uint16_t>(std::max(proportional, TABLE_MIN_COL_WIDTH));
} else {
colWidths[c] = static_cast<uint16_t>(std::max(wideSpace, TABLE_MIN_COL_WIDTH));
}
}
}
}
} else if (unhintedCount > 0) {
// No remaining space for unhinted columns — give them minimum width
for (size_t c = 0; c < numCols; ++c) {
if (colHintedWidth[c] <= 0) {
colWidths[c] = static_cast<uint16_t>(TABLE_MIN_COL_WIDTH);
}
}
}
}
// Compute column x-offsets (cumulative: border + padding + content width + padding + border ...)
std::vector<uint16_t> colXOffsets(numCols, 0);
int xAccum = TABLE_GRID_LINE_PX; // start after left border
for (size_t c = 0; c < numCols; ++c) {
colXOffsets[c] = static_cast<uint16_t>(xAccum);
xAccum += TABLE_CELL_PAD_X + colWidths[c] + TABLE_CELL_PAD_X + TABLE_GRID_LINE_PX;
}
const int16_t totalTableWidth = static_cast<int16_t>(xAccum);
// Helper: compute the combined content width for a cell spanning multiple columns.
// This includes the content widths plus the internal grid lines and padding between spanned columns.
auto spanContentWidth = [&](size_t startCol, int colspan) -> uint16_t {
int width = 0;
for (int s = 0; s < colspan && startCol + s < numCols; ++s) {
width += colWidths[startCol + s];
if (s > 0) {
// Add internal padding and grid line between spanned columns
width += TABLE_CELL_PAD_X * 2 + TABLE_GRID_LINE_PX;
}
}
return static_cast<uint16_t>(std::max(width, 0));
};
// Helper: compute the full cell width (including padding on both sides) for a spanning cell.
auto spanFullCellWidth = [&](size_t startCol, int colspan) -> uint16_t {
if (colspan <= 0 || startCol >= numCols) return 0;
const size_t endCol = std::min(startCol + static_cast<size_t>(colspan), numCols) - 1;
// From the left edge of startCol's cell to the right edge of endCol's cell
const int leftEdge = colXOffsets[startCol];
const int rightEdge = colXOffsets[endCol] + TABLE_CELL_PAD_X + colWidths[endCol] + TABLE_CELL_PAD_X;
return static_cast<uint16_t>(rightEdge - leftEdge);
};
// 4. Lay out each row: map cells to logical columns, create PageTableRow
for (auto& row : tableData->rows) {
// Build cell data for this row, one entry per CELL (not per logical column).
// Each PageTableCellData gets the correct x-offset and combined column width.
std::vector<PageTableCellData> cellDataVec;
size_t maxLinesInRow = 1;
size_t logicalCol = 0;
for (size_t ci = 0; ci < row.cells.size() && logicalCol < numCols; ++ci) {
auto& cell = row.cells[ci];
const int cs = cell.colspan;
PageTableCellData cellData;
cellData.xOffset = colXOffsets[logicalCol];
cellData.columnWidth = spanFullCellWidth(logicalCol, cs);
if (cell.content && !cell.content->isEmpty()) {
// Center-align cells that span the full table width (common for section headers/titles)
if (cs >= static_cast<int>(numCols)) {
BlockStyle centeredStyle = cell.content->getBlockStyle();
centeredStyle.alignment = CssTextAlign::Center;
centeredStyle.textAlignDefined = true;
cell.content->setBlockStyle(centeredStyle);
}
const uint16_t contentWidth = spanContentWidth(logicalCol, cs);
std::vector<std::shared_ptr<TextBlock>> cellLines;
cell.content->layoutAndExtractLines(
renderer, fontId, contentWidth,
[&cellLines](const std::shared_ptr<TextBlock>& textBlock) { cellLines.push_back(textBlock); });
if (cellLines.size() > maxLinesInRow) {
maxLinesInRow = cellLines.size();
}
cellData.lines = std::move(cellLines);
}
cellDataVec.push_back(std::move(cellData));
logicalCol += static_cast<size_t>(cs);
}
// Fill remaining logical columns with empty cells (rows shorter than numCols)
while (logicalCol < numCols) {
PageTableCellData emptyCell;
emptyCell.xOffset = colXOffsets[logicalCol];
emptyCell.columnWidth = static_cast<uint16_t>(TABLE_CELL_PAD_X + colWidths[logicalCol] + TABLE_CELL_PAD_X);
cellDataVec.push_back(std::move(emptyCell));
logicalCol++;
}
// Row height = max lines * lineHeight + top/bottom border + asymmetric vertical padding
const int16_t rowHeight = static_cast<int16_t>(
static_cast<int>(maxLinesInRow) * lh + 2 + TABLE_CELL_PAD_TOP + TABLE_CELL_PAD_BOTTOM);
auto pageTableRow = std::make_shared<PageTableRow>(
std::move(cellDataVec), rowHeight, totalTableWidth, static_cast<int16_t>(lh), 0, 0);
addTableRowToPage(std::move(pageTableRow));
}
// Add a small gap after the table
if (extraParagraphSpacing) {
currentPageNextY += lh / 2;
}
}

View File

@@ -7,11 +7,13 @@
#include <memory> #include <memory>
#include "../ParsedText.h" #include "../ParsedText.h"
#include "../TableData.h"
#include "../blocks/TextBlock.h" #include "../blocks/TextBlock.h"
#include "../css/CssParser.h" #include "../css/CssParser.h"
#include "../css/CssStyle.h" #include "../css/CssStyle.h"
class Page; class Page;
class PageTableRow;
class GfxRenderer; class GfxRenderer;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
@@ -57,13 +59,20 @@ class ChapterHtmlSlimParser {
bool effectiveItalic = false; bool effectiveItalic = false;
bool effectiveUnderline = false; bool effectiveUnderline = false;
// Table buffering state
bool inTable = false;
std::unique_ptr<TableData> tableData;
void updateEffectiveInlineStyle(); void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle); void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer(); void flushPartWordBuffer();
void makePages(); void makePages();
void processTable();
void addTableRowToPage(std::shared_ptr<PageTableRow> row);
// XML callbacks // XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len); static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
static void XMLCALL defaultHandlerExpand(void* userData, const XML_Char* s, int len);
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: public:

View File

@@ -1,11 +1,11 @@
#include "ContainerParser.h" #include "ContainerParser.h"
#include <HardwareSerial.h> #include <Logging.h>
bool ContainerParser::setup() { bool ContainerParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [CTR] Couldn't allocate memory for parser\n", millis()); LOG_ERR("CTR", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -34,7 +34,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [CTR] Couldn't allocate buffer\n", millis()); LOG_DBG("CTR", "Couldn't allocate buffer");
return 0; return 0;
} }
@@ -42,7 +42,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [CTR] Parse error: %s\n", millis(), XML_ErrorString(XML_GetErrorCode(parser))); LOG_ERR("CTR", "Parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
return 0; return 0;
} }

View File

@@ -1,7 +1,7 @@
#include "ContentOpfParser.h" #include "ContentOpfParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
@@ -15,7 +15,7 @@ constexpr char itemCacheFile[] = "/.items.bin";
bool ContentOpfParser::setup() { bool ContentOpfParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for parser\n", millis()); LOG_DBG("COF", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -56,7 +56,7 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis()); LOG_ERR("COF", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -69,8 +69,8 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("COF", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -119,9 +119,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST; self->state = IN_MANIFEST;
if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n",
millis());
} }
return; return;
} }
@@ -129,9 +127,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE; self->state = IN_SPINE;
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
} }
// Sort item index for binary search if we have enough items // Sort item index for binary search if we have enough items
@@ -140,7 +136,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen); return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
}); });
self->useItemIndex = true; self->useItemIndex = true;
Serial.printf("[%lu] [COF] Using fast index for %zu manifest items\n", millis(), self->itemIndex.size()); LOG_DBG("COF", "Using fast index for %zu manifest items", self->itemIndex.size());
} }
return; return;
} }
@@ -148,11 +144,9 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
self->state = IN_GUIDE; self->state = IN_GUIDE;
// TODO Remove print // TODO Remove print
Serial.printf("[%lu] [COF] Entering guide state.\n", millis()); LOG_DBG("COF", "Entering guide state.");
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
} }
return; return;
} }
@@ -214,8 +208,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->tocNcxPath.empty()) { if (self->tocNcxPath.empty()) {
self->tocNcxPath = href; self->tocNcxPath = href;
} else { } else {
Serial.printf("[%lu] [COF] Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s\n", millis(), LOG_DBG("COF", "Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s", href.c_str());
href.c_str());
} }
} }
@@ -229,7 +222,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
// Properties is space-separated, check if "nav" is present as a word // Properties is space-separated, check if "nav" is present as a word
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) { if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
self->tocNavPath = href; self->tocNavPath = href;
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str()); LOG_DBG("COF", "Found EPUB 3 nav document: %s", href.c_str());
} }
} }
@@ -310,7 +303,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (type == "text" || type == "start") { if (type == "text" || type == "start") {
continue; continue;
} else { } else {
Serial.printf("[%lu] [COF] Skipping non-text reference in guide: %s\n", millis(), type.c_str()); LOG_DBG("COF", "Skipping non-text reference in guide: %s", type.c_str());
break; break;
} }
} else if (strcmp(atts[i], "href") == 0) { } else if (strcmp(atts[i], "href") == 0) {
@@ -318,7 +311,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
} }
} }
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
Serial.printf("[%lu] [COF] Found %s reference in guide: %s.\n", millis(), type.c_str(), textHref.c_str()); LOG_DBG("COF", "Found %s reference in guide: %s.", type.c_str(), textHref.c_str());
self->textReferenceHref = textHref; self->textReferenceHref = textHref;
} }
return; return;

View File

@@ -1,14 +1,14 @@
#include "TocNavParser.h" #include "TocNavParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
bool TocNavParser::setup() { bool TocNavParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis()); LOG_DBG("NAV", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -39,7 +39,7 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("NAV", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr); XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -52,8 +52,8 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("NAV", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr); XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -88,7 +88,7 @@ void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, co
for (int i = 0; atts[i]; i += 2) { for (int i = 0; atts[i]; i += 2) {
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) { if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
self->state = IN_NAV_TOC; self->state = IN_NAV_TOC;
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis()); LOG_DBG("NAV", "Found nav toc element");
return; return;
} }
} }
@@ -179,7 +179,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) { if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
self->state = IN_BODY; self->state = IN_BODY;
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis()); LOG_DBG("NAV", "Finished parsing nav toc");
return; return;
} }
} }

View File

@@ -1,14 +1,14 @@
#include "TocNcxParser.h" #include "TocNcxParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
bool TocNcxParser::setup() { bool TocNcxParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for parser\n", millis()); LOG_DBG("TOC", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -39,7 +39,7 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("TOC", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -52,8 +52,8 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("TOC", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);

View File

@@ -1,11 +1,12 @@
#include "GfxRenderer.h" #include "GfxRenderer.h"
#include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
void GfxRenderer::begin() { void GfxRenderer::begin() {
frameBuffer = display.getFrameBuffer(); frameBuffer = display.getFrameBuffer();
if (!frameBuffer) { if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer\n", millis()); LOG_ERR("GFX", "!! No framebuffer");
assert(false); assert(false);
} }
} }
@@ -57,7 +58,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
// Bounds checking against physical panel dimensions // Bounds checking against physical panel dimensions
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) { if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, phyX, phyY); LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY);
return; return;
} }
@@ -84,7 +85,7 @@ void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit)
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -110,7 +111,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
} }
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId); const auto font = fontMap.at(fontId);
@@ -143,7 +144,7 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
} }
} else { } else {
// TODO: Implement // TODO: Implement
Serial.printf("[%lu] [GFX] Line drawing not supported\n", millis()); LOG_ERR("GFX", "Line drawing not supported");
} }
} }
@@ -429,8 +430,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
bool isScaled = false; bool isScaled = false;
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f); int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f);
Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); bitmap.isTopDown() ? "top-down" : "bottom-up");
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight(); const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
@@ -448,7 +449,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
scale = static_cast<float>(maxHeight) / effectiveHeight; scale = static_cast<float>(maxHeight) / effectiveHeight;
isScaled = true; isScaled = true;
} }
Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled"); LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
// Calculate output row size (2 bits per pixel, packed into bytes) // Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
@@ -457,7 +458,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); LOG_ERR("GFX", "!! Failed to allocate BMP row buffers");
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -481,7 +482,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
} }
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); LOG_ERR("GFX", "Failed to read row %d from bitmap", bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -564,7 +565,7 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); LOG_ERR("GFX", "!! Failed to allocate 1-bit BMP row buffers");
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -573,7 +574,7 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// Read rows sequentially using readNextRow // Read rows sequentially using readNextRow
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); LOG_ERR("GFX", "Failed to read row %d from 1-bit bitmap", bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -654,7 +655,7 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
// Allocate node buffer for scanline algorithm // Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int))); auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) { if (!nodeX) {
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); LOG_ERR("GFX", "!! Failed to allocate polygon node buffer");
return; return;
} }
@@ -721,7 +722,7 @@ void GfxRenderer::invertScreen() const {
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
auto elapsed = millis() - start_ms; auto elapsed = millis() - start_ms;
Serial.printf("[%lu] [GFX] Time = %lu ms from clearScreen to displayBuffer\n", millis(), elapsed); LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed);
display.displayBuffer(refreshMode, fadingFix); display.displayBuffer(refreshMode, fadingFix);
} }
@@ -775,7 +776,7 @@ int GfxRenderer::getScreenHeight() const {
int GfxRenderer::getSpaceWidth(const int fontId) const { int GfxRenderer::getSpaceWidth(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -784,7 +785,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const { int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -798,7 +799,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
int GfxRenderer::getFontAscenderSize(const int fontId) const { int GfxRenderer::getFontAscenderSize(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -807,7 +808,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
int GfxRenderer::getLineHeight(const int fontId) const { int GfxRenderer::getLineHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -816,7 +817,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
int GfxRenderer::getTextHeight(const int fontId) const { int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender; return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
@@ -830,7 +831,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
} }
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId); const auto font = fontMap.at(fontId);
@@ -913,7 +914,7 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
} }
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId); const auto font = fontMap.at(fontId);
@@ -1024,8 +1025,7 @@ bool GfxRenderer::storeBwBuffer() {
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if any chunks are already allocated // Check if any chunks are already allocated
if (bwBufferChunks[i]) { if (bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk\n", LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i);
millis(), i);
free(bwBufferChunks[i]); free(bwBufferChunks[i]);
bwBufferChunks[i] = nullptr; bwBufferChunks[i] = nullptr;
} }
@@ -1034,8 +1034,7 @@ bool GfxRenderer::storeBwBuffer() {
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE)); bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE));
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", millis(), i, LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, BW_BUFFER_CHUNK_SIZE);
BW_BUFFER_CHUNK_SIZE);
// Free previously allocated chunks // Free previously allocated chunks
freeBwBufferChunks(); freeBwBufferChunks();
return false; return false;
@@ -1044,8 +1043,7 @@ bool GfxRenderer::storeBwBuffer() {
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
} }
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS, LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE);
BW_BUFFER_CHUNK_SIZE);
return true; return true;
} }
@@ -1072,7 +1070,7 @@ void GfxRenderer::restoreBwBuffer() {
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing // Check if chunk is missing
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis()); LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug");
freeBwBufferChunks(); freeBwBufferChunks();
return; return;
} }
@@ -1084,7 +1082,7 @@ void GfxRenderer::restoreBwBuffer() {
display.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks(); freeBwBufferChunks();
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); LOG_DBG("GFX", "Restored and freed BW buffer chunks");
} }
/** /**
@@ -1106,7 +1104,7 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
// no glyph? // no glyph?
if (!glyph) { if (!glyph) {
Serial.printf("[%lu] [GFX] No glyph for codepoint %d\n", millis(), cp); LOG_ERR("GFX", "No glyph for codepoint %d", cp);
return; return;
} }

View File

@@ -1,7 +1,7 @@
#include "JpegToBmpConverter.h" #include "JpegToBmpConverter.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <picojpeg.h> #include <picojpeg.h>
#include <cstdio> #include <cstdio>
@@ -201,8 +201,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
// Internal implementation with configurable target size and bit depth // Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit, bool crop) { bool oneBit, bool crop) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
targetWidth, targetHeight);
// Setup context for picojpeg callback // Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@@ -211,12 +210,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
pjpeg_image_info_t imageInfo; pjpeg_image_info_t imageInfo;
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) { if (status != 0) {
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status); LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status);
return false; return false;
} }
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height,
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
// Safety limits to prevent memory issues on ESP32 // Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_WIDTH = 2048;
@@ -224,8 +223,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
constexpr int MAX_MCU_ROW_BYTES = 65536; constexpr int MAX_MCU_ROW_BYTES = 65536;
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height,
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
return false; return false;
} }
@@ -262,8 +261,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight; scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
needsScaling = true; needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "Pre-scaling %dx%d -> %dx%d (fit to %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth,
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); outHeight, targetWidth, targetHeight);
} }
// Write BMP header with output dimensions // Write BMP header with output dimensions
@@ -282,7 +281,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Allocate row buffer // Allocate row buffer
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow)); auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) { if (!rowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); LOG_ERR("JPG", "Failed to allocate row buffer");
return false; return false;
} }
@@ -293,15 +292,14 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Validate MCU row buffer size before allocation // Validate MCU row buffer size before allocation
if (mcuRowPixels > MAX_MCU_ROW_BYTES) { if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels, LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES);
MAX_MCU_ROW_BYTES);
free(rowBuffer); free(rowBuffer);
return false; return false;
} }
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels)); auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) { if (!mcuRowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels); LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels);
free(rowBuffer); free(rowBuffer);
return false; return false;
} }
@@ -349,10 +347,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
const unsigned char mcuStatus = pjpeg_decode_mcu(); const unsigned char mcuStatus = pjpeg_decode_mcu();
if (mcuStatus != 0) { if (mcuStatus != 0) {
if (mcuStatus == PJPG_NO_MORE_BLOCKS) { if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY); LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY);
} else { } else {
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY, LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus);
mcuStatus);
} }
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);
@@ -549,7 +546,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); LOG_DBG("JPG", "Successfully converted JPEG to BMP");
return true; return true;
} }

View File

@@ -1,7 +1,7 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <Serialization.h> #include <Serialization.h>
@@ -44,7 +44,7 @@ bool KOReaderCredentialStore::saveToFile() const {
// Write username (plaintext - not particularly sensitive) // Write username (plaintext - not particularly sensitive)
serialization::writeString(file, username); serialization::writeString(file, username);
Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str()); LOG_DBG("KRS", "Saving username: %s", username.c_str());
// Write password (obfuscated) // Write password (obfuscated)
std::string obfuscatedPwd = password; std::string obfuscatedPwd = password;
@@ -58,14 +58,14 @@ bool KOReaderCredentialStore::saveToFile() const {
serialization::writePod(file, static_cast<uint8_t>(matchMethod)); serialization::writePod(file, static_cast<uint8_t>(matchMethod));
file.close(); file.close();
Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis()); LOG_DBG("KRS", "Saved KOReader credentials to file");
return true; return true;
} }
bool KOReaderCredentialStore::loadFromFile() { bool KOReaderCredentialStore::loadFromFile() {
FsFile file; FsFile file;
if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) { if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) {
Serial.printf("[%lu] [KRS] No credentials file found\n", millis()); LOG_DBG("KRS", "No credentials file found");
return false; return false;
} }
@@ -73,7 +73,7 @@ bool KOReaderCredentialStore::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != KOREADER_FILE_VERSION) { if (version != KOREADER_FILE_VERSION) {
Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version); LOG_DBG("KRS", "Unknown file version: %u", version);
file.close(); file.close();
return false; return false;
} }
@@ -110,14 +110,14 @@ bool KOReaderCredentialStore::loadFromFile() {
} }
file.close(); file.close();
Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str()); LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", username.c_str());
return true; return true;
} }
void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) { void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) {
username = user; username = user;
password = pass; password = pass;
Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str()); LOG_DBG("KRS", "Set credentials for user: %s", user.c_str());
} }
std::string KOReaderCredentialStore::getMd5Password() const { std::string KOReaderCredentialStore::getMd5Password() const {
@@ -140,12 +140,12 @@ void KOReaderCredentialStore::clearCredentials() {
username.clear(); username.clear();
password.clear(); password.clear();
saveToFile(); saveToFile();
Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis()); LOG_DBG("KRS", "Cleared KOReader credentials");
} }
void KOReaderCredentialStore::setServerUrl(const std::string& url) { void KOReaderCredentialStore::setServerUrl(const std::string& url) {
serverUrl = url; serverUrl = url;
Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str()); LOG_DBG("KRS", "Set server URL: %s", url.empty() ? "(default)" : url.c_str());
} }
std::string KOReaderCredentialStore::getBaseUrl() const { std::string KOReaderCredentialStore::getBaseUrl() const {
@@ -163,6 +163,5 @@ std::string KOReaderCredentialStore::getBaseUrl() const {
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) { void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
matchMethod = method; matchMethod = method;
Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(), LOG_DBG("KRS", "Set match method: %s", method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
} }

View File

@@ -1,7 +1,7 @@
#include "KOReaderDocumentId.h" #include "KOReaderDocumentId.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <MD5Builder.h> #include <MD5Builder.h>
namespace { namespace {
@@ -27,7 +27,7 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat
md5.calculate(); md5.calculate();
std::string result = md5.toString().c_str(); std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str()); LOG_DBG("KODoc", "Filename hash: %s (from '%s')", result.c_str(), filename.c_str());
return result; return result;
} }
@@ -44,12 +44,12 @@ size_t KOReaderDocumentId::getOffset(int i) {
std::string KOReaderDocumentId::calculate(const std::string& filePath) { std::string KOReaderDocumentId::calculate(const std::string& filePath) {
FsFile file; FsFile file;
if (!Storage.openFileForRead("KODoc", filePath, file)) { if (!Storage.openFileForRead("KODoc", filePath, file)) {
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str()); LOG_DBG("KODoc", "Failed to open file: %s", filePath.c_str());
return ""; return "";
} }
const size_t fileSize = file.fileSize(); const size_t fileSize = file.fileSize();
Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize); LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
// Initialize MD5 builder // Initialize MD5 builder
MD5Builder md5; MD5Builder md5;
@@ -70,7 +70,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
// Seek to offset // Seek to offset
if (!file.seekSet(offset)) { if (!file.seekSet(offset)) {
Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset); LOG_DBG("KODoc", "Failed to seek to offset %zu", offset);
continue; continue;
} }
@@ -90,7 +90,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
md5.calculate(); md5.calculate();
std::string result = md5.toString().c_str(); std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead); LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
return result; return result;
} }

View File

@@ -2,7 +2,7 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
@@ -30,12 +30,12 @@ bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0;
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth"; std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Authenticating: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -53,7 +53,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
const int httpCode = http.GET(); const int httpCode = http.GET();
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Auth response: %d", httpCode);
if (httpCode == 200) { if (httpCode == 200) {
return OK; return OK;
@@ -68,12 +68,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) { KOReaderProgress& outProgress) {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash; std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Getting progress: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -99,7 +99,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
const DeserializationError error = deserializeJson(doc, responseBody); const DeserializationError error = deserializeJson(doc, responseBody);
if (error) { if (error) {
Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str()); LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str());
return JSON_ERROR; return JSON_ERROR;
} }
@@ -110,14 +110,13 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
outProgress.deviceId = doc["device_id"].as<std::string>(); outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>(); outProgress.timestamp = doc["timestamp"].as<int64_t>();
Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100, LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
outProgress.progress.c_str());
return OK; return OK;
} }
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Get progress response: %d", httpCode);
if (httpCode == 401) { if (httpCode == 401) {
return AUTH_FAILED; return AUTH_FAILED;
@@ -131,12 +130,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) { KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress"; std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Updating progress: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -163,12 +162,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
std::string body; std::string body;
serializeJson(doc, body); serializeJson(doc, body);
Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str()); LOG_DBG("KOSync", "Request body: %s", body.c_str());
const int httpCode = http.PUT(body.c_str()); const int httpCode = http.PUT(body.c_str());
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Update progress response: %d", httpCode);
if (httpCode == 200 || httpCode == 202) { if (httpCode == 200 || httpCode == 202) {
return OK; return OK;

View File

@@ -1,6 +1,6 @@
#include "ProgressMapper.h" #include "ProgressMapper.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <cmath> #include <cmath>
@@ -23,8 +23,8 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex); const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown"; const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(), LOG_DBG("ProgressMapper", "CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s", chapterName.c_str(),
chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str()); pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
return result; return result;
} }
@@ -76,8 +76,8 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
} }
} }
Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(), LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber); koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
return result; return result;
} }

47
lib/Logging/Logging.cpp Normal file
View File

@@ -0,0 +1,47 @@
#include "Logging.h"
// Since logging can take a large amount of flash, we want to make the format string as short as possible.
// This logPrintf prepend the timestamp, level and origin to the user-provided message, so that the user only needs to
// provide the format string for the message itself.
void logPrintf(const char* level, const char* origin, const char* format, ...) {
if (!logSerial) {
return; // Serial not initialized, skip logging
}
va_list args;
va_start(args, format);
char buf[256];
char* c = buf;
// add the timestamp
{
unsigned long ms = millis();
int len = snprintf(c, sizeof(buf), "[%lu] ", ms);
if (len < 0) {
return; // encoding error, skip logging
}
c += len;
}
// add the level
{
const char* p = level;
size_t remaining = sizeof(buf) - (c - buf);
while (*p && remaining > 1) {
*c++ = *p++;
remaining--;
}
if (remaining > 1) {
*c++ = ' ';
}
}
// add the origin
{
int len = snprintf(c, sizeof(buf) - (c - buf), "[%s] ", origin);
if (len < 0) {
return; // encoding error, skip logging
}
c += len;
}
// add the user message
vsnprintf(c, sizeof(buf) - (c - buf), format, args);
va_end(args);
logSerial.print(buf);
}

71
lib/Logging/Logging.h Normal file
View File

@@ -0,0 +1,71 @@
#pragma once
#include <HardwareSerial.h>
/*
Define ENABLE_SERIAL_LOG to enable logging
Can be set in platformio.ini build_flags or as a compile definition
Define LOG_LEVEL to control log verbosity:
0 = ERR only
1 = ERR + INF
2 = ERR + INF + DBG
If not defined, defaults to 0
If you have a legitimate need for raw Serial access (e.g., binary data,
special formatting), use the underlying logSerial object directly:
logSerial.printf("Special case: %d\n", value);
logSerial.write(binaryData, length);
The logSerial reference (defined below) points to the real Serial object and
won't trigger deprecation warnings.
*/
#ifndef LOG_LEVEL
#define LOG_LEVEL 0
#endif
static HWCDC& logSerial = Serial;
void logPrintf(const char* level, const char* origin, const char* format, ...);
#ifdef ENABLE_SERIAL_LOG
#if LOG_LEVEL >= 0
#define LOG_ERR(origin, format, ...) logPrintf("[ERR]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_ERR(origin, format, ...)
#endif
#if LOG_LEVEL >= 1
#define LOG_INF(origin, format, ...) logPrintf("[INF]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_INF(origin, format, ...)
#endif
#if LOG_LEVEL >= 2
#define LOG_DBG(origin, format, ...) logPrintf("[DBG]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_DBG(origin, format, ...)
#endif
#else
#define LOG_DBG(origin, format, ...)
#define LOG_ERR(origin, format, ...)
#define LOG_INF(origin, format, ...)
#endif
class MySerialImpl : public Print {
public:
void begin(unsigned long baud) { logSerial.begin(baud); }
// Support boolean conversion for compatibility with code like:
// if (Serial) or while (!Serial)
operator bool() const { return logSerial; }
__attribute__((deprecated("Use LOG_* macro instead"))) size_t printf(const char* format, ...);
size_t write(uint8_t b) override;
size_t write(const uint8_t* buffer, size_t size) override;
void flush() override;
static MySerialImpl instance;
};
#define Serial MySerialImpl::instance

View File

@@ -1,6 +1,6 @@
#include "OpdsParser.h" #include "OpdsParser.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <cstring> #include <cstring>
@@ -8,7 +8,7 @@ OpdsParser::OpdsParser() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis()); LOG_DBG("OPDS", "Couldn't allocate memory for parser");
} }
} }
@@ -42,7 +42,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
void* const buf = XML_GetBuffer(parser, chunkSize); void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) { if (!buf) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
return length; return length;
@@ -53,8 +53,8 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
return length; return length;

View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
// Book icon: 48x48, 1-bit packed (MSB first)
// 0 = black, 1 = white (same format as Logo120.h)
static constexpr int BOOK_ICON_WIDTH = 48;
static constexpr int BOOK_ICON_HEIGHT = 48;
static const uint8_t BookIcon[] = {
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00,
0x00, 0x1f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x00, 0x01, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x01,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1c, 0x00, 0x00, 0x1f, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f,
0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
};

View File

@@ -0,0 +1,480 @@
#include "PlaceholderCoverGenerator.h"
#include <EpdFont.h>
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
#include <algorithm>
#include <cstring>
#include <vector>
// Include the UI fonts directly for self-contained placeholder rendering.
// These are 1-bit bitmap fonts compiled from Ubuntu TTF.
#include "builtinFonts/ubuntu_10_regular.h"
#include "builtinFonts/ubuntu_12_bold.h"
// Book icon bitmap (48x48 1-bit, generated by scripts/generate_book_icon.py)
#include "BookIcon.h"
namespace {
// BMP writing helpers (same format as JpegToBmpConverter)
inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
}
inline void write32(Print& out, const uint32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
inline void write32Signed(Print& out, const int32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
const int bytesPerRow = (width + 31) / 32 * 4;
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize;
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0); // Reserved
write32(bmpOut, 62); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative = top-down
write16(bmpOut, 1); // Color planes
write16(bmpOut, 1); // Bits per pixel
write32(bmpOut, 0); // BI_RGB
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter
write32(bmpOut, 2835); // yPixelsPerMeter
write32(bmpOut, 2); // colorsUsed
write32(bmpOut, 2); // colorsImportant
// Palette: index 0 = black, index 1 = white
const uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Black
0xFF, 0xFF, 0xFF, 0x00 // White
};
for (const uint8_t b : palette) {
bmpOut.write(b);
}
}
/// 1-bit pixel buffer that can render text, icons, and shapes, then write as BMP.
class PixelBuffer {
public:
PixelBuffer(int width, int height) : width(width), height(height) {
bytesPerRow = (width + 31) / 32 * 4;
bufferSize = bytesPerRow * height;
buffer = static_cast<uint8_t*>(malloc(bufferSize));
if (buffer) {
memset(buffer, 0xFF, bufferSize); // White background
}
}
~PixelBuffer() {
if (buffer) {
free(buffer);
}
}
bool isValid() const { return buffer != nullptr; }
/// Set a pixel to black.
void setBlack(int x, int y) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const int byteIndex = y * bytesPerRow + x / 8;
const uint8_t bitMask = 0x80 >> (x % 8);
buffer[byteIndex] &= ~bitMask;
}
/// Set a scaled "pixel" (scale x scale block) to black.
void setBlackScaled(int x, int y, int scale) {
for (int dy = 0; dy < scale; dy++) {
for (int dx = 0; dx < scale; dx++) {
setBlack(x + dx, y + dy);
}
}
}
/// Draw a filled rectangle in black.
void fillRect(int x, int y, int w, int h) {
for (int row = y; row < y + h && row < height; row++) {
for (int col = x; col < x + w && col < width; col++) {
setBlack(col, row);
}
}
}
/// Draw a rectangular border in black.
void drawBorder(int x, int y, int w, int h, int thickness) {
fillRect(x, y, w, thickness); // Top
fillRect(x, y + h - thickness, w, thickness); // Bottom
fillRect(x, y, thickness, h); // Left
fillRect(x + w - thickness, y, thickness, h); // Right
}
/// Draw a horizontal line in black with configurable thickness.
void drawHLine(int x, int y, int length, int thickness = 1) {
fillRect(x, y, length, thickness);
}
/// Render a single glyph at (cursorX, baselineY) with integer scaling. Returns advance in X (scaled).
int renderGlyph(const EpdFontData* font, uint32_t codepoint, int cursorX, int baselineY, int scale = 1) {
const EpdFont fontObj(font);
const EpdGlyph* glyph = fontObj.getGlyph(codepoint);
if (!glyph) {
glyph = fontObj.getGlyph(REPLACEMENT_GLYPH);
}
if (!glyph) {
return 0;
}
const uint8_t* bitmap = &font->bitmap[glyph->dataOffset];
const int glyphW = glyph->width;
const int glyphH = glyph->height;
for (int gy = 0; gy < glyphH; gy++) {
const int screenY = baselineY - glyph->top * scale + gy * scale;
for (int gx = 0; gx < glyphW; gx++) {
const int pixelPos = gy * glyphW + gx;
const int screenX = cursorX + glyph->left * scale + gx * scale;
bool isSet = false;
if (font->is2Bit) {
const uint8_t byte = bitmap[pixelPos / 4];
const uint8_t bitIndex = (3 - pixelPos % 4) * 2;
const uint8_t val = 3 - ((byte >> bitIndex) & 0x3);
isSet = (val < 3);
} else {
const uint8_t byte = bitmap[pixelPos / 8];
const uint8_t bitIndex = 7 - (pixelPos % 8);
isSet = ((byte >> bitIndex) & 1);
}
if (isSet) {
setBlackScaled(screenX, screenY, scale);
}
}
}
return glyph->advanceX * scale;
}
/// Render a UTF-8 string at (x, y) where y is the top of the text line, with integer scaling.
void drawText(const EpdFontData* font, int x, int y, const char* text, int scale = 1) {
const int baselineY = y + font->ascender * scale;
int cursorX = x;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
cursorX += renderGlyph(font, cp, cursorX, baselineY, scale);
}
}
/// Draw a 1-bit icon bitmap (MSB first, 0=black, 1=white) with integer scaling.
void drawIcon(const uint8_t* icon, int iconW, int iconH, int x, int y, int scale = 1) {
const int bytesPerIconRow = iconW / 8;
for (int iy = 0; iy < iconH; iy++) {
for (int ix = 0; ix < iconW; ix++) {
const int byteIdx = iy * bytesPerIconRow + ix / 8;
const uint8_t bitMask = 0x80 >> (ix % 8);
// In the icon data: 0 = black (drawn), 1 = white (skip)
if (!(icon[byteIdx] & bitMask)) {
const int sx = x + ix * scale;
const int sy = y + iy * scale;
setBlackScaled(sx, sy, scale);
}
}
}
}
/// Write the pixel buffer to a file as a 1-bit BMP.
bool writeBmp(Print& out) const {
if (!buffer) return false;
writeBmpHeader1bit(out, width, height);
out.write(buffer, bufferSize);
return true;
}
int getWidth() const { return width; }
int getHeight() const { return height; }
private:
int width;
int height;
int bytesPerRow;
size_t bufferSize;
uint8_t* buffer;
};
/// Measure the width of a UTF-8 string in pixels (at 1x scale).
int measureTextWidth(const EpdFontData* font, const char* text) {
const EpdFont fontObj(font);
int w = 0, h = 0;
fontObj.getTextDimensions(text, &w, &h);
return w;
}
/// Get the advance width of a single character.
int getCharAdvance(const EpdFontData* font, uint32_t cp) {
const EpdFont fontObj(font);
const EpdGlyph* glyph = fontObj.getGlyph(cp);
if (!glyph) return 0;
return glyph->advanceX;
}
/// Split a string into words (splitting on spaces).
std::vector<std::string> splitWords(const std::string& text) {
std::vector<std::string> words;
std::string current;
for (size_t i = 0; i < text.size(); i++) {
if (text[i] == ' ') {
if (!current.empty()) {
words.push_back(current);
current.clear();
}
} else {
current += text[i];
}
}
if (!current.empty()) {
words.push_back(current);
}
return words;
}
/// Word-wrap text into lines that fit within maxWidth pixels at the given scale.
std::vector<std::string> wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
std::vector<std::string> lines;
const auto words = splitWords(text);
if (words.empty()) return lines;
const int spaceWidth = getCharAdvance(font, ' ') * scale;
std::string currentLine;
int currentWidth = 0;
for (const auto& word : words) {
const int wordWidth = measureTextWidth(font, word.c_str()) * scale;
if (currentLine.empty()) {
currentLine = word;
currentWidth = wordWidth;
} else if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
currentLine += " " + word;
currentWidth += spaceWidth + wordWidth;
} else {
lines.push_back(currentLine);
currentLine = word;
currentWidth = wordWidth;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
/// Truncate a string with "..." if it exceeds maxWidth pixels at the given scale.
std::string truncateText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
if (measureTextWidth(font, text.c_str()) * scale <= maxWidth) {
return text;
}
std::string truncated = text;
const char* ellipsis = "...";
const int ellipsisWidth = measureTextWidth(font, ellipsis) * scale;
while (!truncated.empty()) {
utf8RemoveLastChar(truncated);
if (measureTextWidth(font, truncated.c_str()) * scale + ellipsisWidth <= maxWidth) {
return truncated + ellipsis;
}
}
return ellipsis;
}
} // namespace
bool PlaceholderCoverGenerator::generate(const std::string& outputPath, const std::string& title,
const std::string& author, int width, int height) {
LOG_DBG("PHC", "Generating placeholder cover %dx%d: \"%s\" by \"%s\"", width, height, title.c_str(), author.c_str());
const EpdFontData* titleFont = &ubuntu_12_bold;
const EpdFontData* authorFont = &ubuntu_10_regular;
PixelBuffer buf(width, height);
if (!buf.isValid()) {
LOG_ERR("PHC", "Failed to allocate %dx%d pixel buffer (%d bytes)", width, height,
(width + 31) / 32 * 4 * height);
return false;
}
// Proportional layout constants based on cover dimensions.
// The device bezel covers ~2-3px on each edge, so we pad inward from the edge.
const int edgePadding = std::max(3, width / 48); // ~10px at 480w, ~3px at 136w
const int borderWidth = std::max(2, width / 96); // ~5px at 480w, ~2px at 136w
const int innerPadding = std::max(4, width / 32); // ~15px at 480w, ~4px at 136w
// Text scaling: 2x for full-size covers, 1x for thumbnails
const int titleScale = (height >= 600) ? 2 : 1;
const int authorScale = (height >= 600) ? 2 : 1; // Author also larger on full covers
// Icon: 2x for full cover, 1x for medium thumb, skip for small
const int iconScale = (height >= 600) ? 2 : (height >= 350 ? 1 : 0);
// Draw border inset from edge
buf.drawBorder(edgePadding, edgePadding, width - 2 * edgePadding, height - 2 * edgePadding, borderWidth);
// Content area (inside border + inner padding)
const int contentX = edgePadding + borderWidth + innerPadding;
const int contentY = edgePadding + borderWidth + innerPadding;
const int contentW = width - 2 * contentX;
const int contentH = height - 2 * contentY;
if (contentW <= 0 || contentH <= 0) {
LOG_ERR("PHC", "Cover too small for content (%dx%d)", width, height);
FsFile file;
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
return false;
}
buf.writeBmp(file);
file.close();
return true;
}
// --- Layout zones ---
// Title zone: top 2/3 of content area (icon + title)
// Author zone: bottom 1/3 of content area
const int titleZoneH = contentH * 2 / 3;
const int authorZoneH = contentH - titleZoneH;
const int authorZoneY = contentY + titleZoneH;
// --- Separator line at the zone boundary ---
const int separatorWidth = contentW / 3;
const int separatorX = contentX + (contentW - separatorWidth) / 2;
buf.drawHLine(separatorX, authorZoneY, separatorWidth);
// --- Icon dimensions (needed for title text wrapping) ---
const int iconW = (iconScale > 0) ? BOOK_ICON_WIDTH * iconScale : 0;
const int iconGap = (iconScale > 0) ? std::max(8, width / 40) : 0; // Gap between icon and title text
const int titleTextW = contentW - iconW - iconGap; // Title wraps in narrower area beside icon
// --- Prepare title text (wraps within the area to the right of the icon) ---
const std::string displayTitle = title.empty() ? "Untitled" : title;
auto titleLines = wrapText(titleFont, displayTitle, titleTextW, titleScale);
constexpr int MAX_TITLE_LINES = 5;
if (static_cast<int>(titleLines.size()) > MAX_TITLE_LINES) {
titleLines.resize(MAX_TITLE_LINES);
titleLines.back() = truncateText(titleFont, titleLines.back(), titleTextW, titleScale);
}
// --- Prepare author text (multi-line, max 3 lines) ---
std::vector<std::string> authorLines;
if (!author.empty()) {
authorLines = wrapText(authorFont, author, contentW, authorScale);
constexpr int MAX_AUTHOR_LINES = 3;
if (static_cast<int>(authorLines.size()) > MAX_AUTHOR_LINES) {
authorLines.resize(MAX_AUTHOR_LINES);
authorLines.back() = truncateText(authorFont, authorLines.back(), contentW, authorScale);
}
}
// --- Calculate title zone layout (icon LEFT of title) ---
// Tighter line spacing so 2-3 title lines fit within the icon height
const int titleLineH = titleFont->advanceY * titleScale * 3 / 4;
const int iconH = (iconScale > 0) ? BOOK_ICON_HEIGHT * iconScale : 0;
const int numTitleLines = static_cast<int>(titleLines.size());
// Visual height: distance from top of first line to bottom of last line's glyphs.
// Use ascender (not full advanceY) for the last line since trailing line-gap isn't visible.
const int titleVisualH = (numTitleLines > 0)
? (numTitleLines - 1) * titleLineH + titleFont->ascender * titleScale
: 0;
const int titleBlockH = std::max(iconH, titleVisualH); // Taller of icon or text
int titleStartY = contentY + (titleZoneH - titleBlockH) / 2;
if (titleStartY < contentY) {
titleStartY = contentY;
}
// If title fits within icon height, center it vertically against the icon.
// Otherwise top-align so extra lines overflow below.
const int iconY = titleStartY;
const int titleTextY = (iconH > 0 && titleVisualH <= iconH)
? titleStartY + (iconH - titleVisualH) / 2
: titleStartY;
// --- Horizontal centering: measure the widest title line, then center icon+gap+text block ---
int maxTitleLineW = 0;
for (const auto& line : titleLines) {
const int w = measureTextWidth(titleFont, line.c_str()) * titleScale;
if (w > maxTitleLineW) maxTitleLineW = w;
}
const int titleBlockW = iconW + iconGap + maxTitleLineW;
const int titleBlockX = contentX + (contentW - titleBlockW) / 2;
// --- Draw icon ---
if (iconScale > 0) {
buf.drawIcon(BookIcon, BOOK_ICON_WIDTH, BOOK_ICON_HEIGHT, titleBlockX, iconY, iconScale);
}
// --- Draw title lines (to the right of the icon) ---
const int titleTextX = titleBlockX + iconW + iconGap;
int currentY = titleTextY;
for (const auto& line : titleLines) {
buf.drawText(titleFont, titleTextX, currentY, line.c_str(), titleScale);
currentY += titleLineH;
}
// --- Draw author lines (centered vertically in bottom 1/3, centered horizontally) ---
if (!authorLines.empty()) {
const int authorLineH = authorFont->advanceY * authorScale;
const int authorBlockH = static_cast<int>(authorLines.size()) * authorLineH;
int authorStartY = authorZoneY + (authorZoneH - authorBlockH) / 2;
if (authorStartY < authorZoneY + 4) {
authorStartY = authorZoneY + 4; // Small gap below separator
}
for (const auto& line : authorLines) {
const int lineWidth = measureTextWidth(authorFont, line.c_str()) * authorScale;
const int lineX = contentX + (contentW - lineWidth) / 2;
buf.drawText(authorFont, lineX, authorStartY, line.c_str(), authorScale);
authorStartY += authorLineH;
}
}
// --- Write to file ---
FsFile file;
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
LOG_ERR("PHC", "Failed to open output file: %s", outputPath.c_str());
return false;
}
const bool success = buf.writeBmp(file);
file.close();
if (success) {
LOG_DBG("PHC", "Placeholder cover written to %s", outputPath.c_str());
} else {
LOG_ERR("PHC", "Failed to write placeholder BMP");
Storage.remove(outputPath.c_str());
}
return success;
}

View File

@@ -0,0 +1,14 @@
#pragma once
#include <string>
/// Generates simple 1-bit BMP placeholder covers with title/author text
/// for books that have no embedded cover image.
class PlaceholderCoverGenerator {
public:
/// Generate a placeholder cover BMP with title and author text.
/// The BMP is written to outputPath as a 1-bit black-and-white image.
/// Returns true if the file was written successfully.
static bool generate(const std::string& outputPath, const std::string& title, const std::string& author, int width,
int height);
};

View File

@@ -2,6 +2,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <Logging.h>
Txt::Txt(std::string path, std::string cacheBasePath) Txt::Txt(std::string path, std::string cacheBasePath)
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
@@ -16,13 +17,13 @@ bool Txt::load() {
} }
if (!Storage.exists(filepath.c_str())) { if (!Storage.exists(filepath.c_str())) {
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); LOG_ERR("TXT", "File does not exist: %s", filepath.c_str());
return false; return false;
} }
FsFile file; FsFile file;
if (!Storage.openFileForRead("TXT", filepath, file)) { if (!Storage.openFileForRead("TXT", filepath, file)) {
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); LOG_ERR("TXT", "Failed to open file: %s", filepath.c_str());
return false; return false;
} }
@@ -30,7 +31,7 @@ bool Txt::load() {
file.close(); file.close();
loaded = true; loaded = true;
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); LOG_DBG("TXT", "Loaded TXT file: %s (%zu bytes)", filepath.c_str(), fileSize);
return true; return true;
} }
@@ -74,7 +75,7 @@ std::string Txt::findCoverImage() const {
for (const auto& ext : extensions) { for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + baseName + ext; std::string coverPath = folder + "/" + baseName + ext;
if (Storage.exists(coverPath.c_str())) { if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); LOG_DBG("TXT", "Found matching cover image: %s", coverPath.c_str());
return coverPath; return coverPath;
} }
} }
@@ -85,7 +86,7 @@ std::string Txt::findCoverImage() const {
for (const auto& ext : extensions) { for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + std::string(name) + ext; std::string coverPath = folder + "/" + std::string(name) + ext;
if (Storage.exists(coverPath.c_str())) { if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); LOG_DBG("TXT", "Found fallback cover image: %s", coverPath.c_str());
return coverPath; return coverPath;
} }
} }
@@ -96,6 +97,9 @@ std::string Txt::findCoverImage() const {
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Txt::generateCoverBmp() const { bool Txt::generateCoverBmp() const {
// Already generated, return true // Already generated, return true
if (Storage.exists(getCoverBmpPath().c_str())) { if (Storage.exists(getCoverBmpPath().c_str())) {
@@ -104,7 +108,7 @@ bool Txt::generateCoverBmp() const {
std::string coverImagePath = findCoverImage(); std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) { if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); LOG_DBG("TXT", "No cover image found for TXT file");
return false; return false;
} }
@@ -120,7 +124,7 @@ bool Txt::generateCoverBmp() const {
if (isBmp) { if (isBmp) {
// Copy BMP file to cache // Copy BMP file to cache
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); LOG_DBG("TXT", "Copying BMP cover image to cache");
FsFile src, dst; FsFile src, dst;
if (!Storage.openFileForRead("TXT", coverImagePath, src)) { if (!Storage.openFileForRead("TXT", coverImagePath, src)) {
return false; return false;
@@ -136,13 +140,13 @@ bool Txt::generateCoverBmp() const {
} }
src.close(); src.close();
dst.close(); dst.close();
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); LOG_DBG("TXT", "Copied BMP cover to cache");
return true; return true;
} }
if (isJpg) { if (isJpg) {
// Convert JPG/JPEG to BMP (same approach as Epub) // Convert JPG/JPEG to BMP (same approach as Epub)
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); LOG_DBG("TXT", "Generating BMP from JPG cover image");
FsFile coverJpg, coverBmp; FsFile coverJpg, coverBmp;
if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) { if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false; return false;
@@ -156,16 +160,16 @@ bool Txt::generateCoverBmp() const {
coverBmp.close(); coverBmp.close();
if (!success) { if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); LOG_ERR("TXT", "Failed to generate BMP from JPG cover image");
Storage.remove(getCoverBmpPath().c_str()); Storage.remove(getCoverBmpPath().c_str());
} else { } else {
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); LOG_DBG("TXT", "Generated BMP from JPG cover image");
} }
return success; return success;
} }
// PNG files are not supported (would need a PNG decoder) // PNG files are not supported (would need a PNG decoder)
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG)");
return false; return false;
} }

View File

@@ -28,6 +28,10 @@ class Txt {
[[nodiscard]] bool generateCoverBmp() const; [[nodiscard]] bool generateCoverBmp() const;
[[nodiscard]] std::string findCoverImage() const; [[nodiscard]] std::string findCoverImage() const;
// Thumbnail paths (matching Epub/Xtc pattern for home screen covers)
[[nodiscard]] std::string getThumbBmpPath() const;
[[nodiscard]] std::string getThumbBmpPath(int height) const;
// Read content from file // Read content from file
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
}; };

View File

@@ -8,10 +8,10 @@
#include "Xtc.h" #include "Xtc.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
bool Xtc::load() { bool Xtc::load() {
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str()); LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
// Initialize parser // Initialize parser
parser.reset(new xtc::XtcParser()); parser.reset(new xtc::XtcParser());
@@ -19,28 +19,28 @@ bool Xtc::load() {
// Open XTC file // Open XTC file
xtc::XtcError err = parser->open(filepath.c_str()); xtc::XtcError err = parser->open(filepath.c_str());
if (err != xtc::XtcError::OK) { if (err != xtc::XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err)); LOG_ERR("XTC", "Failed to load: %s", xtc::errorToString(err));
parser.reset(); parser.reset();
return false; return false;
} }
loaded = true; loaded = true;
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount()); LOG_DBG("XTC", "Loaded XTC: %s (%lu pages)", filepath.c_str(), parser->getPageCount());
return true; return true;
} }
bool Xtc::clearCache() const { bool Xtc::clearCache() const {
if (!Storage.exists(cachePath.c_str())) { if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis()); LOG_DBG("XTC", "Cache does not exist, no action needed");
return true; return true;
} }
if (!Storage.removeDir(cachePath.c_str())) { if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis()); LOG_ERR("XTC", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis()); LOG_DBG("XTC", "Cache cleared successfully");
return true; return true;
} }
@@ -119,12 +119,12 @@ bool Xtc::generateCoverBmp() const {
} }
if (!loaded || !parser) { if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis()); LOG_ERR("XTC", "Cannot generate cover BMP, file not loaded");
return false; return false;
} }
if (parser->getPageCount() == 0) { if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); LOG_ERR("XTC", "No pages in XTC file");
return false; return false;
} }
@@ -134,7 +134,7 @@ bool Xtc::generateCoverBmp() const {
// Get first page info for cover // Get first page info for cover
xtc::PageInfo pageInfo; xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) { if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); LOG_DBG("XTC", "Failed to get first page info");
return false; return false;
} }
@@ -152,14 +152,14 @@ bool Xtc::generateCoverBmp() const {
} }
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
return false; return false;
} }
// Load first page (cover) // Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize); size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis()); LOG_ERR("XTC", "Failed to load cover page");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -167,7 +167,7 @@ bool Xtc::generateCoverBmp() const {
// Create BMP file // Create BMP file
FsFile coverBmp; FsFile coverBmp;
if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis()); LOG_DBG("XTC", "Failed to create cover BMP file");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -297,7 +297,7 @@ bool Xtc::generateCoverBmp() const {
coverBmp.close(); coverBmp.close();
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str()); LOG_DBG("XTC", "Generated cover BMP: %s", getCoverBmpPath().c_str());
return true; return true;
} }
@@ -311,12 +311,12 @@ bool Xtc::generateThumbBmp(int height) const {
} }
if (!loaded || !parser) { if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); LOG_ERR("XTC", "Cannot generate thumb BMP, file not loaded");
return false; return false;
} }
if (parser->getPageCount() == 0) { if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); LOG_ERR("XTC", "No pages in XTC file");
return false; return false;
} }
@@ -326,7 +326,7 @@ bool Xtc::generateThumbBmp(int height) const {
// Get first page info for cover // Get first page info for cover
xtc::PageInfo pageInfo; xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) { if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); LOG_DBG("XTC", "Failed to get first page info");
return false; return false;
} }
@@ -359,7 +359,7 @@ bool Xtc::generateThumbBmp(int height) const {
} }
src.close(); src.close();
} }
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); LOG_DBG("XTC", "Copied cover to thumb (no scaling needed)");
return Storage.exists(getThumbBmpPath(height).c_str()); return Storage.exists(getThumbBmpPath(height).c_str());
} }
return false; return false;
@@ -368,8 +368,8 @@ bool Xtc::generateThumbBmp(int height) const {
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale); uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale); uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth,
pageInfo.height, thumbWidth, thumbHeight, scale); thumbHeight, scale);
// Allocate buffer for page data // Allocate buffer for page data
size_t bitmapSize; size_t bitmapSize;
@@ -380,14 +380,14 @@ bool Xtc::generateThumbBmp(int height) const {
} }
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
return false; return false;
} }
// Load first page (cover) // Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize); size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); LOG_ERR("XTC", "Failed to load cover page for thumb");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -395,7 +395,7 @@ bool Xtc::generateThumbBmp(int height) const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp; FsFile thumbBmp;
if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); LOG_DBG("XTC", "Failed to create thumb BMP file");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -558,8 +558,7 @@ bool Xtc::generateThumbBmp(int height) const {
thumbBmp.close(); thumbBmp.close();
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str());
getThumbBmpPath(height).c_str());
return true; return true;
} }

View File

@@ -9,7 +9,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <cstring> #include <cstring>
@@ -42,7 +42,7 @@ XtcError XtcParser::open(const char* filepath) {
// Read header // Read header
m_lastError = readHeader(); m_lastError = readHeader();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read header: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -51,13 +51,13 @@ XtcError XtcParser::open(const char* filepath) {
if (m_header.hasMetadata) { if (m_header.hasMetadata) {
m_lastError = readTitle(); m_lastError = readTitle();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
m_lastError = readAuthor(); m_lastError = readAuthor();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -66,7 +66,7 @@ XtcError XtcParser::open(const char* filepath) {
// Read page table // Read page table
m_lastError = readPageTable(); m_lastError = readPageTable();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -74,14 +74,13 @@ XtcError XtcParser::open(const char* filepath) {
// Read chapters if present // Read chapters if present
m_lastError = readChapters(); m_lastError = readChapters();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read chapters: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
m_isOpen = true; m_isOpen = true;
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount, LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight);
m_defaultWidth, m_defaultHeight);
return XtcError::OK; return XtcError::OK;
} }
@@ -106,8 +105,7 @@ XtcError XtcParser::readHeader() {
// Verify magic number (accept both XTC and XTCH) // Verify magic number (accept both XTC and XTCH)
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) { if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic, LOG_DBG("XTC", "Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)", m_header.magic, XTC_MAGIC, XTCH_MAGIC);
XTC_MAGIC, XTCH_MAGIC);
return XtcError::INVALID_MAGIC; return XtcError::INVALID_MAGIC;
} }
@@ -120,7 +118,7 @@ XtcError XtcParser::readHeader() {
const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 || const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 ||
m_header.versionMajor == 0 && m_header.versionMinor == 1; m_header.versionMajor == 0 && m_header.versionMinor == 1;
if (!validVersion) { if (!validVersion) {
Serial.printf("[%lu] [XTC] Unsupported version: %u.%u\n", millis(), m_header.versionMajor, m_header.versionMinor); LOG_DBG("XTC", "Unsupported version: %u.%u", m_header.versionMajor, m_header.versionMinor);
return XtcError::INVALID_VERSION; return XtcError::INVALID_VERSION;
} }
@@ -129,9 +127,9 @@ XtcError XtcParser::readHeader() {
return XtcError::CORRUPTED_HEADER; return XtcError::CORRUPTED_HEADER;
} }
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic, LOG_DBG("XTC", "Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u", m_header.magic,
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor, (m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor,
m_header.pageCount, m_bitDepth); m_header.pageCount, m_bitDepth);
return XtcError::OK; return XtcError::OK;
} }
@@ -146,7 +144,7 @@ XtcError XtcParser::readTitle() {
m_file.read(titleBuf, sizeof(titleBuf) - 1); m_file.read(titleBuf, sizeof(titleBuf) - 1);
m_title = titleBuf; m_title = titleBuf;
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str()); LOG_DBG("XTC", "Title: %s", m_title.c_str());
return XtcError::OK; return XtcError::OK;
} }
@@ -161,19 +159,19 @@ XtcError XtcParser::readAuthor() {
m_file.read(authorBuf, sizeof(authorBuf) - 1); m_file.read(authorBuf, sizeof(authorBuf) - 1);
m_author = authorBuf; m_author = authorBuf;
Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str()); LOG_DBG("XTC", "Author: %s", m_author.c_str());
return XtcError::OK; return XtcError::OK;
} }
XtcError XtcParser::readPageTable() { XtcError XtcParser::readPageTable() {
if (m_header.pageTableOffset == 0) { if (m_header.pageTableOffset == 0) {
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis()); LOG_DBG("XTC", "Page table offset is 0, cannot read");
return XtcError::CORRUPTED_HEADER; return XtcError::CORRUPTED_HEADER;
} }
// Seek to page table // Seek to page table
if (!m_file.seek(m_header.pageTableOffset)) { if (!m_file.seek(m_header.pageTableOffset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset); LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset);
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
@@ -184,7 +182,7 @@ XtcError XtcParser::readPageTable() {
PageTableEntry entry; PageTableEntry entry;
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry)); size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
if (bytesRead != sizeof(PageTableEntry)) { if (bytesRead != sizeof(PageTableEntry)) {
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i); LOG_DBG("XTC", "Failed to read page table entry %u", i);
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
@@ -201,7 +199,7 @@ XtcError XtcParser::readPageTable() {
} }
} }
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount); LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount);
return XtcError::OK; return XtcError::OK;
} }
@@ -307,7 +305,7 @@ XtcError XtcParser::readChapters() {
} }
m_hasChapters = !m_chapters.empty(); m_hasChapters = !m_chapters.empty();
Serial.printf("[%lu] [XTC] Chapters: %u\n", millis(), static_cast<unsigned int>(m_chapters.size())); LOG_DBG("XTC", "Chapters: %u", static_cast<unsigned int>(m_chapters.size()));
return XtcError::OK; return XtcError::OK;
} }
@@ -334,7 +332,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Seek to page data // Seek to page data
if (!m_file.seek(page.offset)) { if (!m_file.seek(page.offset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset); LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
@@ -343,7 +341,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
XtgPageHeader pageHeader; XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader)); size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader)) { if (headerRead != sizeof(XtgPageHeader)) {
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex); LOG_DBG("XTC", "Failed to read page header for page %u", pageIndex);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
@@ -351,8 +349,8 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Verify page magic (XTG for 1-bit, XTH for 2-bit) // Verify page magic (XTG for 1-bit, XTH for 2-bit)
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC; const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (pageHeader.magic != expectedMagic) { if (pageHeader.magic != expectedMagic) {
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex, LOG_DBG("XTC", "Invalid page magic for page %u: 0x%08X (expected 0x%08X)", pageIndex, pageHeader.magic,
pageHeader.magic, expectedMagic); expectedMagic);
m_lastError = XtcError::INVALID_MAGIC; m_lastError = XtcError::INVALID_MAGIC;
return 0; return 0;
} }
@@ -370,7 +368,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Check buffer size // Check buffer size
if (bufferSize < bitmapSize) { if (bufferSize < bitmapSize) {
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize); LOG_DBG("XTC", "Buffer too small: need %u, have %u", bitmapSize, bufferSize);
m_lastError = XtcError::MEMORY_ERROR; m_lastError = XtcError::MEMORY_ERROR;
return 0; return 0;
} }
@@ -378,7 +376,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Read bitmap data // Read bitmap data
size_t bytesRead = m_file.read(buffer, bitmapSize); size_t bytesRead = m_file.read(buffer, bitmapSize);
if (bytesRead != bitmapSize) { if (bytesRead != bitmapSize) {
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead); LOG_DBG("XTC", "Page read error: expected %u, got %u", bitmapSize, bytesRead);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }

View File

@@ -1,7 +1,7 @@
#include "ZipFile.h" #include "ZipFile.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <miniz.h> #include <miniz.h>
#include <algorithm> #include <algorithm>
@@ -10,7 +10,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
// Setup inflator // Setup inflator
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for inflator");
return false; return false;
} }
memset(inflator, 0, sizeof(tinfl_decompressor)); memset(inflator, 0, sizeof(tinfl_decompressor));
@@ -23,7 +23,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
free(inflator); free(inflator);
if (status != TINFL_STATUS_DONE) { if (status != TINFL_STATUS_DONE) {
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status); LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
return false; return false;
} }
@@ -195,13 +195,13 @@ long ZipFile::getDataOffset(const FileStatSlim& fileStat) {
} }
if (read != localHeaderSize) { if (read != localHeaderSize) {
Serial.printf("[%lu] [ZIP] Something went wrong reading the local header\n", millis()); LOG_ERR("ZIP", "Something went wrong reading the local header");
return -1; return -1;
} }
if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) != if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) !=
0x04034b50 /* MZ_ZIP_LOCAL_DIR_HEADER_SIG */) { 0x04034b50 /* MZ_ZIP_LOCAL_DIR_HEADER_SIG */) {
Serial.printf("[%lu] [ZIP] Not a valid zip file header\n", millis()); LOG_ERR("ZIP", "Not a valid zip file header");
return -1; return -1;
} }
@@ -222,7 +222,7 @@ bool ZipFile::loadZipDetails() {
const size_t fileSize = file.size(); const size_t fileSize = file.size();
if (fileSize < 22) { if (fileSize < 22) {
Serial.printf("[%lu] [ZIP] File too small to be a valid zip\n", millis()); LOG_ERR("ZIP", "File too small to be a valid zip");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -234,7 +234,7 @@ bool ZipFile::loadZipDetails() {
const int scanRange = fileSize > 1024 ? 1024 : fileSize; const int scanRange = fileSize > 1024 ? 1024 : fileSize;
const auto buffer = static_cast<uint8_t*>(malloc(scanRange)); const auto buffer = static_cast<uint8_t*>(malloc(scanRange));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for EOCD scan buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -255,7 +255,7 @@ bool ZipFile::loadZipDetails() {
} }
if (foundOffset == -1) { if (foundOffset == -1) {
Serial.printf("[%lu] [ZIP] EOCD signature not found in zip file\n", millis()); LOG_ERR("ZIP", "EOCD signature not found in zip file");
free(buffer); free(buffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -407,7 +407,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize; const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize;
const auto data = static_cast<uint8_t*>(malloc(dataSize)); const auto data = static_cast<uint8_t*>(malloc(dataSize));
if (data == nullptr) { if (data == nullptr) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize); LOG_ERR("ZIP", "Failed to allocate memory for output buffer (%zu bytes)", dataSize);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -422,7 +422,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
} }
if (dataRead != inflatedDataSize) { if (dataRead != inflatedDataSize) {
Serial.printf("[%lu] [ZIP] Failed to read data\n", millis()); LOG_ERR("ZIP", "Failed to read data");
free(data); free(data);
return nullptr; return nullptr;
} }
@@ -432,7 +432,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
// Read out deflated content from file // Read out deflated content from file
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize)); const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
if (deflatedData == nullptr) { if (deflatedData == nullptr) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for decompression buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -445,7 +445,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
} }
if (dataRead != deflatedDataSize) { if (dataRead != deflatedDataSize) {
Serial.printf("[%lu] [ZIP] Failed to read data, expected %d got %d\n", millis(), deflatedDataSize, dataRead); LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
free(deflatedData); free(deflatedData);
free(data); free(data);
return nullptr; return nullptr;
@@ -455,14 +455,14 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
free(deflatedData); free(deflatedData);
if (!success) { if (!success) {
Serial.printf("[%lu] [ZIP] Failed to inflate file\n", millis()); LOG_ERR("ZIP", "Failed to inflate file");
free(data); free(data);
return nullptr; return nullptr;
} }
// Continue out of block with data set // Continue out of block with data set
} else { } else {
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis()); LOG_ERR("ZIP", "Unsupported compression method");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -498,7 +498,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// no deflation, just read content // no deflation, just read content
const auto buffer = static_cast<uint8_t*>(malloc(chunkSize)); const auto buffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -509,7 +509,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
while (remaining > 0) { while (remaining > 0) {
const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize); const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize);
if (dataRead == 0) { if (dataRead == 0) {
Serial.printf("[%lu] [ZIP] Could not read more bytes\n", millis()); LOG_ERR("ZIP", "Could not read more bytes");
free(buffer); free(buffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -532,7 +532,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// Setup inflator // Setup inflator
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for inflator");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -544,7 +544,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// Setup file read buffer // Setup file read buffer
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize)); const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!fileReadBuffer) { if (!fileReadBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
free(inflator); free(inflator);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -554,7 +554,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE)); const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) { if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for dictionary");
free(inflator); free(inflator);
free(fileReadBuffer); free(fileReadBuffer);
if (!wasOpen) { if (!wasOpen) {
@@ -605,7 +605,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
if (outBytes > 0) { if (outBytes > 0) {
processedOutputBytes += outBytes; processedOutputBytes += outBytes;
if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) { if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) {
Serial.printf("[%lu] [ZIP] Failed to write all output bytes to stream\n", millis()); LOG_ERR("ZIP", "Failed to write all output bytes to stream");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -619,7 +619,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
if (status < 0) { if (status < 0) {
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status); LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -630,8 +630,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
if (status == TINFL_STATUS_DONE) { if (status == TINFL_STATUS_DONE) {
Serial.printf("[%lu] [ZIP] Decompressed %d bytes into %d bytes\n", millis(), deflatedDataSize, LOG_ERR("ZIP", "Decompressed %d bytes into %d bytes", deflatedDataSize, inflatedDataSize);
inflatedDataSize);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -643,7 +642,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
// If we get here, EOF reached without TINFL_STATUS_DONE // If we get here, EOF reached without TINFL_STATUS_DONE
Serial.printf("[%lu] [ZIP] Unexpected EOF\n", millis()); LOG_ERR("ZIP", "Unexpected EOF");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -657,6 +656,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
close(); close();
} }
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis()); LOG_ERR("ZIP", "Unsupported compression method");
return false; return false;
} }

View File

@@ -1,5 +1,6 @@
#include "HalPowerManager.h" #include "HalPowerManager.h"
#include <Logging.h>
#include <esp_sleep.h> #include <esp_sleep.h>
#include "HalGPIO.h" #include "HalGPIO.h"
@@ -14,16 +15,16 @@ void HalPowerManager::setPowerSaving(bool enabled) {
return; // invalid state return; // invalid state
} }
if (enabled && !isLowPower) { if (enabled && !isLowPower) {
Serial.printf("[%lu] [PWR] Going to low-power mode\n", millis()); LOG_DBG("PWR", "Going to low-power mode");
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) { if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
Serial.printf("[%lu] [PWR] Failed to set low-power CPU frequency\n", millis()); LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", LOW_POWER_FREQ);
return; return;
} }
} }
if (!enabled && isLowPower) { if (!enabled && isLowPower) {
Serial.printf("[%lu] [PWR] Restoring normal CPU frequency\n", millis()); LOG_DBG("PWR", "Restoring normal CPU frequency");
if (!setCpuFrequencyMhz(normalFreq)) { if (!setCpuFrequencyMhz(normalFreq)) {
Serial.printf("[%lu] [PWR] Failed to restore normal CPU frequency\n", millis()); LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", normalFreq);
return; return;
} }
} }

View File

@@ -54,15 +54,47 @@ extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for development builds
[env:mod]
extends = base
extra_scripts =
${base.extra_scripts}
pre:scripts/inject_mod_version.py
build_flags =
${base.build_flags}
-DOMIT_OPENDYSLEXIC
-DOMIT_HYPH_DE
-DOMIT_HYPH_ES
-DOMIT_HYPH_FR
-DOMIT_HYPH_IT
-DOMIT_HYPH_RU
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for mod builds
[env:gh_release] [env:gh_release]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=0 ; Set log level to error for release builds
[env:gh_release_rc] [env:gh_release_rc]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
[env:slim]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
; serial output is disabled in slim builds to save space
-UENABLE_SERIAL_LOG

View File

@@ -1,5 +1,6 @@
import os import os
import re import re
import gzip
SRC_DIR = "src" SRC_DIR = "src"
@@ -40,12 +41,34 @@ for root, _, files in os.walk(SRC_DIR):
# minified = regex.sub("\g<1>", html_content) # minified = regex.sub("\g<1>", html_content)
minified = minify_html(html_content) minified = minify_html(html_content)
# Compress with gzip (compresslevel 9 is maximum compression)
# IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS)
compressed = gzip.compress(minified.encode('utf-8'), compresslevel=9)
base_name = f"{os.path.splitext(file)[0]}Html" base_name = f"{os.path.splitext(file)[0]}Html"
header_path = os.path.join(root, f"{base_name}.generated.h") header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h: with open(header_path, "w", encoding="utf-8") as h:
h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n") h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
h.write(f"#pragma once\n") h.write(f"#pragma once\n")
h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n') h.write(f"#include <cstddef>\n\n")
# Write the compressed data as a byte array
h.write(f"constexpr char {base_name}[] PROGMEM = {{\n")
# Write bytes in rows of 16
for i in range(0, len(compressed), 16):
chunk = compressed[i:i+16]
hex_values = ', '.join(f'0x{b:02x}' for b in chunk)
h.write(f" {hex_values},\n")
h.write(f"}};\n\n")
h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n")
h.write(f"constexpr size_t {base_name}OriginalSize = {len(minified)};\n")
print(f"Generated: {header_path}") print(f"Generated: {header_path}")
print(f" Original: {len(html_content)} bytes")
print(f" Minified: {len(minified)} bytes ({100*len(minified)/len(html_content):.1f}%)")
print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(html_content):.1f}%)")

View File

@@ -2,30 +2,57 @@
""" """
ESP32 Serial Monitor with Memory Graph ESP32 Serial Monitor with Memory Graph
This script provides a real-time serial monitor for ESP32 devices with This script provides a comprehensive real-time serial monitor for ESP32 devices with
integrated memory usage graphing capabilities. It reads serial output, integrated memory usage graphing capabilities. It reads serial output, parses memory
parses memory information, and displays it in both console and graphical form. information, and displays it in both console and graphical form.
Features:
- Real-time serial output monitoring with color-coded log levels
- Interactive memory usage graphing with matplotlib
- Command input interface for sending commands to the ESP32 device
- Screenshot capture and processing (1-bit black/white format)
- Graceful shutdown handling with Ctrl-C signal processing
- Configurable filtering and suppression of log messages
- Thread-safe operation with coordinated shutdown events
Usage:
python debugging_monitor.py [port] [options]
The script will open a matplotlib window showing memory usage over time and provide
an interactive command prompt for sending commands to the device. Press Ctrl-C or
close the graph window to exit gracefully.
""" """
import sys from __future__ import annotations
import argparse import argparse
import glob
import platform
import re import re
import signal
import sys
import threading import threading
from datetime import datetime
from collections import deque from collections import deque
from datetime import datetime
# Try to import potentially missing packages # Try to import potentially missing packages
PACKAGE_MAPPING: dict[str, str] = { PACKAGE_MAPPING: dict[str, str] = {
"serial": "pyserial", "serial": "pyserial",
"colorama": "colorama", "colorama": "colorama",
"matplotlib": "matplotlib", "matplotlib": "matplotlib",
"PIL": "Pillow",
} }
try: try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import serial
from colorama import Fore, Style, init
from matplotlib import animation from matplotlib import animation
try:
from PIL import Image
except ImportError:
Image = None
except ImportError as e: except ImportError as e:
ERROR_MSG = str(e).lower() ERROR_MSG = str(e).lower()
missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG] missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG]
@@ -53,6 +80,9 @@ free_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
total_mem_data: deque[float] = deque(maxlen=MAX_POINTS) total_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
data_lock: threading.Lock = threading.Lock() # Prevent reading while writing data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
# Global shutdown flag
shutdown_event = threading.Event()
# Initialize colors # Initialize colors
init(autoreset=True) init(autoreset=True)
@@ -121,6 +151,15 @@ COLOR_KEYWORDS: dict[str, list[str]] = {
} }
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl-C) by setting the shutdown event."""
# frame parameter is required by signal handler signature but not used
del frame # Explicitly mark as unused to satisfy linters
print(f"\n{Fore.YELLOW}Received signal {signum}. Shutting down...{Style.RESET_ALL}")
shutdown_event.set()
plt.close("all")
# pylint: disable=R0912 # pylint: disable=R0912
def get_color_for_line(line: str) -> str: def get_color_for_line(line: str) -> str:
""" """
@@ -150,12 +189,13 @@ def parse_memory_line(line: str) -> tuple[int | None, int | None]:
return None, None return None, None
def serial_worker(port: str, baud: int, kwargs: dict[str, str]) -> None: def serial_worker(ser, kwargs: dict[str, str]) -> None:
""" """
Runs in a background thread. Handles reading serial, printing to console, Runs in a background thread. Handles reading serial data, printing to console,
and updating the data lists. updating memory usage data for graphing, and processing screenshot data.
Monitors the global shutdown event for graceful termination.
""" """
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}") print(f"{Fore.CYAN}--- Opening serial port ---{Style.RESET_ALL}")
filter_keyword = kwargs.get("filter", "").lower() filter_keyword = kwargs.get("filter", "").lower()
suppress = kwargs.get("suppress", "").lower() suppress = kwargs.get("suppress", "").lower()
if filter_keyword and suppress and filter_keyword == suppress: if filter_keyword and suppress and filter_keyword == suppress:
@@ -173,62 +213,107 @@ def serial_worker(port: str, baud: int, kwargs: dict[str, str]) -> None:
f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}" f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}"
) )
try: expecting_screenshot = False
ser = serial.Serial(port, baud, timeout=0.1) screenshot_size = 0
ser.dtr = False screenshot_data = b""
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
try: try:
while True: while not shutdown_event.is_set():
try: if expecting_screenshot:
raw_data = ser.readline().decode("utf-8", errors="replace") data = ser.read(screenshot_size - len(screenshot_data))
if not data:
if not raw_data:
continue continue
screenshot_data += data
if len(screenshot_data) == screenshot_size:
if Image:
img = Image.frombytes("1", (800, 480), screenshot_data)
# We need to rotate the image because the raw data is in landscape mode
img = img.transpose(Image.ROTATE_270)
img.save("screenshot.bmp")
print(
f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}"
)
else:
with open("screenshot.raw", "wb") as f:
f.write(screenshot_data)
print(
f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}"
)
expecting_screenshot = False
screenshot_data = b""
else:
try:
raw_data = ser.readline().decode("utf-8", errors="replace")
clean_line = raw_data.strip() if not raw_data:
if not clean_line: continue
continue
# Add PC timestamp clean_line = raw_data.strip()
pc_time = datetime.now().strftime("%H:%M:%S") if not clean_line:
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line) continue
# Check for Memory Line if clean_line.startswith("SCREENSHOT_START:"):
if "[MEM]" in formatted_line: screenshot_size = int(clean_line.split(":")[1])
free_val, total_val = parse_memory_line(formatted_line) expecting_screenshot = True
if free_val is not None and total_val is not None: continue
with data_lock: elif clean_line == "SCREENSHOT_END":
time_data.append(pc_time) continue # ignore
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except (OSError, UnicodeDecodeError): # Add PC timestamp
print(f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}") pc_time = datetime.now().strftime("%H:%M:%S")
break formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None and total_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except (OSError, UnicodeDecodeError):
print(
f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}"
)
break
except KeyboardInterrupt: except KeyboardInterrupt:
# If thread is killed violently (e.g. main exit), silence errors # If thread is killed violently (e.g. main exit), silence errors
pass pass
finally: finally:
if "ser" in locals() and ser.is_open: pass # ser closed in main
ser.close()
def input_worker(ser) -> None:
"""
Runs in a background thread. Handles user input to send commands to the ESP32 device.
Monitors the global shutdown event for graceful termination on Ctrl-C.
"""
while not shutdown_event.is_set():
try:
cmd = input("Command: ")
ser.write(f"CMD:{cmd}\n".encode())
except (EOFError, KeyboardInterrupt):
break
def update_graph(frame) -> list: # pylint: disable=unused-argument def update_graph(frame) -> list: # pylint: disable=unused-argument
""" """
Called by Matplotlib animation to redraw the chart. Called by Matplotlib animation to redraw the memory usage chart.
Monitors the global shutdown event and closes the plot when shutdown is requested.
""" """
if shutdown_event.is_set():
plt.close("all")
return []
with data_lock: with data_lock:
if not time_data: if not time_data:
return [] return []
@@ -262,24 +347,65 @@ def update_graph(frame) -> list: # pylint: disable=unused-argument
return [] return []
def get_auto_detected_port() -> list[str]:
"""
Attempts to auto-detect the serial port for the ESP32 device.
Returns a list of all detected ports.
If no suitable port is found, the list will be empty.
Darwin/Linux logic by jonasdiemer
"""
port_list = []
system = platform.system()
# Code for darwin (macOS), linux, and windows
if system in ("Darwin", "Linux"):
pattern = "/dev/tty.usbmodem*" if system == "Darwin" else "/dev/ttyACM*"
port_list = sorted(glob.glob(pattern))
elif system == "Windows":
from serial.tools import list_ports
# Be careful with this pattern list - it should be specific
# enough to avoid picking up unrelated devices, but broad enough
# to catch all common USB-serial adapters used with ESP32
# Caveat: localized versions of Windows may have different descriptions,
# so we also check for specific VID:PID (but that may not cover all clones)
pattern_list = ["CP210x", "CH340", "USB Serial"]
found_ports = list_ports.comports()
port_list = [
port.device
for port in found_ports
if any(pat in port.description for pat in pattern_list)
or port.hwid.startswith(
"USB VID:PID=303A:1001"
) # Add specific VID:PID for XTEINK X4
]
return port_list
def main() -> None: def main() -> None:
""" """
Main entry point for the ESP32 monitor application. Main entry point for the ESP32 monitor application.
Sets up argument parsing, starts serial monitoring thread, and initializes the memory graph.
Sets up argument parsing, initializes serial communication, starts background threads
for serial monitoring and command input, and launches the memory usage graph.
Implements graceful shutdown handling with signal processing for clean termination.
Features:
- Serial port monitoring with color-coded output
- Real-time memory usage graphing
- Interactive command interface
- Screenshot capture capability
- Graceful shutdown on Ctrl-C or window close
""" """
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph") parser = argparse.ArgumentParser(
if sys.platform.startswith("win"): description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface"
default_port = "COM8" )
elif sys.platform.startswith("darwin"):
default_port = "/dev/cu.usbmodem101"
else:
default_port = "/dev/ttyACM0"
default_baudrate = 115200 default_baudrate = 115200
parser.add_argument( parser.add_argument(
"port", "port",
nargs="?", nargs="?",
default=default_port, default=None,
help=f"Serial port (default: {default_port})", help="Serial port (leave empty for autodetection)",
) )
parser.add_argument( parser.add_argument(
"--baud", "--baud",
@@ -300,19 +426,54 @@ def main() -> None:
help="Suppress lines containing this keyword (case-insensitive)", help="Suppress lines containing this keyword (case-insensitive)",
) )
args = parser.parse_args() args = parser.parse_args()
port = args.port
if port is None:
port_list = get_auto_detected_port()
if len(port_list) == 1:
port = port_list[0]
print(f"{Fore.CYAN}Auto-detected serial port: {port}{Style.RESET_ALL}")
elif len(port_list) > 1:
print(f"{Fore.YELLOW}Multiple serial ports found:{Style.RESET_ALL}")
for p in port_list:
print(f" - {p}")
print(
f"{Fore.YELLOW}Please specify the desired port as a command-line argument.{Style.RESET_ALL}"
)
if port is None:
print(f"{Fore.RED}Error: No suitable serial port found.{Style.RESET_ALL}")
sys.exit(1)
try:
ser = serial.Serial(port, args.baud, timeout=0.1)
ser.dtr = False
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
# Set up signal handler for graceful shutdown
signal.signal(signal.SIGINT, signal_handler)
# 1. Start the Serial Reader in a separate thread # 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes # Daemon=True means this thread dies when the main program closes
myargs = vars(args) # Convert Namespace to dict for easier passing myargs = vars(args) # Convert Namespace to dict for easier passing
t = threading.Thread( t = threading.Thread(target=serial_worker, args=(ser, myargs), daemon=True)
target=serial_worker, args=(args.port, args.baud, myargs), daemon=True
)
t.start() t.start()
# Start input thread
input_thread = threading.Thread(target=input_worker, args=(ser,), daemon=True)
input_thread.start()
# 2. Set up the Graph (Main Thread) # 2. Set up the Graph (Main Thread)
try: try:
import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
default_styles = ("light_background", "ggplot", "seaborn", "dark_background", )
default_styles = (
"light_background",
"ggplot",
"seaborn",
"dark_background",
)
styles = list(mplstyle.available) styles = list(mplstyle.available)
for default_style in default_styles: for default_style in default_styles:
if default_style in styles: if default_style in styles:
@@ -333,11 +494,13 @@ def main() -> None:
try: try:
print( print(
f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}" f"{Fore.YELLOW}Starting Graph Window... (Close window or press Ctrl-C to exit){Style.RESET_ALL}"
) )
plt.show() plt.show()
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}") print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
finally:
shutdown_event.set() # Ensure all threads know to stop
plt.close("all") # Force close any lingering plot windows plt.close("all") # Force close any lingering plot windows

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Generate a 1-bit book icon bitmap as a C header for PlaceholderCoverGenerator.
The icon is a simplified closed book with a spine on the left and 3 text lines.
Output format matches Logo120.h: MSB-first packed 1-bit, 0=black, 1=white.
"""
from PIL import Image, ImageDraw
import sys
def generate_book_icon(size=48):
"""Create a book icon at the given size."""
img = Image.new("1", (size, size), 1) # White background
draw = ImageDraw.Draw(img)
# Scale helper
s = size / 48.0
# Book body (main rectangle, leaving room for spine and pages)
body_left = int(6 * s)
body_top = int(2 * s)
body_right = int(42 * s)
body_bottom = int(40 * s)
# Draw book body outline (2px thick)
for i in range(int(2 * s)):
draw.rectangle(
[body_left + i, body_top + i, body_right - i, body_bottom - i], outline=0
)
# Spine (thicker left edge)
spine_width = int(4 * s)
draw.rectangle([body_left, body_top, body_left + spine_width, body_bottom], fill=0)
# Pages at the bottom (slight offset from body)
pages_top = body_bottom
pages_bottom = int(44 * s)
draw.rectangle(
[body_left + int(2 * s), pages_top, body_right - int(1 * s), pages_bottom],
outline=0,
)
# Page edges (a few lines)
for i in range(3):
y = pages_top + int((i + 1) * 1 * s)
if y < pages_bottom:
draw.line(
[body_left + int(3 * s), y, body_right - int(2 * s), y], fill=0
)
# Text lines on the book cover
text_left = body_left + spine_width + int(4 * s)
text_right = body_right - int(4 * s)
line_thickness = max(1, int(1.5 * s))
text_lines_y = [int(12 * s), int(18 * s), int(24 * s)]
text_widths = [1.0, 0.7, 0.85] # Relative widths for visual interest
for y, w_ratio in zip(text_lines_y, text_widths):
line_right = text_left + int((text_right - text_left) * w_ratio)
for t in range(line_thickness):
draw.line([text_left, y + t, line_right, y + t], fill=0)
return img
def image_to_c_array(img, name="BookIcon"):
"""Convert a 1-bit PIL image to a C header array."""
width, height = img.size
pixels = img.load()
bytes_per_row = width // 8
data = []
for y in range(height):
for bx in range(bytes_per_row):
byte = 0
for bit in range(8):
x = bx * 8 + bit
if x < width:
# 1 = white, 0 = black (matching Logo120.h convention)
if pixels[x, y]:
byte |= 1 << (7 - bit)
data.append(byte)
# Format as C header
lines = []
lines.append("#pragma once")
lines.append("#include <cstdint>")
lines.append("")
lines.append(f"// Book icon: {width}x{height}, 1-bit packed (MSB first)")
lines.append(f"// 0 = black, 1 = white (same format as Logo120.h)")
lines.append(f"static constexpr int BOOK_ICON_WIDTH = {width};")
lines.append(f"static constexpr int BOOK_ICON_HEIGHT = {height};")
lines.append(f"static const uint8_t {name}[] = {{")
# Format data in rows of 16 bytes
for i in range(0, len(data), 16):
chunk = data[i : i + 16]
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f" {hex_str},")
lines.append("};")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
size = int(sys.argv[1]) if len(sys.argv) > 1 else 48
img = generate_book_icon(size)
# Save preview PNG
preview_path = f"mod/book_icon_{size}x{size}.png"
img.resize((size * 4, size * 4), Image.NEAREST).save(preview_path)
print(f"Preview saved to {preview_path}", file=sys.stderr)
# Generate C header
header = image_to_c_array(img, "BookIcon")
output_path = "lib/PlaceholderCover/BookIcon.h"
with open(output_path, "w") as f:
f.write(header)
print(f"C header saved to {output_path}", file=sys.stderr)

View File

@@ -0,0 +1,15 @@
Import("env")
import subprocess
config = env.GetProjectConfig()
version = config.get("crosspoint", "version")
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True
)
git_hash = result.stdout.strip()
env.Append(
BUILD_FLAGS=[f'-DCROSSPOINT_VERSION=\\"{version}-mod+{git_hash}\\"']
)

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate a preview of the placeholder cover layout at full cover size (480x800).
This mirrors the C++ PlaceholderCoverGenerator layout logic for visual verification.
"""
from PIL import Image, ImageDraw, ImageFont
import sys
import os
# Reuse the book icon generator
sys.path.insert(0, os.path.dirname(__file__))
from generate_book_icon import generate_book_icon
def create_preview(width=480, height=800, title="The Great Gatsby", author="F. Scott Fitzgerald"):
img = Image.new("1", (width, height), 1) # White
draw = ImageDraw.Draw(img)
# Proportional layout constants
edge_padding = max(3, width // 48) # ~10px at 480w
border_width = max(2, width // 96) # ~5px at 480w
inner_padding = max(4, width // 32) # ~15px at 480w
title_scale = 2 if height >= 600 else 1
author_scale = 2 if height >= 600 else 1 # Author also larger on full covers
icon_scale = 2 if height >= 600 else (1 if height >= 350 else 0)
# Draw border inset from edge
bx = edge_padding
by = edge_padding
bw = width - 2 * edge_padding
bh = height - 2 * edge_padding
for i in range(border_width):
draw.rectangle([bx + i, by + i, bx + bw - 1 - i, by + bh - 1 - i], outline=0)
# Content area
content_x = edge_padding + border_width + inner_padding
content_y = edge_padding + border_width + inner_padding
content_w = width - 2 * content_x
content_h = height - 2 * content_y
# Zones
title_zone_h = content_h * 2 // 3
author_zone_h = content_h - title_zone_h
author_zone_y = content_y + title_zone_h
# Separator
sep_w = content_w // 3
sep_x = content_x + (content_w - sep_w) // 2
draw.line([sep_x, author_zone_y, sep_x + sep_w, author_zone_y], fill=0)
# Use a basic font for the preview (won't match exact Ubuntu metrics, but shows layout)
try:
title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12 * title_scale)
author_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10 * author_scale)
except (OSError, IOError):
title_font = ImageFont.load_default()
author_font = ImageFont.load_default()
# Icon dimensions (needed for title text wrapping)
icon_w_px = 48 * icon_scale if icon_scale > 0 else 0
icon_h_px = 48 * icon_scale if icon_scale > 0 else 0
icon_gap = max(8, width // 40) if icon_scale > 0 else 0
title_text_w = content_w - icon_w_px - icon_gap # Title wraps in narrower area beside icon
# Wrap title (within the narrower area to the right of the icon)
title_lines = []
words = title.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=title_font)
if bbox[2] - bbox[0] <= title_text_w:
current_line = test
else:
if current_line:
title_lines.append(current_line)
current_line = word
if current_line:
title_lines.append(current_line)
title_lines = title_lines[:5]
# Line spacing: 75% of advanceY (tighter so 2-3 lines fit within icon height)
title_line_h = 29 * title_scale * 3 // 4 # Based on C++ ubuntu_12_bold advanceY
# Measure actual single-line height from the PIL font for accurate centering
sample_bbox = draw.textbbox((0, 0), "Ag", font=title_font) # Tall + descender chars
single_line_visual_h = sample_bbox[3] - sample_bbox[1]
# Visual height: line spacing between lines + actual height of last line's glyphs
num_title_lines = len(title_lines)
title_visual_h = (num_title_lines - 1) * title_line_h + single_line_visual_h if num_title_lines > 0 else 0
title_block_h = max(icon_h_px, title_visual_h)
title_start_y = content_y + (title_zone_h - title_block_h) // 2
if title_start_y < content_y:
title_start_y = content_y
# If title fits within icon height, center it vertically against the icon.
# Otherwise top-align so extra lines overflow below.
icon_y = title_start_y
if icon_h_px > 0 and title_visual_h <= icon_h_px:
title_text_y = title_start_y + (icon_h_px - title_visual_h) // 2
else:
title_text_y = title_start_y
# Horizontal centering: measure widest title line, center icon+gap+text block
max_title_line_w = 0
for line in title_lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
w = bbox[2] - bbox[0]
if w > max_title_line_w:
max_title_line_w = w
title_block_w = icon_w_px + icon_gap + max_title_line_w
title_block_x = content_x + (content_w - title_block_w) // 2
# Draw icon
if icon_scale > 0:
icon_img = generate_book_icon(48)
scaled_icon = icon_img.resize((icon_w_px, icon_h_px), Image.NEAREST)
for iy in range(scaled_icon.height):
for ix in range(scaled_icon.width):
if not scaled_icon.getpixel((ix, iy)):
img.putpixel((title_block_x + ix, icon_y + iy), 0)
# Draw title (to the right of the icon)
title_text_x = title_block_x + icon_w_px + icon_gap
current_y = title_text_y
for line in title_lines:
draw.text((title_text_x, current_y), line, fill=0, font=title_font)
current_y += title_line_h
# Wrap author
author_lines = []
words = author.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=author_font)
if bbox[2] - bbox[0] <= content_w:
current_line = test
else:
if current_line:
author_lines.append(current_line)
current_line = word
if current_line:
author_lines.append(current_line)
author_lines = author_lines[:3]
# Draw author centered in bottom 1/3
author_line_h = 24 * author_scale # Ubuntu 10 regular advanceY ~24
author_block_h = len(author_lines) * author_line_h
author_start_y = author_zone_y + (author_zone_h - author_block_h) // 2
for line in author_lines:
bbox = draw.textbbox((0, 0), line, font=author_font)
line_w = bbox[2] - bbox[0]
line_x = content_x + (content_w - line_w) // 2
draw.text((line_x, author_start_y), line, fill=0, font=author_font)
author_start_y += author_line_h
return img
if __name__ == "__main__":
# Full cover
img = create_preview(480, 800, "A Really Long Book Title That Should Wrap", "Jane Doe")
img.save("mod/preview_cover_480x800.png")
print("Saved mod/preview_cover_480x800.png", file=sys.stderr)
# Medium thumbnail
img2 = create_preview(240, 400, "A Really Long Book Title That Should Wrap", "Jane Doe")
img2.save("mod/preview_thumb_240x400.png")
print("Saved mod/preview_thumb_240x400.png", file=sys.stderr)
# Small thumbnail
img3 = create_preview(136, 226, "A Really Long Book Title", "Jane Doe")
img3.save("mod/preview_thumb_136x226.png")
print("Saved mod/preview_thumb_136x226.png", file=sys.stderr)

View File

@@ -1,7 +1,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <cstring> #include <cstring>
@@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 32; constexpr uint8_t SETTINGS_COUNT = 31;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
// Validate front button mapping to ensure each hardware button is unique. // Validate front button mapping to ensure each hardware button is unique.
@@ -119,11 +119,10 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fadingFix); serialization::writePod(outputFile, fadingFix);
serialization::writePod(outputFile, embeddedStyle); serialization::writePod(outputFile, embeddedStyle);
serialization::writePod(outputFile, sleepScreenLetterboxFill); serialization::writePod(outputFile, sleepScreenLetterboxFill);
serialization::writePod(outputFile, sleepScreenGradientDir);
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); LOG_DBG("CPS", "Settings saved to file");
return true; return true;
} }
@@ -136,7 +135,7 @@ bool CrossPointSettings::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) { if (version != SETTINGS_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }
@@ -227,7 +226,7 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT); readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenGradientDir, SLEEP_SCREEN_GRADIENT_DIR_COUNT); { uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
} while (false); } while (false);
@@ -239,14 +238,14 @@ bool CrossPointSettings::loadFromFile() {
} }
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis()); LOG_DBG("CPS", "Settings loaded from file");
return true; return true;
} }
float CrossPointSettings::getReaderLineCompression() const { float CrossPointSettings::getReaderLineCompression() const {
switch (fontFamily) { switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY: case BOOKERLY:
default:
switch (lineSpacing) { switch (lineSpacing) {
case TIGHT: case TIGHT:
return 0.95f; return 0.95f;
@@ -256,6 +255,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE: case WIDE:
return 1.1f; return 1.1f;
} }
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS: case NOTOSANS:
switch (lineSpacing) { switch (lineSpacing) {
case TIGHT: case TIGHT:
@@ -266,6 +267,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE: case WIDE:
return 1.0f; return 1.0f;
} }
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC: case OPENDYSLEXIC:
switch (lineSpacing) { switch (lineSpacing) {
case TIGHT: case TIGHT:
@@ -276,6 +279,30 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE: case WIDE:
return 1.0f; return 1.0f;
} }
#endif // OMIT_OPENDYSLEXIC
default:
// Fallback: use Bookerly-style compression, or Noto Sans if Bookerly is omitted
#if !defined(OMIT_BOOKERLY)
switch (lineSpacing) {
case TIGHT:
return 0.95f;
case NORMAL:
default:
return 1.0f;
case WIDE:
return 1.1f;
}
#else
switch (lineSpacing) {
case TIGHT:
return 0.90f;
case NORMAL:
default:
return 0.95f;
case WIDE:
return 1.0f;
}
#endif
} }
} }
@@ -313,8 +340,8 @@ int CrossPointSettings::getRefreshFrequency() const {
int CrossPointSettings::getReaderFontId() const { int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) { switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY: case BOOKERLY:
default:
switch (fontSize) { switch (fontSize) {
case SMALL: case SMALL:
return BOOKERLY_12_FONT_ID; return BOOKERLY_12_FONT_ID;
@@ -326,6 +353,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE: case EXTRA_LARGE:
return BOOKERLY_18_FONT_ID; return BOOKERLY_18_FONT_ID;
} }
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS: case NOTOSANS:
switch (fontSize) { switch (fontSize) {
case SMALL: case SMALL:
@@ -338,6 +367,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE: case EXTRA_LARGE:
return NOTOSANS_18_FONT_ID; return NOTOSANS_18_FONT_ID;
} }
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC: case OPENDYSLEXIC:
switch (fontSize) { switch (fontSize) {
case SMALL: case SMALL:
@@ -350,5 +381,17 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE: case EXTRA_LARGE:
return OPENDYSLEXIC_14_FONT_ID; return OPENDYSLEXIC_14_FONT_ID;
} }
#endif // OMIT_OPENDYSLEXIC
default:
// Fallback to first available font family at medium size
#if !defined(OMIT_BOOKERLY)
return BOOKERLY_14_FONT_ID;
#elif !defined(OMIT_NOTOSANS)
return NOTOSANS_14_FONT_ID;
#elif !defined(OMIT_OPENDYSLEXIC)
return OPENDYSLEXIC_10_FONT_ID;
#else
#error "At least one font family must be available"
#endif
} }
} }

View File

@@ -32,13 +32,11 @@ class CrossPointSettings {
SLEEP_SCREEN_COVER_FILTER_COUNT SLEEP_SCREEN_COVER_FILTER_COUNT
}; };
enum SLEEP_SCREEN_LETTERBOX_FILL { enum SLEEP_SCREEN_LETTERBOX_FILL {
LETTERBOX_NONE = 0, LETTERBOX_DITHERED = 0,
LETTERBOX_SOLID = 1, LETTERBOX_SOLID = 1,
LETTERBOX_BLENDED = 2, LETTERBOX_NONE = 2,
LETTERBOX_GRADIENT = 3,
SLEEP_SCREEN_LETTERBOX_FILL_COUNT SLEEP_SCREEN_LETTERBOX_FILL_COUNT
}; };
enum SLEEP_SCREEN_GRADIENT_DIR { GRADIENT_TO_WHITE = 0, GRADIENT_TO_BLACK = 1, SLEEP_SCREEN_GRADIENT_DIR_COUNT };
// Status bar display type enum // Status bar display type enum
enum STATUS_BAR_MODE { enum STATUS_BAR_MODE {
@@ -133,10 +131,8 @@ class CrossPointSettings {
uint8_t sleepScreenCoverMode = FIT; uint8_t sleepScreenCoverMode = FIT;
// Sleep screen cover filter // Sleep screen cover filter
uint8_t sleepScreenCoverFilter = NO_FILTER; uint8_t sleepScreenCoverFilter = NO_FILTER;
// Sleep screen letterbox fill mode (None / Solid / Blended / Gradient) // Sleep screen letterbox fill mode (Dithered / Solid / None)
uint8_t sleepScreenLetterboxFill = LETTERBOX_GRADIENT; uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
// Sleep screen gradient direction (towards white or black)
uint8_t sleepScreenGradientDir = GRADIENT_TO_WHITE;
// Status bar settings // Status bar settings
uint8_t statusBar = FULL; uint8_t statusBar = FULL;
// Text rendering settings // Text rendering settings

View File

@@ -1,7 +1,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
namespace { namespace {
@@ -35,7 +35,7 @@ bool CrossPointState::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version > STATE_FILE_VERSION) { if (version > STATE_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }

View File

@@ -2,7 +2,7 @@
#include <Epub.h> #include <Epub.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <Xtc.h> #include <Xtc.h>
@@ -72,7 +72,7 @@ bool RecentBooksStore::saveToFile() const {
} }
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [RBS] Recent books saved to file (%d entries)\n", millis(), count); LOG_DBG("RBS", "Recent books saved to file (%d entries)", count);
return true; return true;
} }
@@ -83,7 +83,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
lastBookFileName = path.substr(lastSlash + 1); lastBookFileName = path.substr(lastSlash + 1);
} }
Serial.printf("[%lu] [RBS] Loading recent book: %s\n", millis(), path.c_str()); LOG_DBG("RBS", "Loading recent book: %s", path.c_str());
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
@@ -136,7 +136,7 @@ bool RecentBooksStore::loadFromFile() {
} }
} }
} else { } else {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }
@@ -158,6 +158,6 @@ bool RecentBooksStore::loadFromFile() {
} }
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size()); LOG_DBG("RBS", "Recent books loaded from file (%d entries)", recentBooks.size());
return true; return true;
} }

View File

@@ -6,10 +6,36 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
// Compile-time table of available font families and their enum values.
// Used by the DynamicEnum getter/setter to map between list indices and stored FONT_FAMILY values.
struct FontFamilyMapping {
const char* name;
uint8_t value;
};
inline constexpr FontFamilyMapping kFontFamilyMappings[] = {
#ifndef OMIT_BOOKERLY
{"Bookerly", CrossPointSettings::BOOKERLY},
#endif
#ifndef OMIT_NOTOSANS
{"Noto Sans", CrossPointSettings::NOTOSANS},
#endif
#ifndef OMIT_OPENDYSLEXIC
{"Open Dyslexic", CrossPointSettings::OPENDYSLEXIC},
#endif
};
inline constexpr size_t kFontFamilyMappingCount = sizeof(kFontFamilyMappings) / sizeof(kFontFamilyMappings[0]);
static_assert(kFontFamilyMappingCount > 0, "At least one font family must be available");
// Shared settings list used by both the device settings UI and the web settings API. // Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping). // Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only. // ACTION-type entries and entries without a key are device-only.
inline std::vector<SettingInfo> getSettingsList() { inline std::vector<SettingInfo> getSettingsList() {
// Build font family options from the compile-time mapping table
std::vector<std::string> fontFamilyOptions;
for (size_t i = 0; i < kFontFamilyMappingCount; i++) {
fontFamilyOptions.push_back(kFontFamilyMappings[i].name);
}
return { return {
// --- Display --- // --- Display ---
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
@@ -19,9 +45,7 @@ inline std::vector<SettingInfo> getSettingsList() {
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"), {"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill, SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
{"None", "Solid", "Blended", "Gradient"}, "sleepScreenLetterboxFill", "Display"), {"Dithered", "Solid", "None"}, "sleepScreenLetterboxFill", "Display"),
SettingInfo::Enum("Gradient Direction", &CrossPointSettings::sleepScreenGradientDir, {"To White", "To Black"},
"sleepScreenGradientDir", "Display"),
SettingInfo::Enum( SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar, "Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}, {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
@@ -34,8 +58,20 @@ inline std::vector<SettingInfo> getSettingsList() {
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"), SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
// --- Reader --- // --- Reader ---
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}, SettingInfo::DynamicEnum(
"fontFamily", "Reader"), "Font Family", std::move(fontFamilyOptions),
[]() -> uint8_t {
for (uint8_t i = 0; i < kFontFamilyMappingCount; i++) {
if (kFontFamilyMappings[i].value == SETTINGS.fontFamily) return i;
}
return 0; // fallback to first available family
},
[](uint8_t idx) {
if (idx < kFontFamilyMappingCount) {
SETTINGS.fontFamily = kFontFamilyMappings[idx].value;
}
},
"fontFamily", "Reader"),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize", SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
"Reader"), "Reader"),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing", SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",

View File

@@ -1,7 +1,7 @@
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
// Initialize the static instance // Initialize the static instance
@@ -21,7 +21,7 @@ constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY);
} // namespace } // namespace
void WifiCredentialStore::obfuscate(std::string& data) const { void WifiCredentialStore::obfuscate(std::string& data) const {
Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size()); LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size());
for (size_t i = 0; i < data.size(); i++) { for (size_t i = 0; i < data.size(); i++) {
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
} }
@@ -45,8 +45,7 @@ bool WifiCredentialStore::saveToFile() const {
for (const auto& cred : credentials) { for (const auto& cred : credentials) {
// Write SSID (plaintext - not sensitive) // Write SSID (plaintext - not sensitive)
serialization::writeString(file, cred.ssid); serialization::writeString(file, cred.ssid);
Serial.printf("[%lu] [WCS] Saving SSID: %s, password length: %zu\n", millis(), cred.ssid.c_str(), LOG_DBG("WCS", "Saving SSID: %s, password length: %zu", cred.ssid.c_str(), cred.password.size());
cred.password.size());
// Write password (obfuscated) // Write password (obfuscated)
std::string obfuscatedPwd = cred.password; std::string obfuscatedPwd = cred.password;
@@ -55,7 +54,7 @@ bool WifiCredentialStore::saveToFile() const {
} }
file.close(); file.close();
Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size()); LOG_DBG("WCS", "Saved %zu WiFi credentials to file", credentials.size());
return true; return true;
} }
@@ -69,7 +68,7 @@ bool WifiCredentialStore::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(file, version); serialization::readPod(file, version);
if (version > WIFI_FILE_VERSION) { if (version > WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version); LOG_DBG("WCS", "Unknown file version: %u", version);
file.close(); file.close();
return false; return false;
} }
@@ -94,16 +93,15 @@ bool WifiCredentialStore::loadFromFile() {
// Read and deobfuscate password // Read and deobfuscate password
serialization::readString(file, cred.password); serialization::readString(file, cred.password);
Serial.printf("[%lu] [WCS] Loaded SSID: %s, obfuscated password length: %zu\n", millis(), cred.ssid.c_str(), LOG_DBG("WCS", "Loaded SSID: %s, obfuscated password length: %zu", cred.ssid.c_str(), cred.password.size());
cred.password.size());
obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates
Serial.printf("[%lu] [WCS] After deobfuscation, password length: %zu\n", millis(), cred.password.size()); LOG_DBG("WCS", "After deobfuscation, password length: %zu", cred.password.size());
credentials.push_back(cred); credentials.push_back(cred);
} }
file.close(); file.close();
Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size()); LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
return true; return true;
} }
@@ -113,19 +111,19 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
if (cred != credentials.end()) { if (cred != credentials.end()) {
cred->password = password; cred->password = password;
Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Updated credentials for: %s", ssid.c_str());
return saveToFile(); return saveToFile();
} }
// Check if we've reached the limit // Check if we've reached the limit
if (credentials.size() >= MAX_NETWORKS) { if (credentials.size() >= MAX_NETWORKS) {
Serial.printf("[%lu] [WCS] Cannot add more networks, limit of %zu reached\n", millis(), MAX_NETWORKS); LOG_DBG("WCS", "Cannot add more networks, limit of %zu reached", MAX_NETWORKS);
return false; return false;
} }
// Add new credential // Add new credential
credentials.push_back({ssid, password}); credentials.push_back({ssid, password});
Serial.printf("[%lu] [WCS] Added credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Added credentials for: %s", ssid.c_str());
return saveToFile(); return saveToFile();
} }
@@ -134,7 +132,7 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
if (cred != credentials.end()) { if (cred != credentials.end()) {
credentials.erase(cred); credentials.erase(cred);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Removed credentials for: %s", ssid.c_str());
if (ssid == lastConnectedSsid) { if (ssid == lastConnectedSsid) {
clearLastConnectedSsid(); clearLastConnectedSsid();
} }
@@ -176,5 +174,5 @@ void WifiCredentialStore::clearAll() {
credentials.clear(); credentials.clear();
lastConnectedSsid.clear(); lastConnectedSsid.clear();
saveToFile(); saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis()); LOG_DBG("WCS", "Cleared all WiFi credentials");
} }

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <HardwareSerial.h> #include <Logging.h>
#include <string> #include <string>
#include <utility> #include <utility>
@@ -18,8 +18,8 @@ class Activity {
explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput) explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {} : name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
virtual ~Activity() = default; virtual ~Activity() = default;
virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); } virtual void onEnter() { LOG_DBG("ACT", "Entering activity: %s", name.c_str()); }
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void onExit() { LOG_DBG("ACT", "Exiting activity: %s", name.c_str()); }
virtual void loop() {} virtual void loop() {}
virtual bool skipLoopDelay() { return false; } virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; } virtual bool preventAutoSleep() { return false; }

View File

@@ -14,4 +14,6 @@ class ActivityWithSubactivity : public Activity {
: Activity(std::move(name), renderer, mappedInput) {} : Activity(std::move(name), renderer, mappedInput) {}
void loop() override; void loop() override;
void onExit() override; void onExit() override;
bool preventAutoSleep() override { return subActivity && subActivity->preventAutoSleep(); }
bool skipLoopDelay() override { return subActivity && subActivity->skipLoopDelay(); }
}; };

View File

@@ -1,17 +1,20 @@
#include "SleepActivity.h" #include "SleepActivity.h"
#include <BitmapHelpers.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include <Serialization.h> #include <Serialization.h>
#include <Txt.h> #include <Txt.h>
#include <Xtc.h> #include <Xtc.h>
#include <algorithm> #include <algorithm>
#include <cmath>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "util/BookSettings.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "images/Logo120.h" #include "images/Logo120.h"
@@ -19,36 +22,95 @@
namespace { namespace {
// Number of source pixels along the image edge to average for the gradient color // Number of source pixels along the image edge to average for the dominant color
constexpr int EDGE_SAMPLE_DEPTH = 20; constexpr int EDGE_SAMPLE_DEPTH = 20;
// Map a 2-bit quantized pixel value to an 8-bit grayscale value // Map a 2-bit quantized pixel value to an 8-bit grayscale value
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; } constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
// Edge gradient data produced by sampleBitmapEdges and consumed by drawLetterboxGradients. // Letterbox fill data: one average gray value per edge (top/bottom or left/right).
// edgeA is the "first" edge (top or left), edgeB is the "second" edge (bottom or right). struct LetterboxFillData {
struct LetterboxGradientData { uint8_t avgA = 128; // average gray of edge A (top or left)
uint8_t* edgeA = nullptr; uint8_t avgB = 128; // average gray of edge B (bottom or right)
uint8_t* edgeB = nullptr;
int edgeCount = 0;
int letterboxA = 0; // pixel size of the first letterbox area (top or left) int letterboxA = 0; // pixel size of the first letterbox area (top or left)
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right) int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
bool horizontal = false; // true = top/bottom letterbox, false = left/right bool horizontal = false; // true = top/bottom letterbox, false = left/right
bool valid = false;
void free() {
::free(edgeA);
::free(edgeB);
edgeA = nullptr;
edgeB = nullptr;
}
}; };
// Binary cache version for edge data files // Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
constexpr uint8_t EDGE_CACHE_VERSION = 1; uint8_t snapToEinkLevel(uint8_t gray) {
// Thresholds at midpoints: 42, 127, 212
if (gray < 43) return 0;
if (gray < 128) return 85;
if (gray < 213) return 170;
return 255;
}
// Load cached edge data from a binary file. Returns true if the cache was valid and loaded successfully. // 4x4 Bayer ordered dithering matrix, values 0-255.
// Validates cache version and screen dimensions to detect stale data. // Produces a structured halftone pattern for 4-level quantization.
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxGradientData& data) { // clang-format off
constexpr uint8_t BAYER_4X4[4][4] = {
{ 0, 128, 32, 160},
{192, 64, 224, 96},
{ 48, 176, 16, 144},
{240, 112, 208, 80}
};
// clang-format on
// Ordered (Bayer) dithering for 4-level e-ink display.
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
// to produce a structured, repeating halftone pattern.
uint8_t quantizeBayerDither(int gray, int x, int y) {
const int threshold = BAYER_4X4[y & 3][x & 3];
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Check whether a gray value would produce a dithered mix that crosses the
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
// creating a high-frequency checkerboard that causes e-ink display crosstalk
// and washes out adjacent content during HALF_REFRESH.
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
bool bayerCrossesBwBoundary(uint8_t gray) {
return gray > 170 && gray < 255;
}
// Hash-based block dithering for BW-boundary gray values (171-254).
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
// determined by a deterministic spatial hash. The proportion of level-3 blocks
// approximates the target gray. Unlike Bayer, the pattern is irregular
// (noise-like), making it much less visually obvious at the same block size.
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
// identical levels across BW, LSB, and MSB render passes.
static constexpr int BW_DITHER_BLOCK = 2;
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
const int bx = x / BW_DITHER_BLOCK;
const int by = y / BW_DITHER_BLOCK;
// Fast mixing hash (splitmix32-inspired)
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
h ^= h >> 16;
h *= 0x45d9f3bu;
h ^= h >> 16;
// Proportion of level-3 blocks needed to approximate the target gray
const float ratio = (avg - 170.0f) / 85.0f;
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
return (h < threshold) ? 3 : 2;
}
// --- Edge average cache ---
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
constexpr uint8_t EDGE_CACHE_VERSION = 2;
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
FsFile file; FsFile file;
if (!Storage.openFileForRead("SLP", path, file)) return false; if (!Storage.openFileForRead("SLP", path, file)) return false;
@@ -71,9 +133,8 @@ bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, L
serialization::readPod(file, horizontal); serialization::readPod(file, horizontal);
data.horizontal = (horizontal != 0); data.horizontal = (horizontal != 0);
uint16_t edgeCount; serialization::readPod(file, data.avgA);
serialization::readPod(file, edgeCount); serialization::readPod(file, data.avgB);
data.edgeCount = edgeCount;
int16_t lbA, lbB; int16_t lbA, lbB;
serialization::readPod(file, lbA); serialization::readPod(file, lbA);
@@ -81,34 +142,14 @@ bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, L
data.letterboxA = lbA; data.letterboxA = lbA;
data.letterboxB = lbB; data.letterboxB = lbB;
if (edgeCount == 0 || edgeCount > 2048) {
file.close();
return false;
}
data.edgeA = static_cast<uint8_t*>(malloc(edgeCount));
data.edgeB = static_cast<uint8_t*>(malloc(edgeCount));
if (!data.edgeA || !data.edgeB) {
data.free();
file.close();
return false;
}
if (file.read(data.edgeA, edgeCount) != static_cast<int>(edgeCount) ||
file.read(data.edgeB, edgeCount) != static_cast<int>(edgeCount)) {
data.free();
file.close();
return false;
}
file.close(); file.close();
Serial.printf("[%lu] [SLP] Loaded edge cache from %s (%d edges)\n", millis(), path.c_str(), edgeCount); data.valid = true;
LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB);
return true; return true;
} }
// Save edge data to a binary cache file for reuse on subsequent sleep screens. bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxGradientData& data) { if (!data.valid) return false;
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return false;
FsFile file; FsFile file;
if (!Storage.openFileForWrite("SLP", path, file)) return false; if (!Storage.openFileForWrite("SLP", path, file)) return false;
@@ -117,23 +158,22 @@ bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, c
serialization::writePod(file, static_cast<uint16_t>(screenWidth)); serialization::writePod(file, static_cast<uint16_t>(screenWidth));
serialization::writePod(file, static_cast<uint16_t>(screenHeight)); serialization::writePod(file, static_cast<uint16_t>(screenHeight));
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0)); serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
serialization::writePod(file, static_cast<uint16_t>(data.edgeCount)); serialization::writePod(file, data.avgA);
serialization::writePod(file, data.avgB);
serialization::writePod(file, static_cast<int16_t>(data.letterboxA)); serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
serialization::writePod(file, static_cast<int16_t>(data.letterboxB)); serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
file.write(data.edgeA, data.edgeCount);
file.write(data.edgeB, data.edgeCount);
file.close(); file.close();
Serial.printf("[%lu] [SLP] Saved edge cache to %s (%d edges)\n", millis(), path.c_str(), data.edgeCount); LOG_DBG("SLP", "Saved edge cache to %s", path.c_str());
return true; return true;
} }
// Read the bitmap once to sample the first/last EDGE_SAMPLE_DEPTH rows or columns. // Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
// Returns edge color arrays in source pixel resolution. Caller must call data.free() when done. // Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
// After sampling the bitmap is rewound via rewindToData(). // After sampling the bitmap is rewound via rewindToData().
LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight, LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
float scale, float cropX, float cropY) { float scale, float cropX, float cropY) {
LetterboxGradientData data; LetterboxFillData data;
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f)); const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f)); const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
@@ -146,35 +186,22 @@ LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize)); auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
::free(outputRow); free(outputRow);
::free(rowBytes); free(rowBytes);
return data; return data;
} }
if (imgY > 0) { if (imgY > 0) {
// Top/bottom letterboxing -- sample per-column averages of first/last N rows // Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
data.horizontal = true; data.horizontal = true;
data.edgeCount = visibleWidth;
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale)); const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
data.letterboxA = imgY; data.letterboxA = imgY;
data.letterboxB = pageHeight - imgY - scaledHeight; data.letterboxB = pageHeight - imgY - scaledHeight;
if (data.letterboxB < 0) data.letterboxB = 0; if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight); const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
uint64_t sumTop = 0, sumBot = 0;
auto* accumTop = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t))); int countTop = 0, countBot = 0;
auto* accumBot = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleWidth));
data.edgeB = static_cast<uint8_t*>(malloc(visibleWidth));
if (!accumTop || !accumBot || !data.edgeA || !data.edgeB) {
::free(accumTop);
::free(accumBot);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
@@ -187,188 +214,130 @@ LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY
if (!inTop && !inBot) continue; if (!inTop && !inBot) continue;
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const int outX = bmpX - cropPixX;
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
const uint8_t gray = val2bitToGray(val); const uint8_t gray = val2bitToGray(val);
if (inTop) accumTop[outX] += gray; if (inTop) {
if (inBot) accumBot[outX] += gray; sumTop += gray;
countTop++;
}
if (inBot) {
sumBot += gray;
countBot++;
}
} }
} }
for (int i = 0; i < visibleWidth; i++) { data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
data.edgeA[i] = static_cast<uint8_t>(accumTop[i] / sampleRows); data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
data.edgeB[i] = static_cast<uint8_t>(accumBot[i] / sampleRows); data.valid = true;
}
::free(accumTop);
::free(accumBot);
} else if (imgX > 0) { } else if (imgX > 0) {
// Left/right letterboxing -- sample per-row averages of first/last N columns // Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
data.horizontal = false; data.horizontal = false;
data.edgeCount = visibleHeight;
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale)); const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
data.letterboxA = imgX; data.letterboxA = imgX;
data.letterboxB = pageWidth - imgX - scaledWidth; data.letterboxB = pageWidth - imgX - scaledWidth;
if (data.letterboxB < 0) data.letterboxB = 0; if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth); const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
uint64_t sumLeft = 0, sumRight = 0;
auto* accumLeft = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t))); int countLeft = 0, countRight = 0;
auto* accumRight = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleHeight));
data.edgeB = static_cast<uint8_t*>(malloc(visibleHeight));
if (!accumLeft || !accumRight || !data.edgeA || !data.edgeB) {
::free(accumLeft);
::free(accumRight);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
// Sample left edge columns
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) { for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumLeft[outY] += val2bitToGray(val); sumLeft += val2bitToGray(val);
countLeft++;
} }
// Sample right edge columns
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumRight[outY] += val2bitToGray(val); sumRight += val2bitToGray(val);
countRight++;
} }
} }
for (int i = 0; i < visibleHeight; i++) { data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
data.edgeA[i] = static_cast<uint8_t>(accumLeft[i] / sampleCols); data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
data.edgeB[i] = static_cast<uint8_t>(accumRight[i] / sampleCols); data.valid = true;
}
::free(accumLeft);
::free(accumRight);
} }
::free(outputRow);
::free(rowBytes);
bitmap.rewindToData(); bitmap.rewindToData();
free(outputRow);
free(rowBytes);
return data; return data;
} }
// Draw dithered fills in the letterbox areas using the sampled edge colors. // Draw letterbox fill in the areas around the cover image.
// fillMode selects the fill algorithm: SOLID (single dominant shade), BLENDED (per-pixel edge color), // DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
// or GRADIENT (per-pixel edge color interpolated toward targetColor). // SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
// targetColor is the color the gradient fades toward (255=white, 0=black); only used in GRADIENT mode.
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB). // Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxGradientData& data, float scale, uint8_t fillMode, void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
int targetColor) { if (!data.valid) return;
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return;
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
const bool isGradient = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_GRADIENT);
// For SOLID mode, compute the dominant (average) shade for each edge once // For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
uint8_t solidColorA = 0, solidColorB = 0; // Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
if (isSolid) { // the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
uint32_t sumA = 0, sumB = 0; //
for (int i = 0; i < data.edgeCount; i++) { // Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
sumA += data.edgeA[i]; // level (2 or 3) determined by a spatial hash, with the proportion of level-3
sumB += data.edgeB[i]; // blocks tuned to approximate the target gray. The 2px minimum run avoids BW
} // crosstalk, and the irregular hash pattern is much less visible than a regular
solidColorA = static_cast<uint8_t>(sumA / data.edgeCount); // Bayer grid at the same block size.
solidColorB = static_cast<uint8_t>(sumB / data.edgeCount); const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
} const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
// Helper: compute gray value for a pixel given the edge color and interpolation factor t (0..1) // For solid mode: snap to nearest e-ink level
// GRADIENT interpolates from edgeColor toward targetColor; SOLID and BLENDED return edgeColor directly. const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
auto computeGray = [&](int edgeColor, float t) -> int { const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
if (isGradient) return edgeColor + static_cast<int>(static_cast<float>(targetColor - edgeColor) * t);
return edgeColor;
};
if (data.horizontal) { if (data.horizontal) {
// Top letterbox
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
const int imgTopY = data.letterboxA; for (int y = 0; y < data.letterboxA; y++)
for (int screenY = 0; screenY < imgTopY; screenY++) { for (int x = 0; x < renderer.getScreenWidth(); x++) {
const float t = static_cast<float>(imgTopY - screenY) / static_cast<float>(imgTopY); uint8_t lv;
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) { if (isSolid) lv = levelA;
int edgeColor; else if (hashA) lv = hashBlockDither(data.avgA, x, y);
if (isSolid) { else lv = quantizeBayerDither(data.avgA, x, y);
edgeColor = solidColorA; renderer.drawPixelGray(x, y, lv);
} else {
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeA[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
} }
}
} }
// Bottom letterbox
if (data.letterboxB > 0) { if (data.letterboxB > 0) {
const int imgBottomY = renderer.getScreenHeight() - data.letterboxB; const int start = renderer.getScreenHeight() - data.letterboxB;
for (int screenY = imgBottomY; screenY < renderer.getScreenHeight(); screenY++) { for (int y = start; y < renderer.getScreenHeight(); y++)
const float t = static_cast<float>(screenY - imgBottomY + 1) / static_cast<float>(data.letterboxB); for (int x = 0; x < renderer.getScreenWidth(); x++) {
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) { uint8_t lv;
int edgeColor; if (isSolid) lv = levelB;
if (isSolid) { else if (hashB) lv = hashBlockDither(data.avgB, x, y);
edgeColor = solidColorB; else lv = quantizeBayerDither(data.avgB, x, y);
} else { renderer.drawPixelGray(x, y, lv);
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeB[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
} }
}
} }
} else { } else {
// Left letterbox
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
const int imgLeftX = data.letterboxA; for (int x = 0; x < data.letterboxA; x++)
for (int screenX = 0; screenX < imgLeftX; screenX++) { for (int y = 0; y < renderer.getScreenHeight(); y++) {
const float t = static_cast<float>(imgLeftX - screenX) / static_cast<float>(imgLeftX); uint8_t lv;
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) { if (isSolid) lv = levelA;
int edgeColor; else if (hashA) lv = hashBlockDither(data.avgA, x, y);
if (isSolid) { else lv = quantizeBayerDither(data.avgA, x, y);
edgeColor = solidColorA; renderer.drawPixelGray(x, y, lv);
} else {
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeA[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
} }
}
} }
// Right letterbox
if (data.letterboxB > 0) { if (data.letterboxB > 0) {
const int imgRightX = renderer.getScreenWidth() - data.letterboxB; const int start = renderer.getScreenWidth() - data.letterboxB;
for (int screenX = imgRightX; screenX < renderer.getScreenWidth(); screenX++) { for (int x = start; x < renderer.getScreenWidth(); x++)
const float t = static_cast<float>(screenX - imgRightX + 1) / static_cast<float>(data.letterboxB); for (int y = 0; y < renderer.getScreenHeight(); y++) {
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) { uint8_t lv;
int edgeColor; if (isSolid) lv = levelB;
if (isSolid) { else if (hashB) lv = hashBlockDither(data.avgB, x, y);
edgeColor = solidColorB; else lv = quantizeBayerDither(data.avgB, x, y);
} else { renderer.drawPixelGray(x, y, lv);
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeB[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
} }
}
} }
} }
} }
@@ -412,13 +381,13 @@ void SleepActivity::renderCustomSleepScreen() const {
} }
if (filename.substr(filename.length() - 4) != ".bmp") { if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name); LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
file.close(); file.close();
continue; continue;
} }
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) { if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name); LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
file.close(); file.close();
continue; continue;
} }
@@ -438,7 +407,7 @@ void SleepActivity::renderCustomSleepScreen() const {
const auto filename = "/sleep/" + files[randomFileIndex]; const auto filename = "/sleep/" + files[randomFileIndex];
FsFile file; FsFile file;
if (Storage.openFileForRead("SLP", filename, file)) { if (Storage.openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); LOG_DBG("SLP", "Randomly loading: /sleep/%s", files[randomFileIndex].c_str());
delay(100); delay(100);
Bitmap bitmap(file, true); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
@@ -457,7 +426,7 @@ void SleepActivity::renderCustomSleepScreen() const {
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file, true); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); LOG_DBG("SLP", "Loading: /sleep.bmp");
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
return; return;
} }
@@ -483,43 +452,43 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.displayBuffer(HalDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath) const { void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
uint8_t fillModeOverride) const {
int x, y; int x, y;
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
float cropX = 0, cropY = 0; float cropX = 0, cropY = 0;
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
pageWidth, pageHeight);
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images) // Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight); const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
if (ratio > screenRatio) { if (ratio > screenRatio) {
// image wider than viewport ratio, needs to be centered vertically // image wider than viewport ratio, needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio); cropX = 1.0f - (screenRatio / ratio);
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
} }
x = 0; x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2); y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
} else { } else {
// image taller than or equal to viewport ratio, needs to be centered horizontally // image taller than or equal to viewport ratio, needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio); cropY = 1.0f - (ratio / screenRatio);
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight())); ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
} }
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2); x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
y = 0; y = 0;
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
} }
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); LOG_DBG("SLP", "drawing to %d x %d", x, y);
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords // Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
@@ -527,31 +496,36 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const float scale = const float scale =
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight); std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
// Determine letterbox fill settings // Determine letterbox fill settings (per-book override takes precedence)
const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill; const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL &&
fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT)
? fillModeOverride
: SETTINGS.sleepScreenLetterboxFill;
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE); const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
const int targetColor =
(SETTINGS.sleepScreenGradientDir == CrossPointSettings::SLEEP_SCREEN_GRADIENT_DIR::GRADIENT_TO_BLACK) ? 0 : 255;
static const char* fillModeNames[] = {"none", "solid", "blended", "gradient"}; static const char* fillModeNames[] = {"dithered", "solid", "none"};
const char* fillModeName = (fillMode < 4) ? fillModeNames[fillMode] : "unknown"; const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
// Load cached edge data or sample from bitmap (first pass over bitmap, then rewind) // Compute edge averages if letterbox fill is requested (try cache first)
LetterboxGradientData gradientData; LetterboxFillData fillData;
const bool hasLetterbox = (x > 0 || y > 0); const bool hasLetterbox = (x > 0 || y > 0);
if (hasLetterbox && wantFill) { if (hasLetterbox && wantFill) {
bool cacheLoaded = false; bool cacheLoaded = false;
if (!edgeCachePath.empty()) { if (!edgeCachePath.empty()) {
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData); cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
} }
if (!cacheLoaded) { if (!cacheLoaded) {
Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), sampling edges for %s fill\n", millis(), x, y, LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName);
fillModeName); fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
gradientData = sampleBitmapEdges(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY); if (fillData.valid && !edgeCachePath.empty()) {
if (!edgeCachePath.empty() && gradientData.edgeA) { saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData);
} }
} }
if (fillData.valid) {
LOG_DBG("SLP", "Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d",
fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA,
fillData.letterboxB);
}
} }
renderer.clearScreen(); renderer.clearScreen();
@@ -560,8 +534,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass) // Draw letterbox fill (BW pass)
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); drawLetterboxFill(renderer, fillData, fillMode);
} }
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
@@ -576,8 +550,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); drawLetterboxFill(renderer, fillData, fillMode);
} }
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers(); renderer.copyGrayscaleLsbBuffers();
@@ -585,8 +559,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (gradientData.edgeA) { if (fillData.valid) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); drawLetterboxFill(renderer, fillData, fillMode);
} }
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers(); renderer.copyGrayscaleMsbBuffers();
@@ -594,8 +568,6 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
renderer.displayGrayBuffer(); renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW); renderer.setRenderMode(GfxRenderer::BW);
} }
gradientData.free();
} }
void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderCoverSleepScreen() const {
@@ -614,6 +586,7 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
std::string coverBmpPath; std::string coverBmpPath;
std::string bookCachePath;
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Check if the current book is XTC, TXT, or EPUB // Check if the current book is XTC, TXT, or EPUB
@@ -622,61 +595,87 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle XTC file // Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) { if (!lastXtc.load()) {
Serial.printf("[%lu] [SLP] Failed to load last XTC\n", millis()); LOG_ERR("SLP", "Failed to load last XTC");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastXtc.generateCoverBmp()) { if (!lastXtc.generateCoverBmp()) {
Serial.printf("[%lu] [SLP] Failed to generate XTC cover bmp\n", millis()); LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
}
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
coverBmpPath = lastXtc.getCoverBmpPath(); coverBmpPath = lastXtc.getCoverBmpPath();
bookCachePath = lastXtc.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
// Handle TXT file - looks for cover image in the same folder // Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) { if (!lastTxt.load()) {
Serial.printf("[%lu] [SLP] Failed to load last TXT\n", millis()); LOG_ERR("SLP", "Failed to load last TXT");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastTxt.generateCoverBmp()) { if (!lastTxt.generateCoverBmp()) {
Serial.printf("[%lu] [SLP] No cover image found for TXT file\n", millis()); LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
}
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "No cover image found for TXT file");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
coverBmpPath = lastTxt.getCoverBmpPath(); coverBmpPath = lastTxt.getCoverBmpPath();
bookCachePath = lastTxt.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
// Handle EPUB file // Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here // Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) { if (!lastEpub.load(true, true)) {
Serial.printf("[%lu] [SLP] Failed to load last epub\n", millis()); LOG_ERR("SLP", "Failed to load last epub");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastEpub.generateCoverBmp(cropped)) { if (!lastEpub.generateCoverBmp(cropped)) {
Serial.printf("[%lu] [SLP] Failed to generate cover bmp\n", millis()); LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
lastEpub.getAuthor(), 480, 800);
}
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
coverBmpPath = lastEpub.getCoverBmpPath(cropped); coverBmpPath = lastEpub.getCoverBmpPath(cropped);
bookCachePath = lastEpub.getCachePath();
} else { } else {
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin) // Load per-book letterbox fill override (falls back to global if not set)
std::string edgeCachePath; uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
if (coverBmpPath.size() > 4) { if (!bookCachePath.empty()) {
edgeCachePath = coverBmpPath.substr(0, coverBmpPath.size() - 4) + "_edges.bin"; auto bookSettings = BookSettings::load(bookCachePath);
fillModeOverride = bookSettings.letterboxFillOverride;
} }
FsFile file; FsFile file;
if (Storage.openFileForRead("SLP", coverBmpPath, file)) { if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str()); LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap, edgeCachePath); // Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
std::string edgeCachePath;
const auto dotPos = coverBmpPath.rfind(".bmp");
if (dotPos != std::string::npos) {
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
}
renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
return; return;
} }
} }

View File

@@ -1,4 +1,5 @@
#pragma once #pragma once
#include <string> #include <string>
#include "../Activity.h" #include "../Activity.h"
@@ -15,6 +16,8 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const; void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const; void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const; void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "") const; // fillModeOverride: 0xFF = use global setting, otherwise a SLEEP_SCREEN_LETTERBOX_FILL value.
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
uint8_t fillModeOverride = 0xFF) const;
void renderBlankSleepScreen() const; void renderBlankSleepScreen() const;
}; };

View File

@@ -2,7 +2,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <OpdsStream.h> #include <OpdsStream.h>
#include <WiFi.h> #include <WiFi.h>
@@ -78,14 +78,14 @@ void OpdsBookBrowserActivity::loop() {
// Check if WiFi is still connected // Check if WiFi is still connected
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
// WiFi connected - just retry fetching the feed // WiFi connected - just retry fetching the feed
Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis()); LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch");
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
fetchFeed(currentPath); fetchFeed(currentPath);
} else { } else {
// WiFi not connected - launch WiFi selection // WiFi not connected - launch WiFi selection
Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis()); LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection");
launchWifiSelection(); launchWifiSelection();
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
@@ -265,7 +265,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
} }
std::string url = UrlUtils::buildUrl(serverUrl, path); std::string url = UrlUtils::buildUrl(serverUrl, path);
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser; OpdsParser parser;
@@ -287,7 +287,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
} }
entries = std::move(parser).getEntries(); entries = std::move(parser).getEntries();
Serial.printf("[%lu] [OPDS] Found %d entries\n", millis(), entries.size()); LOG_DBG("OPDS", "Found %d entries", entries.size());
selectorIndex = 0; selectorIndex = 0;
if (entries.empty()) { if (entries.empty()) {
@@ -351,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
} }
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str()); LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
const auto result = const auto result =
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
@@ -361,12 +361,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}); });
if (result == HttpDownloader::OK) { if (result == HttpDownloader::OK) {
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
// Invalidate any existing cache for this file to prevent stale metadata issues // Invalidate any existing cache for this file to prevent stale metadata issues
Epub epub(filename, "/.crosspoint"); Epub epub(filename, "/.crosspoint");
epub.clearCache(); epub.clearCache();
Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str()); LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
state = BrowserState::BROWSING; state = BrowserState::BROWSING;
updateRequired = true; updateRequired = true;
@@ -403,13 +403,13 @@ void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
exitActivity(); exitActivity();
if (connected) { if (connected) {
Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis()); LOG_DBG("OPDS", "WiFi connected via selection, fetching feed");
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
fetchFeed(currentPath); fetchFeed(currentPath);
} else { } else {
Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis()); LOG_DBG("OPDS", "WiFi selection cancelled/failed");
// Force disconnect to ensure clean state for next retry // Force disconnect to ensure clean state for next retry
// This prevents stale connection status from interfering // This prevents stale connection status from interfering
WiFi.disconnect(); WiFi.disconnect();

View File

@@ -5,6 +5,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Utf8.h> #include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <cstring>
@@ -65,45 +66,35 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
if (!book.coverBmpPath.empty()) { if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) { if (!Storage.exists(coverPath.c_str())) {
// If epub, try to load the metadata for title/author and cover if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = false;
// Try format-specific thumbnail generation first
if (StringUtils::checkFileExtension(book.path, ".epub")) { if (StringUtils::checkFileExtension(book.path, ".epub")) {
Epub epub(book.path, "/.crosspoint"); Epub epub(book.path, "/.crosspoint");
// Skip loading css since we only need metadata here
epub.load(false, true); epub.load(false, true);
success = epub.generateThumbBmp(coverHeight);
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = epub.generateThumbBmp(coverHeight);
if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = "";
}
coverRendered = false;
updateRequired = true;
} else if (StringUtils::checkFileExtension(book.path, ".xtch") || } else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
StringUtils::checkFileExtension(book.path, ".xtc")) { StringUtils::checkFileExtension(book.path, ".xtc")) {
// Handle XTC file
Xtc xtc(book.path, "/.crosspoint"); Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) { if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card success = xtc.generateThumbBmp(coverHeight);
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = xtc.generateThumbBmp(coverHeight);
if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = "";
}
coverRendered = false;
updateRequired = true;
} }
} }
// Fallback: generate a placeholder thumbnail with title/author
if (!success && !Storage.exists(coverPath.c_str())) {
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
coverRendered = false;
updateRequired = true;
} }
} }
progress++; progress++;

View File

@@ -73,7 +73,7 @@ void RecentBooksActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("[%lu] [RBA] Selected recent book: %s\n", millis(), recentBooks[selectorIndex].path.c_str()); LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path); onSelectBook(recentBooks[selectorIndex].path);
return; return;
} }

View File

@@ -96,7 +96,7 @@ void CalibreConnectActivity::startWebServer() {
if (MDNS.begin(HOSTNAME)) { if (MDNS.begin(HOSTNAME)) {
// mDNS is optional for the Calibre plugin but still helpful for users. // mDNS is optional for the Calibre plugin but still helpful for users.
Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); LOG_DBG("CAL", "mDNS started: http://%s.local/", HOSTNAME);
} }
webServer.reset(new CrossPointWebServer()); webServer.reset(new CrossPointWebServer());
@@ -131,7 +131,7 @@ void CalibreConnectActivity::loop() {
if (webServer && webServer->isRunning()) { if (webServer && webServer->isRunning()) {
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
} }
esp_task_wdt_reset(); esp_task_wdt_reset();

View File

@@ -37,7 +37,7 @@ void CrossPointWebServerActivity::taskTrampoline(void* param) {
void CrossPointWebServerActivity::onEnter() { void CrossPointWebServerActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onEnter: %d bytes", ESP.getFreeHeap());
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
@@ -58,7 +58,7 @@ void CrossPointWebServerActivity::onEnter() {
); );
// Launch network mode selection subactivity // Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); LOG_DBG("WEBACT", "Launching NetworkModeSelectionActivity...");
enterNewActivity(new NetworkModeSelectionActivity( enterNewActivity(new NetworkModeSelectionActivity(
renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home [this]() { onGoBack(); } // Cancel goes back to home
@@ -68,7 +68,7 @@ void CrossPointWebServerActivity::onEnter() {
void CrossPointWebServerActivity::onExit() { void CrossPointWebServerActivity::onExit() {
ActivityWithSubactivity::onExit(); ActivityWithSubactivity::onExit();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onExit start: %d bytes", ESP.getFreeHeap());
state = WebServerActivityState::SHUTTING_DOWN; state = WebServerActivityState::SHUTTING_DOWN;
@@ -80,7 +80,7 @@ void CrossPointWebServerActivity::onExit() {
// Stop DNS server if running (AP mode) // Stop DNS server if running (AP mode)
if (dnsServer) { if (dnsServer) {
Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); LOG_DBG("WEBACT", "Stopping DNS server...");
dnsServer->stop(); dnsServer->stop();
delete dnsServer; delete dnsServer;
dnsServer = nullptr; dnsServer = nullptr;
@@ -91,39 +91,39 @@ void CrossPointWebServerActivity::onExit() {
// Disconnect WiFi gracefully // Disconnect WiFi gracefully
if (isApMode) { if (isApMode) {
Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); LOG_DBG("WEBACT", "Stopping WiFi AP...");
WiFi.softAPdisconnect(true); WiFi.softAPdisconnect(true);
} else { } else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); LOG_DBG("WEBACT", "Disconnecting WiFi (graceful)...");
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
} }
delay(30); // Allow disconnect frame to be sent delay(30); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); LOG_DBG("WEBACT", "Setting WiFi mode OFF...");
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
delay(30); // Allow WiFi hardware to power down delay(30); // Allow WiFi hardware to power down
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap after WiFi disconnect: %d bytes", ESP.getFreeHeap());
// Acquire mutex before deleting task // Acquire mutex before deleting task
Serial.printf("[%lu] [WEBACT] Acquiring rendering mutex before task deletion...\n", millis()); LOG_DBG("WEBACT", "Acquiring rendering mutex before task deletion...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task // Delete the display task
Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis()); LOG_DBG("WEBACT", "Deleting display task...");
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr; displayTaskHandle = nullptr;
Serial.printf("[%lu] [WEBACT] Display task deleted\n", millis()); LOG_DBG("WEBACT", "Display task deleted");
} }
// Delete the mutex // Delete the mutex
Serial.printf("[%lu] [WEBACT] Deleting mutex...\n", millis()); LOG_DBG("WEBACT", "Deleting mutex...");
vSemaphoreDelete(renderingMutex); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis()); LOG_DBG("WEBACT", "Mutex deleted");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onExit end: %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
@@ -133,7 +133,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
} else if (mode == NetworkMode::CREATE_HOTSPOT) { } else if (mode == NetworkMode::CREATE_HOTSPOT) {
modeName = "Create Hotspot"; modeName = "Create Hotspot";
} }
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); LOG_DBG("WEBACT", "Network mode selected: %s", modeName);
networkMode = mode; networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT); isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
@@ -155,11 +155,11 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
if (mode == NetworkMode::JOIN_NETWORK) { if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection // STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); LOG_DBG("WEBACT", "Turning on WiFi (STA mode)...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION; state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("WEBACT", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} else { } else {
@@ -171,7 +171,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
} }
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); LOG_DBG("WEBACT", "WifiSelectionActivity completed, connected=%d", connected);
if (connected) { if (connected) {
// Get connection info before exiting subactivity // Get connection info before exiting subactivity
@@ -183,7 +183,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
// Start mDNS for hostname resolution // Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) { if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME);
} }
// Start the web server // Start the web server
@@ -199,8 +199,8 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
} }
void CrossPointWebServerActivity::startAccessPoint() { void CrossPointWebServerActivity::startAccessPoint() {
Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); LOG_DBG("WEBACT", "Starting Access Point mode...");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap before AP start: %d bytes", ESP.getFreeHeap());
// Configure and start the AP // Configure and start the AP
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
@@ -216,7 +216,7 @@ void CrossPointWebServerActivity::startAccessPoint() {
} }
if (!apStarted) { if (!apStarted) {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis()); LOG_ERR("WEBACT", "ERROR: Failed to start Access Point!");
onGoBack(); onGoBack();
return; return;
} }
@@ -230,15 +230,15 @@ void CrossPointWebServerActivity::startAccessPoint() {
connectedIP = ipStr; connectedIP = ipStr;
connectedSSID = AP_SSID; connectedSSID = AP_SSID;
Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis()); LOG_DBG("WEBACT", "Access Point started!");
Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID); LOG_DBG("WEBACT", "SSID: %s", AP_SSID);
Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str()); LOG_DBG("WEBACT", "IP: %s", connectedIP.c_str());
// Start mDNS for hostname resolution // Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) { if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME);
} else { } else {
Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis()); LOG_DBG("WEBACT", "WARNING: mDNS failed to start");
} }
// Start DNS server for captive portal behavior // Start DNS server for captive portal behavior
@@ -246,16 +246,16 @@ void CrossPointWebServerActivity::startAccessPoint() {
dnsServer = new DNSServer(); dnsServer = new DNSServer();
dnsServer->setErrorReplyCode(DNSReplyCode::NoError); dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
dnsServer->start(DNS_PORT, "*", apIP); dnsServer->start(DNS_PORT, "*", apIP);
Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis()); LOG_DBG("WEBACT", "DNS server started for captive portal");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap after AP start: %d bytes", ESP.getFreeHeap());
// Start the web server // Start the web server
startWebServer(); startWebServer();
} }
void CrossPointWebServerActivity::startWebServer() { void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); LOG_DBG("WEBACT", "Starting web server...");
// Create the web server instance // Create the web server instance
webServer.reset(new CrossPointWebServer()); webServer.reset(new CrossPointWebServer());
@@ -263,16 +263,16 @@ void CrossPointWebServerActivity::startWebServer() {
if (webServer->isRunning()) { if (webServer->isRunning()) {
state = WebServerActivityState::SERVER_RUNNING; state = WebServerActivityState::SERVER_RUNNING;
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis()); LOG_DBG("WEBACT", "Web server started successfully");
// Force an immediate render since we're transitioning from a subactivity // Force an immediate render since we're transitioning from a subactivity
// that had its own rendering task. We need to make sure our display is shown. // that had its own rendering task. We need to make sure our display is shown.
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
render(); render();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis()); LOG_DBG("WEBACT", "Rendered File Transfer screen");
} else { } else {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis()); LOG_ERR("WEBACT", "ERROR: Failed to start web server!");
webServer.reset(); webServer.reset();
// Go back on error // Go back on error
onGoBack(); onGoBack();
@@ -281,9 +281,9 @@ void CrossPointWebServerActivity::startWebServer() {
void CrossPointWebServerActivity::stopWebServer() { void CrossPointWebServerActivity::stopWebServer() {
if (webServer && webServer->isRunning()) { if (webServer && webServer->isRunning()) {
Serial.printf("[%lu] [WEBACT] Stopping web server...\n", millis()); LOG_DBG("WEBACT", "Stopping web server...");
webServer->stop(); webServer->stop();
Serial.printf("[%lu] [WEBACT] Web server stopped\n", millis()); LOG_DBG("WEBACT", "Web server stopped");
} }
webServer.reset(); webServer.reset();
} }
@@ -309,7 +309,7 @@ void CrossPointWebServerActivity::loop() {
lastWifiCheck = millis(); lastWifiCheck = millis();
const wl_status_t wifiStatus = WiFi.status(); const wl_status_t wifiStatus = WiFi.status();
if (wifiStatus != WL_CONNECTED) { if (wifiStatus != WL_CONNECTED) {
Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); LOG_DBG("WEBACT", "WiFi disconnected! Status: %d", wifiStatus);
// Show error and exit gracefully // Show error and exit gracefully
state = WebServerActivityState::SHUTTING_DOWN; state = WebServerActivityState::SHUTTING_DOWN;
updateRequired = true; updateRequired = true;
@@ -318,7 +318,7 @@ void CrossPointWebServerActivity::loop() {
// Log weak signal warnings // Log weak signal warnings
const int rssi = WiFi.RSSI(); const int rssi = WiFi.RSSI();
if (rssi < -75) { if (rssi < -75) {
Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); LOG_DBG("WEBACT", "Warning: Weak WiFi signal: %d dBm", rssi);
} }
} }
} }
@@ -329,8 +329,7 @@ void CrossPointWebServerActivity::loop() {
// Log if there's a significant gap between handleClient calls (>100ms) // Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), LOG_DBG("WEBACT", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
timeSinceLastHandleClient);
} }
// Reset watchdog BEFORE processing - HTTP header parsing can be slow // Reset watchdog BEFORE processing - HTTP header parsing can be slow
@@ -401,7 +400,7 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std
// The structure to manage the QR code // The structure to manage the QR code
QRCode qrcode; QRCode qrcode;
uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); LOG_DBG("WEBACT", "QR Code (%lu): %s", data.length(), data.c_str());
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
const uint8_t px = 6; // pixels per module const uint8_t px = 6; // pixels per module

View File

@@ -1,6 +1,7 @@
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <map> #include <map>
@@ -62,7 +63,7 @@ void WifiSelectionActivity::onEnter() {
if (!lastSsid.empty()) { if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid); const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) { if (cred) {
Serial.printf("[%lu] [WIFI] Attempting to auto-connect to %s\n", millis(), lastSsid.c_str()); LOG_DBG("WIFI", "Attempting to auto-connect to %s", lastSsid.c_str());
selectedSSID = cred->ssid; selectedSSID = cred->ssid;
enteredPassword = cred->password; enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty(); selectedRequiresPassword = !cred->password.empty();
@@ -82,12 +83,12 @@ void WifiSelectionActivity::onEnter() {
void WifiSelectionActivity::onExit() { void WifiSelectionActivity::onExit() {
Activity::onExit(); Activity::onExit();
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap at onExit start: %d bytes", ESP.getFreeHeap());
// Stop any ongoing WiFi scan // Stop any ongoing WiFi scan
Serial.printf("[%lu] [WIFI] Deleting WiFi scan...\n", millis()); LOG_DBG("WIFI", "Deleting WiFi scan...");
WiFi.scanDelete(); WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap after scanDelete: %d bytes", ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity // Note: We do NOT disconnect WiFi here - the parent activity
// (CrossPointWebServerActivity) manages WiFi connection state. We just clean // (CrossPointWebServerActivity) manages WiFi connection state. We just clean
@@ -95,25 +96,25 @@ void WifiSelectionActivity::onExit() {
// Acquire mutex before deleting task to ensure task isn't using it // Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted // This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis()); LOG_DBG("WIFI", "Acquiring rendering mutex before task deletion...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it // Delete the display task (we now hold the mutex, so task is blocked if it
// needs it) // needs it)
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis()); LOG_DBG("WIFI", "Deleting display task...");
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr; displayTaskHandle = nullptr;
Serial.printf("[%lu] [WIFI] Display task deleted\n", millis()); LOG_DBG("WIFI", "Display task deleted");
} }
// Now safe to delete the mutex since we own it // Now safe to delete the mutex since we own it
Serial.printf("[%lu] [WIFI] Deleting mutex...\n", millis()); LOG_DBG("WIFI", "Deleting mutex...");
vSemaphoreDelete(renderingMutex); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis()); LOG_DBG("WIFI", "Mutex deleted");
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap at onExit end: %d bytes", ESP.getFreeHeap());
} }
void WifiSelectionActivity::startWifiScan() { void WifiSelectionActivity::startWifiScan() {
@@ -211,8 +212,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
// Use saved password - connect directly // Use saved password - connect directly
enteredPassword = savedCred->password; enteredPassword = savedCred->password;
usedSavedPassword = true; usedSavedPassword = true;
Serial.printf("[%lu] [WiFi] Using saved password for %s, length: %zu\n", millis(), selectedSSID.c_str(), LOG_DBG("WiFi", "Using saved password for %s, length: %zu", selectedSSID.c_str(), enteredPassword.size());
enteredPassword.size());
attemptConnection(); attemptConnection();
return; return;
} }
@@ -290,10 +290,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
updateRequired = true; updateRequired = true;
} else { } else {
// Using saved password or open network - complete immediately // Using saved password or open network - complete immediately
Serial.printf( LOG_DBG("WIFI",
"[%lu] [WIFI] Connected with saved/open credentials, " "Connected with saved/open credentials, "
"completing immediately\n", "completing immediately");
millis());
onComplete(true); onComplete(true);
} }
return; return;

View File

@@ -450,8 +450,16 @@ void DictionaryDefinitionActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::Back) || if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (onDone) {
onDone();
} else {
onBack();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); onBack();
return; return;
} }
@@ -491,8 +499,8 @@ void DictionaryDefinitionActivity::renderScreen() {
renderer.getScreenHeight() - 50, pageInfo.c_str()); renderer.getScreenHeight() - 50, pageInfo.c_str());
} }
// Button hints (bottom face buttons — hide Confirm stub like Home Screen) // Button hints (bottom face buttons)
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB"); const auto labels = mappedInput.mapLabels("\xC2\xAB Back", onDone ? "Done" : "", "\xC2\xAB Page", "Page \xC2\xBB");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints (drawn in portrait coordinates for correct placement) // Side button hints (drawn in portrait coordinates for correct placement)

View File

@@ -14,13 +14,15 @@ class DictionaryDefinitionActivity final : public Activity {
public: public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId, const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, const std::function<void()>& onBack) uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone = nullptr)
: Activity("DictionaryDefinition", renderer, mappedInput), : Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword), headword(headword),
definition(definition), definition(definition),
readerFontId(readerFontId), readerFontId(readerFontId),
orientation(orientation), orientation(orientation),
onBack(onBack) {} onBack(onBack),
onDone(onDone) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -53,6 +55,7 @@ class DictionaryDefinitionActivity final : public Activity {
int readerFontId; int readerFontId;
uint8_t orientation; uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void()> onDone;
std::vector<std::vector<Segment>> wrappedLines; std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0; int currentPage = 0;

View File

@@ -0,0 +1,141 @@
#include "DictionarySuggestionsActivity.h"
#include <GfxRenderer.h>
#include "DictionaryDefinitionActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
void DictionarySuggestionsActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionarySuggestionsActivity*>(param);
self->displayTaskLoop();
}
void DictionarySuggestionsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionarySuggestionsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
xTaskCreate(&DictionarySuggestionsActivity::taskTrampoline, "DictSugTask", 4096, this, 1, &displayTaskHandle);
}
void DictionarySuggestionsActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void DictionarySuggestionsActivity::loop() {
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return;
}
if (suggestions.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
}
return;
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(suggestions.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const std::string& selected = suggestions[selectedIndex];
std::string definition = Dictionary::lookup(selected);
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, selected, definition, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
void DictionarySuggestionsActivity::renderScreen() {
renderer.clearScreen();
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int leftPadding = contentX + metrics.contentSidePadding;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Header
GUI.drawHeader(
renderer,
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Did you mean?");
// Subtitle: the original word (manual, below header)
const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5;
std::string subtitle = "\"" + originalWord + "\" not found";
renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str());
// Suggestion list
const int listTop = subtitleY + 25;
const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
GUI.drawList(
renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex,
[this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr);
// Button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class DictionarySuggestionsActivity final : public ActivityWithSubactivity {
public:
explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& originalWord, const std::vector<std::string>& suggestions,
int readerFontId, uint8_t orientation, const std::string& cachePath,
const std::function<void()>& onBack, const std::function<void()>& onDone)
: ActivityWithSubactivity("DictionarySuggestions", renderer, mappedInput),
originalWord(originalWord),
suggestions(suggestions),
readerFontId(readerFontId),
orientation(orientation),
cachePath(cachePath),
onBack(onBack),
onDone(onDone) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string originalWord;
std::vector<std::string> suggestions;
int readerFontId;
uint8_t orientation;
std::string cachePath;
const std::function<void()> onBack;
const std::function<void()> onDone;
int selectedIndex = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -6,6 +6,8 @@
#include <climits> #include <climits>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -19,7 +21,7 @@ void DictionaryWordSelectActivity::taskTrampoline(void* param) {
void DictionaryWordSelectActivity::displayTaskLoop() { void DictionaryWordSelectActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired && !subActivity) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen(); renderScreen();
@@ -30,7 +32,7 @@ void DictionaryWordSelectActivity::displayTaskLoop() {
} }
void DictionaryWordSelectActivity::onEnter() { void DictionaryWordSelectActivity::onEnter() {
Activity::onEnter(); ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
extractWords(); extractWords();
mergeHyphenatedWords(); mergeHyphenatedWords();
@@ -43,7 +45,7 @@ void DictionaryWordSelectActivity::onEnter() {
} }
void DictionaryWordSelectActivity::onExit() { void DictionaryWordSelectActivity::onExit() {
Activity::onExit(); ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
@@ -82,9 +84,55 @@ void DictionaryWordSelectActivity::extractWords() {
while (wordIt != wordList.end() && xIt != xPosList.end()) { while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft; int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop; int16_t screenY = line->yPos + marginTop;
int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str()); const std::string& wordText = *wordIt;
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
std::vector<size_t> splitStarts;
size_t partStart = 0;
for (size_t i = 0; i < wordText.size();) {
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(wordText[i + 2]) == 0x94)) {
if (i > partStart) splitStarts.push_back(partStart);
i += 3;
partStart = i;
} else {
i++;
}
}
if (partStart < wordText.size()) splitStarts.push_back(partStart);
if (splitStarts.size() <= 1 && partStart == 0) {
// No dashes found -- add as a single word
int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str());
words.push_back({wordText, screenX, screenY, wordWidth, 0});
} else {
// Add each part as a separate selectable word
for (size_t si = 0; si < splitStarts.size(); si++) {
size_t start = splitStarts[si];
size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size();
// Find actual end by trimming any trailing dash bytes
size_t textEnd = end;
while (textEnd > start && textEnd <= wordText.size()) {
if (textEnd >= 3 && static_cast<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
static_cast<uint8_t>(wordText[textEnd - 1]) == 0x94)) {
textEnd -= 3;
} else {
break;
}
}
std::string part = wordText.substr(start, textEnd - start);
if (part.empty()) continue;
std::string prefix = wordText.substr(0, start);
int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str());
int16_t partWidth = renderer.getTextWidth(fontId, part.c_str());
words.push_back({part, static_cast<int16_t>(screenX + offsetX), screenY, partWidth, 0});
}
}
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
++wordIt; ++wordIt;
++xIt; ++xIt;
} }
@@ -146,11 +194,53 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() {
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
} }
// Cross-page hyphenation: last word on page + first word of next page
if (!nextPageFirstWord.empty() && !rows.empty()) {
int lastWordIdx = rows.back().wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (!lastWord.empty()) {
bool endsWithHyphen = false;
if (lastWord.back() == '-') {
endsWithHyphen = true;
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
endsWithHyphen = true;
}
if (endsWithHyphen) {
std::string firstPart = lastWord;
if (firstPart.back() == '-') {
firstPart.pop_back();
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
firstPart.erase(firstPart.size() - 2);
}
std::string merged = firstPart + nextPageFirstWord;
words[lastWordIdx].lookupText = merged;
}
}
}
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation) // Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end()); rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
} }
void DictionaryWordSelectActivity::loop() { void DictionaryWordSelectActivity::loop() {
// Delegate to subactivity (definition/suggestions screen) if active
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onBack();
}
return;
}
if (words.empty()) { if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); onBack();
@@ -297,16 +387,40 @@ void DictionaryWordSelectActivity::loop() {
return; return;
} }
if (definition.empty()) { LookupHistory::addWord(cachePath, cleaned);
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH); if (!definition.empty()) {
vTaskDelay(1500 / portTICK_PERIOD_MS); enterNewActivity(new DictionaryDefinitionActivity(
updateRequired = true; renderer, mappedInput, cleaned, definition, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return; return;
} }
LookupHistory::addWord(cachePath, cleaned); // Try stem variants (e.g., "jumped" -> "jump")
onLookup(cleaned, definition); auto stems = Dictionary::getStemVariants(cleaned);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, stem, stemDef, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
}
// Find similar words for suggestions
auto similar = Dictionary::findSimilar(cleaned, 6);
if (!similar.empty()) {
enterNewActivity(new DictionarySuggestionsActivity(
renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
return; return;
} }

View File

@@ -9,16 +9,16 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "../Activity.h" #include "../ActivityWithSubactivity.h"
class DictionaryWordSelectActivity final : public Activity { class DictionaryWordSelectActivity final : public ActivityWithSubactivity {
public: public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop, std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation, const std::string& cachePath, uint8_t orientation,
const std::function<void()>& onBack, const std::function<void()>& onBack,
const std::function<void(const std::string&, const std::string&)>& onLookup) const std::string& nextPageFirstWord = "")
: Activity("DictionaryWordSelect", renderer, mappedInput), : ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)), page(std::move(page)),
fontId(fontId), fontId(fontId),
marginLeft(marginLeft), marginLeft(marginLeft),
@@ -26,7 +26,7 @@ class DictionaryWordSelectActivity final : public Activity {
cachePath(cachePath), cachePath(cachePath),
orientation(orientation), orientation(orientation),
onBack(onBack), onBack(onBack),
onLookup(onLookup) {} nextPageFirstWord(nextPageFirstWord) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -58,13 +58,15 @@ class DictionaryWordSelectActivity final : public Activity {
std::string cachePath; std::string cachePath;
uint8_t orientation; uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void(const std::string&, const std::string&)> onLookup; std::string nextPageFirstWord;
std::vector<WordInfo> words; std::vector<WordInfo> words;
std::vector<Row> rows; std::vector<Row> rows;
int currentRow = 0; int currentRow = 0;
int currentWordInRow = 0; int currentWordInRow = 0;
bool updateRequired = false; bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;

View File

@@ -4,6 +4,9 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@@ -18,7 +21,6 @@
#include "fontIds.h" #include "fontIds.h"
#include "util/BookmarkStore.h" #include "util/BookmarkStore.h"
#include "util/Dictionary.h" #include "util/Dictionary.h"
#include "util/LookupHistory.h"
namespace { namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -88,7 +90,7 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8); nextPageNumber = data[2] + (data[3] << 8);
cachedSpineIndex = currentSpineIndex; cachedSpineIndex = currentSpineIndex;
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber); LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber);
} }
if (dataSize == 6) { if (dataSize == 6) {
cachedChapterTotalPageCount = data[4] + (data[5] << 8); cachedChapterTotalPageCount = data[4] + (data[5] << 8);
@@ -101,8 +103,7 @@ void EpubReaderActivity::onEnter() {
int textSpineIndex = epub->getSpineIndexForTextReference(); int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) { if (textSpineIndex != 0) {
currentSpineIndex = textSpineIndex; currentSpineIndex = textSpineIndex;
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(), LOG_DBG("ERS", "Opened for first time, navigating to text reference at index %d", textSpineIndex);
textSpineIndex);
} }
} }
@@ -127,15 +128,31 @@ void EpubReaderActivity::onEnter() {
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) { if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
epub->generateCoverBmp(false); epub->generateCoverBmp(false);
// Fallback: generate placeholder if real cover extraction failed
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress(); updateProgress();
} }
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) { if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
epub->generateCoverBmp(true); epub->generateCoverBmp(true);
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress(); updateProgress();
} }
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) { if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]); epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
epub->getAuthor(), thumbWidth, thumbHeight);
}
updateProgress(); updateProgress();
} }
} }
@@ -242,7 +259,7 @@ void EpubReaderActivity::loop() {
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderMenuActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, hasDictionary, isBookmarked, SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
[this](const uint8_t orientation) { onReaderMenuBack(orientation); }, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -665,24 +682,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
const std::string bookCachePath = epub->getCachePath(); const std::string bookCachePath = epub->getCachePath();
const uint8_t currentOrientation = SETTINGS.orientation; const uint8_t currentOrientation = SETTINGS.orientation;
// Get first word of next page for cross-page hyphenation
std::string nextPageFirstWord;
if (section && section->currentPage < section->pageCount - 1) {
int savedPage = section->currentPage;
section->currentPage = savedPage + 1;
auto nextPage = section->loadPageFromSectionFile();
section->currentPage = savedPage;
if (nextPage && !nextPage->elements.empty()) {
const auto* firstLine = static_cast<const PageLine*>(nextPage->elements[0].get());
if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) {
nextPageFirstWord = firstLine->getBlock()->getWords().front();
}
}
}
exitActivity(); exitActivity();
if (pageForLookup) { if (pageForLookup) {
enterNewActivity(new DictionaryWordSelectActivity( enterNewActivity(new DictionaryWordSelectActivity(
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop, renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
bookCachePath, currentOrientation, bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
[this]() {
// On back from word select
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword,
const std::string& definition) {
// On successful lookup - show definition
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition,
readerFontId, currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
} }
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -690,36 +710,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: { case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
const std::string bookCachePath = epub->getCachePath();
const int readerFontId = SETTINGS.getReaderFontId();
const uint8_t currentOrientation = SETTINGS.orientation;
exitActivity(); exitActivity();
enterNewActivity(new LookedUpWordsActivity( enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, bookCachePath, renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
[this]() { [this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
// On back from looked up words
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) {
// Look up the word and show definition with progress bar
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
std::string definition = Dictionary::lookup(
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
return;
}
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId,
currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
break; break;
} }
@@ -776,6 +771,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
break; break;
} }
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
break;
} }
} }
@@ -862,8 +861,10 @@ void EpubReaderActivity::renderScreen() {
} }
if (!section) { if (!section) {
loadingSection = true;
const auto filepath = epub->getSpineItem(currentSpineIndex).href; const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
@@ -872,19 +873,20 @@ void EpubReaderActivity::renderScreen() {
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); LOG_DBG("ERS", "Cache not found, building...");
const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); }; const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset(); section.reset();
loadingSection = false;
return; return;
} }
} else { } else {
Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis()); LOG_DBG("ERS", "Cache found, skipping build...");
} }
if (nextPageNumber == UINT16_MAX) { if (nextPageNumber == UINT16_MAX) {
@@ -913,12 +915,14 @@ void EpubReaderActivity::renderScreen() {
section->currentPage = newPage; section->currentPage = newPage;
pendingPercentJump = false; pendingPercentJump = false;
} }
loadingSection = false;
} }
renderer.clearScreen(); renderer.clearScreen();
if (section->pageCount == 0) { if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis()); LOG_DBG("ERS", "No pages to render");
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -926,7 +930,7 @@ void EpubReaderActivity::renderScreen() {
} }
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -936,14 +940,14 @@ void EpubReaderActivity::renderScreen() {
{ {
auto p = section->loadPageFromSectionFile(); auto p = section->loadPageFromSectionFile();
if (!p) { if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
section->clearCache(); section->clearCache();
section.reset(); section.reset();
return renderScreen(); return renderScreen();
} }
const auto start = millis(); const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
} }
saveProgress(currentSpineIndex, section->currentPage, section->pageCount); saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
} }
@@ -960,9 +964,9 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
data[5] = (pageCount >> 8) & 0xFF; data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6); f.write(data, 6);
f.close(); f.close();
Serial.printf("[%lu] [ERS] Progress saved: Chapter %d, Page %d\n", millis(), spineIndex, currentPage); LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d", spineIndex, currentPage);
} else { } else {
Serial.printf("[%lu] [ERS] Could not save progress!\n", millis()); LOG_ERR("ERS", "Could not save progress!");
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,

View File

@@ -5,7 +5,6 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "DictionaryDefinitionActivity.h"
#include "DictionaryWordSelectActivity.h" #include "DictionaryWordSelectActivity.h"
#include "EpubReaderMenuActivity.h" #include "EpubReaderMenuActivity.h"
#include "LookedUpWordsActivity.h" #include "LookedUpWordsActivity.h"
@@ -30,6 +29,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
bool pendingGoHome = false; // Defer go home to avoid race condition with display task bool pendingGoHome = false; // Defer go home to avoid race condition with display task
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
@@ -56,4 +56,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;
// Defer low-power mode and auto-sleep while a section is loading/building.
// !section covers the period before the Section object is created (including
// cover prerendering in onEnter). loadingSection covers the full !section block
// in renderScreen (including createSectionFile), during which section is non-null
// but the section file is still being built.
bool preventAutoSleep() override { return !section || loadingSection; }
}; };

View File

@@ -68,6 +68,14 @@ void EpubReaderMenuActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (selectedAction == MenuAction::LETTERBOX_FILL) {
// Cycle through: Default -> Dithered -> Solid -> None -> Default ...
int idx = (letterboxFillToIndex() + 1) % LETTERBOX_FILL_OPTION_COUNT;
pendingLetterboxFill = indexToLetterboxFill(idx);
saveLetterboxFill();
updateRequired = true;
return;
}
// 1. Capture the callback and action locally // 1. Capture the callback and action locally
auto actionCallback = onAction; auto actionCallback = onAction;
@@ -139,6 +147,12 @@ void EpubReaderMenuActivity::renderScreen() {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value); const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected); renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
} }
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
// Render current letterbox fill value on the right edge of the content area.
const auto value = letterboxFillLabels[letterboxFillToIndex()];
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
} }
// Footer / Hints // Footer / Hints

View File

@@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/BookSettings.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
@@ -20,6 +21,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
LOOKUP, LOOKUP,
LOOKED_UP_WORDS, LOOKED_UP_WORDS,
ROTATE_SCREEN, ROTATE_SCREEN,
LETTERBOX_FILL,
SELECT_CHAPTER, SELECT_CHAPTER,
GO_TO_BOOKMARK, GO_TO_BOOKMARK,
GO_TO_PERCENT, GO_TO_PERCENT,
@@ -32,18 +34,23 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent, const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const bool hasDictionary, const uint8_t currentOrientation, const bool hasDictionary,
const bool isBookmarked, const bool isBookmarked, const std::string& bookCachePath,
const std::function<void(uint8_t)>& onBack, const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction) const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)), menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title), title(title),
pendingOrientation(currentOrientation), pendingOrientation(currentOrientation),
bookCachePath(bookCachePath),
currentPage(currentPage), currentPage(currentPage),
totalPages(totalPages), totalPages(totalPages),
bookProgressPercent(bookProgressPercent), bookProgressPercent(bookProgressPercent),
onBack(onBack), onBack(onBack),
onAction(onAction) {} onAction(onAction) {
// Load per-book settings to initialize the letterbox fill override
auto bookSettings = BookSettings::load(bookCachePath);
pendingLetterboxFill = bookSettings.letterboxFillOverride;
}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -65,6 +72,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string title = "Reader Menu"; std::string title = "Reader Menu";
uint8_t pendingOrientation = 0; uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
std::string bookCachePath;
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
const std::vector<const char*> letterboxFillLabels = {"Default", "Dithered", "Solid", "None"};
int currentPage = 0; int currentPage = 0;
int totalPages = 0; int totalPages = 0;
int bookProgressPercent = 0; int bookProgressPercent = 0;
@@ -72,6 +84,25 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
const std::function<void(uint8_t)> onBack; const std::function<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction; const std::function<void(MenuAction)> onAction;
// Map the internal override value to an index into letterboxFillLabels.
int letterboxFillToIndex() const {
if (pendingLetterboxFill == BookSettings::USE_GLOBAL) return 0; // "Default"
return pendingLetterboxFill + 1; // 0->1 (Dithered), 1->2 (Solid), 2->3 (None)
}
// Map an index from letterboxFillLabels back to an override value.
static uint8_t indexToLetterboxFill(int index) {
if (index == 0) return BookSettings::USE_GLOBAL;
return static_cast<uint8_t>(index - 1);
}
// Save the current letterbox fill override to the book's settings file.
void saveLetterboxFill() const {
auto bookSettings = BookSettings::load(bookCachePath);
bookSettings.letterboxFillOverride = pendingLetterboxFill;
BookSettings::save(bookCachePath, bookSettings);
}
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) { static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
std::vector<MenuItem> items; std::vector<MenuItem> items;
if (isBookmarked) { if (isBookmarked) {
@@ -84,6 +115,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"}); items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
} }
items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"}); items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"});
items.push_back({MenuAction::LETTERBOX_FILL, "Letterbox Fill"});
items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"}); items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"});
items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"}); items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"});
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"}); items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});

View File

@@ -1,6 +1,7 @@
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_sntp.h> #include <esp_sntp.h>
@@ -32,9 +33,9 @@ void syncTimeWithNTP() {
} }
if (retry < maxRetries) { if (retry < maxRetries) {
Serial.printf("[%lu] [KOSync] NTP time synced\n", millis()); LOG_DBG("KOSync", "NTP time synced");
} else { } else {
Serial.printf("[%lu] [KOSync] NTP sync timeout, using fallback\n", millis()); LOG_DBG("KOSync", "NTP sync timeout, using fallback");
} }
} }
} // namespace } // namespace
@@ -48,12 +49,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
if (!success) { if (!success) {
Serial.printf("[%lu] [KOSync] WiFi connection failed, exiting\n", millis()); LOG_DBG("KOSync", "WiFi connection failed, exiting");
onCancel(); onCancel();
return; return;
} }
Serial.printf("[%lu] [KOSync] WiFi connected, starting sync\n", millis()); LOG_DBG("KOSync", "WiFi connected, starting sync");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = SYNCING; state = SYNCING;
@@ -88,7 +89,7 @@ void KOReaderSyncActivity::performSync() {
return; return;
} }
Serial.printf("[%lu] [KOSync] Document hash: %s\n", millis(), documentHash.c_str()); LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
statusMessage = "Fetching remote progress..."; statusMessage = "Fetching remote progress...";
@@ -188,12 +189,12 @@ void KOReaderSyncActivity::onEnter() {
} }
// Turn on WiFi // Turn on WiFi
Serial.printf("[%lu] [KOSync] Turning on WiFi...\n", millis()); LOG_DBG("KOSync", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Check if already connected // Check if already connected
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[%lu] [KOSync] Already connected to WiFi\n", millis()); LOG_DBG("KOSync", "Already connected to WiFi");
state = SYNCING; state = SYNCING;
statusMessage = "Syncing time..."; statusMessage = "Syncing time...";
updateRequired = true; updateRequired = true;
@@ -216,7 +217,7 @@ void KOReaderSyncActivity::onEnter() {
} }
// Launch WiFi selection subactivity // Launch WiFi selection subactivity
Serial.printf("[%lu] [KOSync] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("KOSync", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} }

View File

@@ -4,9 +4,12 @@
#include <algorithm> #include <algorithm>
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h" #include "util/LookupHistory.h"
void LookedUpWordsActivity::taskTrampoline(void* param) { void LookedUpWordsActivity::taskTrampoline(void* param) {
@@ -30,6 +33,7 @@ void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
words = LookupHistory::load(cachePath); words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
updateRequired = true; updateRequired = true;
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle); xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
} }
@@ -48,6 +52,16 @@ void LookedUpWordsActivity::onExit() {
void LookedUpWordsActivity::loop() { void LookedUpWordsActivity::loop() {
if (subActivity) { if (subActivity) {
subActivity->loop(); subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return; return;
} }
@@ -94,18 +108,68 @@ void LookedUpWordsActivity::loop() {
return; return;
} }
buttonNavigator.onNext([this] { const int totalItems = static_cast<int>(words.size());
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size())); const int pageItems = getPageItems();
buttonNavigator.onNextRelease([this, totalItems] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
updateRequired = true; updateRequired = true;
}); });
buttonNavigator.onPrevious([this] { buttonNavigator.onPreviousRelease([this, totalItems] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size())); selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
updateRequired = true; updateRequired = true;
}); });
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onSelectWord(words[selectedIndex]); const std::string& headword = words[selectedIndex];
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
std::string definition = Dictionary::lookup(
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, headword, definition, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
// Try stem variants
auto stems = Dictionary::getStemVariants(headword);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, stem, stemDef, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
}
// Show similar word suggestions
auto similar = Dictionary::findSimilar(headword, 6);
if (!similar.empty()) {
enterNewActivity(new DictionarySuggestionsActivity(
renderer, mappedInput, headword, similar, readerFontId, orientation, cachePath,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
return; return;
} }
@@ -115,39 +179,46 @@ void LookedUpWordsActivity::loop() {
} }
} }
int LookedUpWordsActivity::getPageItems() const {
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
return std::max(1, contentHeight / metrics.listRowHeight);
}
void LookedUpWordsActivity::renderScreen() { void LookedUpWordsActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
constexpr int sidePadding = 20; const auto orient = renderer.getOrientation();
constexpr int titleY = 15; const auto metrics = UITheme::getInstance().getMetrics();
constexpr int startY = 60; const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
constexpr int lineHeight = 30; const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Title // Header
const int titleX = GUI.drawHeader(
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2; renderer,
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD); Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Lookup History");
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (words.empty()) { if (words.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet"); renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
} else { } else {
const int screenHeight = renderer.getScreenHeight(); GUI.drawList(
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight); renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
const int pageStart = selectedIndex / pageItems * pageItems; [this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
for (int i = 0; i < pageItems; i++) {
int idx = pageStart + i;
if (idx >= static_cast<int>(words.size())) break;
const int displayY = startY + i * lineHeight;
const bool isSelected = (idx == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight);
}
renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected);
}
} }
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) { if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
@@ -161,12 +232,12 @@ void LookedUpWordsActivity::renderScreen() {
std::string msg = "Delete '" + displayWord + "'?"; std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15; constexpr int margin = 15;
constexpr int popupY = 200; const int popupY = 200 + hintGutterHeight;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD); const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2; const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2; const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2; const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true); renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false); renderer.fillRect(x, popupY, w, h, false);
@@ -183,12 +254,14 @@ void LookedUpWordsActivity::renderScreen() {
if (!words.empty()) { if (!words.empty()) {
const char* deleteHint = "Hold select to delete"; const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint); const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70, const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
renderer.drawText(SMALL_FONT_ID, hintX,
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
deleteHint); deleteHint);
} }
// Normal button hints // Normal button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v"); const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@@ -13,12 +13,14 @@
class LookedUpWordsActivity final : public ActivityWithSubactivity { class LookedUpWordsActivity final : public ActivityWithSubactivity {
public: public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath, explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
const std::function<void()>& onBack, int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
const std::function<void(const std::string&)>& onSelectWord) const std::function<void()>& onDone)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput), : ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath), cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack), onBack(onBack),
onSelectWord(onSelectWord) {} onDone(onDone) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -26,12 +28,16 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
private: private:
std::string cachePath; std::string cachePath;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void(const std::string&)> onSelectWord; const std::function<void()> onDone;
std::vector<std::string> words; std::vector<std::string> words;
int selectedIndex = 0; int selectedIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator; ButtonNavigator buttonNavigator;
// Delete confirmation state // Delete confirmation state
@@ -42,6 +48,7 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
int getPageItems() const;
void renderScreen(); void renderScreen();
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();

View File

@@ -30,7 +30,7 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) { std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!Storage.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -39,13 +39,13 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
return epub; return epub;
} }
Serial.printf("[%lu] [ ] Failed to load epub\n", millis()); LOG_ERR("READER", "Failed to load epub");
return nullptr; return nullptr;
} }
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) { std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
if (!Storage.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -54,13 +54,13 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
return xtc; return xtc;
} }
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis()); LOG_ERR("READER", "Failed to load XTC");
return nullptr; return nullptr;
} }
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) { std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
if (!Storage.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -69,7 +69,7 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
return txt; return txt;
} }
Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); LOG_ERR("READER", "Failed to load TXT");
return nullptr; return nullptr;
} }

View File

@@ -5,6 +5,8 @@
#include <Serialization.h> #include <Serialization.h>
#include <Utf8.h> #include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
@@ -57,13 +59,43 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir(); txt->setupCacheDir();
// Prerender cover on first open so the Sleep screen is instant. // Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// generateCoverBmp() is a no-op if the file already exists, so this only does work once. // Each generate* call is a no-op if the file already exists, so this only does work once.
// TXT has no thumbnail support, so only the sleep screen cover is generated. {
if (!Storage.exists(txt->getCoverBmpPath().c_str())) { int totalSteps = 0;
Rect popupRect = GUI.drawPopup(renderer, "Preparing book..."); if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++;
txt->generateCoverBmp(); for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
GUI.fillPopupProgress(renderer, popupRect, 100); if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
}
if (totalSteps > 0) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
int completedSteps = 0;
auto updateProgress = [&]() {
completedSteps++;
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
};
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
const bool coverGenerated = txt->generateCoverBmp();
// Fallback: generate placeholder if no cover image was found
if (!coverGenerated) {
PlaceholderCoverGenerator::generate(txt->getCoverBmpPath(), txt->getTitle(), "", 480, 800);
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
// TXT has no native thumbnail generation, always use placeholder
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(txt->getThumbBmpPath(thumbHeight), txt->getTitle(), "", thumbWidth,
thumbHeight);
updateProgress();
}
}
}
} }
// Save current txt as last opened file and add to recent books // Save current txt as last opened file and add to recent books
@@ -71,7 +103,7 @@ void TxtReaderActivity::onEnter() {
auto fileName = filePath.substr(filePath.rfind('/') + 1); auto fileName = filePath.substr(filePath.rfind('/') + 1);
APP_STATE.openEpubPath = filePath; APP_STATE.openEpubPath = filePath;
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(filePath, fileName, "", ""); RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@@ -200,8 +232,7 @@ void TxtReaderActivity::initializeReader() {
linesPerPage = viewportHeight / lineHeight; linesPerPage = viewportHeight / lineHeight;
if (linesPerPage < 1) linesPerPage = 1; if (linesPerPage < 1) linesPerPage = 1;
Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, LOG_DBG("TRS", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage);
linesPerPage);
// Try to load cached page index first // Try to load cached page index first
if (!loadPageIndexCache()) { if (!loadPageIndexCache()) {
@@ -224,7 +255,7 @@ void TxtReaderActivity::buildPageIndex() {
size_t offset = 0; size_t offset = 0;
const size_t fileSize = txt->getFileSize(); const size_t fileSize = txt->getFileSize();
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); LOG_DBG("TRS", "Building page index for %zu bytes...", fileSize);
GUI.drawPopup(renderer, "Indexing..."); GUI.drawPopup(renderer, "Indexing...");
@@ -253,7 +284,7 @@ void TxtReaderActivity::buildPageIndex() {
} }
totalPages = pageOffsets.size(); totalPages = pageOffsets.size();
Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Built page index: %d pages", totalPages);
} }
bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) { bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) {
@@ -268,7 +299,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1)); auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); LOG_ERR("TRS", "Failed to allocate %zu bytes", chunkSize);
return false; return false;
} }
@@ -597,7 +628,7 @@ void TxtReaderActivity::loadProgress() {
if (currentPage < 0) { if (currentPage < 0) {
currentPage = 0; currentPage = 0;
} }
Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); LOG_DBG("TRS", "Loaded progress: page %d/%d", currentPage, totalPages);
} }
f.close(); f.close();
} }
@@ -619,7 +650,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
std::string cachePath = txt->getCachePath() + "/index.bin"; std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f; FsFile f;
if (!Storage.openFileForRead("TRS", cachePath, f)) { if (!Storage.openFileForRead("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); LOG_DBG("TRS", "No page index cache found");
return false; return false;
} }
@@ -627,7 +658,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint32_t magic; uint32_t magic;
serialization::readPod(f, magic); serialization::readPod(f, magic);
if (magic != CACHE_MAGIC) { if (magic != CACHE_MAGIC) {
Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache magic mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -635,7 +666,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint8_t version; uint8_t version;
serialization::readPod(f, version); serialization::readPod(f, version);
if (version != CACHE_VERSION) { if (version != CACHE_VERSION) {
Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); LOG_DBG("TRS", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION);
f.close(); f.close();
return false; return false;
} }
@@ -643,7 +674,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint32_t fileSize; uint32_t fileSize;
serialization::readPod(f, fileSize); serialization::readPod(f, fileSize);
if (fileSize != txt->getFileSize()) { if (fileSize != txt->getFileSize()) {
Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache file size mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -651,7 +682,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t cachedWidth; int32_t cachedWidth;
serialization::readPod(f, cachedWidth); serialization::readPod(f, cachedWidth);
if (cachedWidth != viewportWidth) { if (cachedWidth != viewportWidth) {
Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache viewport width mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -659,7 +690,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t cachedLines; int32_t cachedLines;
serialization::readPod(f, cachedLines); serialization::readPod(f, cachedLines);
if (cachedLines != linesPerPage) { if (cachedLines != linesPerPage) {
Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache lines per page mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -667,7 +698,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t fontId; int32_t fontId;
serialization::readPod(f, fontId); serialization::readPod(f, fontId);
if (fontId != cachedFontId) { if (fontId != cachedFontId) {
Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); LOG_DBG("TRS", "Cache font ID mismatch (%d != %d), rebuilding", fontId, cachedFontId);
f.close(); f.close();
return false; return false;
} }
@@ -675,7 +706,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t margin; int32_t margin;
serialization::readPod(f, margin); serialization::readPod(f, margin);
if (margin != cachedScreenMargin) { if (margin != cachedScreenMargin) {
Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache screen margin mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -683,7 +714,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint8_t alignment; uint8_t alignment;
serialization::readPod(f, alignment); serialization::readPod(f, alignment);
if (alignment != cachedParagraphAlignment) { if (alignment != cachedParagraphAlignment) {
Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache paragraph alignment mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -703,7 +734,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
f.close(); f.close();
totalPages = pageOffsets.size(); totalPages = pageOffsets.size();
Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages);
return true; return true;
} }
@@ -711,7 +742,7 @@ void TxtReaderActivity::savePageIndexCache() const {
std::string cachePath = txt->getCachePath() + "/index.bin"; std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f; FsFile f;
if (!Storage.openFileForWrite("TRS", cachePath, f)) { if (!Storage.openFileForWrite("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); LOG_ERR("TRS", "Failed to save page index cache");
return; return;
} }
@@ -732,5 +763,5 @@ void TxtReaderActivity::savePageIndexCache() const {
} }
f.close(); f.close();
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages);
} }

View File

@@ -57,4 +57,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;
// Defer low-power mode and auto-sleep while the reader is initializing
// (cover prerendering, page index building on first open).
bool preventAutoSleep() override { return !initialized; }
}; };

View File

@@ -11,6 +11,8 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
@@ -63,11 +65,22 @@ void XtcReaderActivity::onEnter() {
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) { if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
xtc->generateCoverBmp(); xtc->generateCoverBmp();
// Fallback: generate placeholder if first-page cover extraction failed
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
PlaceholderCoverGenerator::generate(xtc->getCoverBmpPath(), xtc->getTitle(), xtc->getAuthor(), 480, 800);
}
updateProgress(); updateProgress();
} }
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) { if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]); xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(xtc->getThumbBmpPath(thumbHeight), xtc->getTitle(), xtc->getAuthor(),
thumbWidth, thumbHeight);
}
updateProgress(); updateProgress();
} }
} }
@@ -237,7 +250,7 @@ void XtcReaderActivity::renderPage() {
// Allocate page buffer // Allocate page buffer
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize); LOG_ERR("XTR", "Failed to allocate page buffer (%lu bytes)", pageBufferSize);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -247,7 +260,7 @@ void XtcReaderActivity::renderPage() {
// Load page data // Load page data
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage); LOG_ERR("XTR", "Failed to load page %lu", currentPage);
free(pageBuffer); free(pageBuffer);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
@@ -296,8 +309,8 @@ void XtcReaderActivity::renderPage() {
pixelCounts[getPixelValue(x, y)]++; pixelCounts[getPixelValue(x, y)]++;
} }
} }
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(), LOG_DBG("XTR", "Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu", pixelCounts[0],
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]); pixelCounts[1], pixelCounts[2], pixelCounts[3]);
// Pass 1: BW buffer - draw all non-white pixels as black // Pass 1: BW buffer - draw all non-white pixels as black
for (uint16_t y = 0; y < pageHeight; y++) { for (uint16_t y = 0; y < pageHeight; y++) {
@@ -360,8 +373,7 @@ void XtcReaderActivity::renderPage() {
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1, LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", currentPage + 1, xtc->getPageCount());
xtc->getPageCount());
return; return;
} else { } else {
// 1-bit mode: 8 pixels per byte, MSB first // 1-bit mode: 8 pixels per byte, MSB first
@@ -397,8 +409,7 @@ void XtcReaderActivity::renderPage() {
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
} }
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(), LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);
bitDepth);
} }
void XtcReaderActivity::saveProgress() const { void XtcReaderActivity::saveProgress() const {
@@ -420,7 +431,7 @@ void XtcReaderActivity::loadProgress() {
uint8_t data[4]; uint8_t data[4];
if (f.read(data, 4) == 4) { if (f.read(data, 4) == 4) {
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage); LOG_DBG("XTR", "Loaded progress: page %lu", currentPage);
// Validate page number // Validate page number
if (currentPage >= xtc->getPageCount()) { if (currentPage >= xtc->getPageCount()) {

View File

@@ -2,7 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -104,12 +104,12 @@ void ClearCacheActivity::render() {
} }
void ClearCacheActivity::clearCache() { void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis()); LOG_DBG("CLEAR_CACHE", "Clearing cache...");
// Open .crosspoint directory // Open .crosspoint directory
auto root = Storage.open("/.crosspoint"); auto root = Storage.open("/.crosspoint");
if (!root || !root.isDirectory()) { if (!root || !root.isDirectory()) {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis()); LOG_DBG("CLEAR_CACHE", "Failed to open cache directory");
if (root) root.close(); if (root) root.close();
state = FAILED; state = FAILED;
updateRequired = true; updateRequired = true;
@@ -128,14 +128,14 @@ void ClearCacheActivity::clearCache() {
// Only delete directories starting with epub_ or xtc_ // Only delete directories starting with epub_ or xtc_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) { if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
String fullPath = "/.crosspoint/" + itemName; String fullPath = "/.crosspoint/" + itemName;
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str()); LOG_DBG("CLEAR_CACHE", "Removing cache: %s", fullPath.c_str());
file.close(); // Close before attempting to delete file.close(); // Close before attempting to delete
if (Storage.removeDir(fullPath.c_str())) { if (Storage.removeDir(fullPath.c_str())) {
clearedCount++; clearedCount++;
} else { } else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str()); LOG_ERR("CLEAR_CACHE", "Failed to remove: %s", fullPath.c_str());
failedCount++; failedCount++;
} }
} else { } else {
@@ -144,7 +144,7 @@ void ClearCacheActivity::clearCache() {
} }
root.close(); root.close();
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount); LOG_DBG("CLEAR_CACHE", "Cache cleared: %d removed, %d failed", clearedCount, failedCount);
state = SUCCESS; state = SUCCESS;
updateRequired = true; updateRequired = true;
@@ -153,7 +153,7 @@ void ClearCacheActivity::clearCache() {
void ClearCacheActivity::loop() { void ClearCacheActivity::loop() {
if (state == WARNING) { if (state == WARNING) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis()); LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CLEARING; state = CLEARING;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -164,7 +164,7 @@ void ClearCacheActivity::loop() {
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
Serial.printf("[%lu] [CLEAR_CACHE] User cancelled\n", millis()); LOG_DBG("CLEAR_CACHE", "User cancelled");
goBack(); goBack();
} }
return; return;

View File

@@ -18,12 +18,12 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
if (!success) { if (!success) {
Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis()); LOG_ERR("OTA", "WiFi connection failed, exiting");
goBack(); goBack();
return; return;
} }
Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis()); LOG_DBG("OTA", "WiFi connected, checking for update");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CHECKING_FOR_UPDATE; state = CHECKING_FOR_UPDATE;
@@ -32,7 +32,7 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
vTaskDelay(10 / portTICK_PERIOD_MS); vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.checkForUpdate(); const auto res = updater.checkForUpdate();
if (res != OtaUpdater::OK) { if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res); LOG_DBG("OTA", "Update check failed: %d", res);
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED; state = FAILED;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -41,7 +41,7 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
} }
if (!updater.isUpdateNewer()) { if (!updater.isUpdateNewer()) {
Serial.printf("[%lu] [OTA] No new update available\n", millis()); LOG_DBG("OTA", "No new update available");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = NO_UPDATE; state = NO_UPDATE;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -68,11 +68,11 @@ void OtaUpdateActivity::onEnter() {
); );
// Turn on WiFi immediately // Turn on WiFi immediately
Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis()); LOG_DBG("OTA", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity // Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("OTA", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} }
@@ -116,8 +116,7 @@ void OtaUpdateActivity::render() {
float updaterProgress = 0; float updaterProgress = 0;
if (state == UPDATE_IN_PROGRESS) { if (state == UPDATE_IN_PROGRESS) {
Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.getProcessedSize(), LOG_DBG("OTA", "Update progress: %d / %d", updater.getProcessedSize(), updater.getTotalSize());
updater.getTotalSize());
updaterProgress = static_cast<float>(updater.getProcessedSize()) / static_cast<float>(updater.getTotalSize()); updaterProgress = static_cast<float>(updater.getProcessedSize()) / static_cast<float>(updater.getTotalSize());
// Only update every 2% at the most // Only update every 2% at the most
if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) { if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) {
@@ -190,7 +189,7 @@ void OtaUpdateActivity::loop() {
if (state == WAITING_CONFIRMATION) { if (state == WAITING_CONFIRMATION) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); LOG_DBG("OTA", "New update available, starting download...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS; state = UPDATE_IN_PROGRESS;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -199,7 +198,7 @@ void OtaUpdateActivity::loop() {
const auto res = updater.installUpdate(); const auto res = updater.installUpdate();
if (res != OtaUpdater::OK) { if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); LOG_DBG("OTA", "Update failed: %d", res);
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED; state = FAILED;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);

View File

@@ -1,7 +1,7 @@
#include "SettingsActivity.h" #include "SettingsActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "ButtonRemapActivity.h" #include "ButtonRemapActivity.h"
#include "CalibreSettingsActivity.h" #include "CalibreSettingsActivity.h"
@@ -173,6 +173,9 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr); const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
const uint8_t currentValue = setting.valueGetter();
setting.valueSetter((currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()));
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr); const int8_t currentValue = SETTINGS.*(setting.valuePtr);
if (currentValue + setting.valueRange.step > setting.valueRange.max) { if (currentValue + setting.valueRange.step > setting.valueRange.max) {
@@ -274,6 +277,11 @@ void SettingsActivity::render() const {
} else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) { } else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settings[i].valuePtr); const uint8_t value = SETTINGS.*(settings[i].valuePtr);
valueText = settings[i].enumValues[value]; valueText = settings[i].enumValues[value];
} else if (settings[i].type == SettingType::ENUM && settings[i].valueGetter) {
const uint8_t value = settings[i].valueGetter();
if (value < settings[i].enumValues.size()) {
valueText = settings[i].enumValues[value];
}
} else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) { } else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settings[i].valuePtr)); valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
} }

View File

@@ -1,6 +1,7 @@
#include "UITheme.h" #include "UITheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <memory> #include <memory>
@@ -23,12 +24,12 @@ void UITheme::reload() {
void UITheme::setTheme(CrossPointSettings::UI_THEME type) { void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
switch (type) { switch (type) {
case CrossPointSettings::UI_THEME::CLASSIC: case CrossPointSettings::UI_THEME::CLASSIC:
Serial.printf("[%lu] [UI] Using Classic theme\n", millis()); LOG_DBG("UI", "Using Classic theme");
currentTheme = new BaseTheme(); currentTheme = new BaseTheme();
currentMetrics = &BaseMetrics::values; currentMetrics = &BaseMetrics::values;
break; break;
case CrossPointSettings::UI_THEME::LYRA: case CrossPointSettings::UI_THEME::LYRA:
Serial.printf("[%lu] [UI] Using Lyra theme\n", millis()); LOG_DBG("UI", "Using Lyra theme");
currentTheme = new LyraTheme(); currentTheme = new LyraTheme();
currentMetrics = &LyraMetrics::values; currentMetrics = &LyraMetrics::values;
break; break;

View File

@@ -2,6 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
#include <cstdint> #include <cstdint>
@@ -311,7 +312,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
if (Storage.openFileForRead("HOME", coverBmpPath, file)) { if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("Rendering bmp\n"); LOG_DBG("THEME", "Rendering bmp");
// Calculate position to center image within the book card // Calculate position to center image within the book card
int coverX, coverY; int coverX, coverY;
@@ -345,7 +346,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First render: if selected, draw selection indicators now // First render: if selected, draw selection indicators now
if (bookSelected) { if (bookSelected) {
Serial.printf("Drawing selection\n"); LOG_DBG("THEME", "Drawing selection");
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
} }

View File

@@ -5,6 +5,7 @@
#include <HalGPIO.h> #include <HalGPIO.h>
#include <HalPowerManager.h> #include <HalPowerManager.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <SPI.h> #include <SPI.h>
#include <builtinFonts/all.h> #include <builtinFonts/all.h>
@@ -38,13 +39,16 @@ GfxRenderer renderer(display);
Activity* currentActivity; Activity* currentActivity;
// Fonts // Fonts
#ifndef OMIT_BOOKERLY
EpdFont bookerly14RegularFont(&bookerly_14_regular); EpdFont bookerly14RegularFont(&bookerly_14_regular);
EpdFont bookerly14BoldFont(&bookerly_14_bold); EpdFont bookerly14BoldFont(&bookerly_14_bold);
EpdFont bookerly14ItalicFont(&bookerly_14_italic); EpdFont bookerly14ItalicFont(&bookerly_14_italic);
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic); EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont, EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
&bookerly14BoldItalicFont); &bookerly14BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_FONTS #ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
EpdFont bookerly12RegularFont(&bookerly_12_regular); EpdFont bookerly12RegularFont(&bookerly_12_regular);
EpdFont bookerly12BoldFont(&bookerly_12_bold); EpdFont bookerly12BoldFont(&bookerly_12_bold);
EpdFont bookerly12ItalicFont(&bookerly_12_italic); EpdFont bookerly12ItalicFont(&bookerly_12_italic);
@@ -63,7 +67,9 @@ EpdFont bookerly18ItalicFont(&bookerly_18_italic);
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic); EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont, EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
&bookerly18BoldItalicFont); &bookerly18BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
EpdFont notosans12RegularFont(&notosans_12_regular); EpdFont notosans12RegularFont(&notosans_12_regular);
EpdFont notosans12BoldFont(&notosans_12_bold); EpdFont notosans12BoldFont(&notosans_12_bold);
EpdFont notosans12ItalicFont(&notosans_12_italic); EpdFont notosans12ItalicFont(&notosans_12_italic);
@@ -88,7 +94,9 @@ EpdFont notosans18ItalicFont(&notosans_18_italic);
EpdFont notosans18BoldItalicFont(&notosans_18_bolditalic); EpdFont notosans18BoldItalicFont(&notosans_18_bolditalic);
EpdFontFamily notosans18FontFamily(&notosans18RegularFont, &notosans18BoldFont, &notosans18ItalicFont, EpdFontFamily notosans18FontFamily(&notosans18RegularFont, &notosans18BoldFont, &notosans18ItalicFont,
&notosans18BoldItalicFont); &notosans18BoldItalicFont);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular); EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold); EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic); EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
@@ -113,6 +121,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic); EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont, EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
&opendyslexic14BoldItalicFont); &opendyslexic14BoldItalicFont);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS #endif // OMIT_FONTS
EpdFont smallFont(&notosans_8_regular); EpdFont smallFont(&notosans_8_regular);
@@ -203,8 +212,8 @@ void enterDeepSleep() {
enterNewActivity(new SleepActivity(renderer, mappedInputManager)); enterNewActivity(new SleepActivity(renderer, mappedInputManager));
display.deepSleep(); display.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); LOG_DBG("MAIN", "Entering deep sleep");
powerManager.startDeepSleep(gpio); powerManager.startDeepSleep(gpio);
} }
@@ -257,26 +266,34 @@ void onGoHome() {
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
display.begin(); display.begin();
renderer.begin(); renderer.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis()); LOG_DBG("MAIN", "Display initialized");
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#endif
#ifndef OMIT_FONTS #ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily); renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily); renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily); renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily); renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily);
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily); renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily); renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily); renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS #endif // OMIT_FONTS
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis()); LOG_DBG("MAIN", "Fonts setup");
} }
void setup() { void setup() {
@@ -298,7 +315,7 @@ void setup() {
// SD Card Initialization // SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter // We need 6 open files concurrently when parsing a new chapter
if (!Storage.begin()) { if (!Storage.begin()) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); LOG_ERR("MAIN", "SD card initialization failed");
setupDisplayAndFonts(); setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
@@ -313,12 +330,12 @@ void setup() {
switch (gpio.getWakeupReason()) { switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:
// For normal wakeups, verify power button press duration // For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); LOG_DBG("MAIN", "Verifying power button press duration");
verifyPowerButtonDuration(); verifyPowerButtonDuration();
break; break;
case HalGPIO::WakeupReason::AfterUSBPower: case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep // If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis()); LOG_DBG("MAIN", "Wakeup reason: After USB Power");
powerManager.startDeepSleep(gpio); powerManager.startDeepSleep(gpio);
break; break;
case HalGPIO::WakeupReason::AfterFlash: case HalGPIO::WakeupReason::AfterFlash:
@@ -329,7 +346,7 @@ void setup() {
} }
// First serial output only here to avoid timing inconsistencies for power button press duration verification // First serial output only here to avoid timing inconsistencies for power button press duration verification
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
setupDisplayAndFonts(); setupDisplayAndFonts();
@@ -367,11 +384,27 @@ void loop() {
renderer.setFadingFix(SETTINGS.fadingFix); renderer.setFadingFix(SETTINGS.fadingFix);
if (Serial && millis() - lastMemPrint >= 10000) { if (Serial && millis() - lastMemPrint >= 10000) {
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), LOG_INF("MEM", "Free: %d bytes, Total: %d bytes, Min Free: %d bytes", ESP.getFreeHeap(), ESP.getHeapSize(),
ESP.getHeapSize(), ESP.getMinFreeHeap()); ESP.getMinFreeHeap());
lastMemPrint = millis(); lastMemPrint = millis();
} }
// Handle incoming serial commands,
// nb: we use logSerial from logging to avoid deprecation warnings
if (logSerial.available() > 0) {
String line = logSerial.readStringUntil('\n');
if (line.startsWith("CMD:")) {
String cmd = line.substring(4);
cmd.trim();
if (cmd == "SCREENSHOT") {
logSerial.printf("SCREENSHOT_START:%d\n", HalDisplay::BUFFER_SIZE);
uint8_t* buf = display.getFrameBuffer();
logSerial.write(buf, HalDisplay::BUFFER_SIZE);
logSerial.printf("SCREENSHOT_END\n");
}
}
}
// Check for any user activity (button press or release) or active background work // Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis(); static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
@@ -381,7 +414,7 @@ void loop() {
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) { if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs); LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", sleepTimeoutMs);
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;
@@ -403,11 +436,17 @@ void loop() {
if (loopDuration > maxLoopDuration) { if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration; maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) { if (maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration, LOG_DBG("LOOP", "New max loop duration: %lu ms (activity: %lu ms)", maxLoopDuration, activityDuration);
activityDuration);
} }
} }
// Re-check preventAutoSleep: the activity may have changed during loop() above
// (e.g., HomeActivity transitioned to EpubReaderActivity with pending section work).
if (currentActivity && currentActivity->preventAutoSleep()) {
lastActivityTime = millis();
powerManager.setPowerSaving(false);
}
// Add delay at the end of the loop to prevent tight spinning // Add delay at the end of the loop to prevent tight spinning
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
// Otherwise, use longer delay to save power // Otherwise, use longer delay to save power

View File

@@ -4,6 +4,7 @@
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
@@ -44,7 +45,7 @@ void clearEpubCacheIfNeeded(const String& filePath) {
// Only clear cache for .epub files // Only clear cache for .epub files
if (StringUtils::checkFileExtension(filePath, ".epub")) { if (StringUtils::checkFileExtension(filePath, ".epub")) {
Epub(filePath.c_str(), "/.crosspoint").clearCache(); Epub(filePath.c_str(), "/.crosspoint").clearCache();
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "Cleared epub cache for: %s", filePath.c_str());
} }
} }
@@ -89,7 +90,7 @@ CrossPointWebServer::~CrossPointWebServer() { stop(); }
void CrossPointWebServer::begin() { void CrossPointWebServer::begin() {
if (running) { if (running) {
Serial.printf("[%lu] [WEB] Web server already running\n", millis()); LOG_DBG("WEB", "Web server already running");
return; return;
} }
@@ -99,18 +100,17 @@ void CrossPointWebServer::begin() {
const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running
if (!isStaConnected && !isInApMode) { if (!isStaConnected && !isInApMode) {
Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, LOG_DBG("WEB", "Cannot start webserver - no valid network (mode=%d, status=%d)", wifiMode, WiFi.status());
WiFi.status());
return; return;
} }
// Store AP mode flag for later use (e.g., in handleStatus) // Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode; apMode = isInApMode;
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap before begin: %d bytes", ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); LOG_DBG("WEB", "Network mode: %s", apMode ? "AP" : "STA");
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); LOG_DBG("WEB", "Creating web server on port %d...", port);
server.reset(new WebServer(port)); server.reset(new WebServer(port));
// Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors. // Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors.
@@ -120,15 +120,15 @@ void CrossPointWebServer::begin() {
// Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library. // Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library.
// We rely on disabling WiFi sleep for responsiveness. // We rely on disabling WiFi sleep for responsiveness.
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after WebServer allocation: %d bytes", ESP.getFreeHeap());
if (!server) { if (!server) {
Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis()); LOG_ERR("WEB", "Failed to create WebServer!");
return; return;
} }
// Setup routes // Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); LOG_DBG("WEB", "Setting up routes...");
server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/files", HTTP_GET, [this] { handleFileList(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); });
@@ -157,43 +157,41 @@ void CrossPointWebServer::begin() {
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); }); server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
server->begin(); server->begin();
// Start WebSocket server for fast binary uploads // Start WebSocket server for fast binary uploads
Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); LOG_DBG("WEB", "Starting WebSocket server on port %d...", wsPort);
wsServer.reset(new WebSocketsServer(wsPort)); wsServer.reset(new WebSocketsServer(wsPort));
wsInstance = const_cast<CrossPointWebServer*>(this); wsInstance = const_cast<CrossPointWebServer*>(this);
wsServer->begin(); wsServer->begin();
wsServer->onEvent(wsEventCallback); wsServer->onEvent(wsEventCallback);
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); LOG_DBG("WEB", "WebSocket server started");
udpActive = udp.begin(LOCAL_UDP_PORT); udpActive = udp.begin(LOCAL_UDP_PORT);
Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
LOCAL_UDP_PORT);
running = true; running = true;
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); LOG_DBG("WEB", "Web server started on port %d", port);
// Show the correct IP based on network mode // Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); LOG_DBG("WEB", "Access at http://%s/", ipAddr.c_str());
Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); LOG_DBG("WEB", "WebSocket at ws://%s:%d/", ipAddr.c_str(), wsPort);
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServer::stop() { void CrossPointWebServer::stop() {
if (!running || !server) { if (!running || !server) {
Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
server.get());
return; return;
} }
Serial.printf("[%lu] [WEB] STOP INITIATED - setting running=false first\n", millis()); LOG_DBG("WEB", "STOP INITIATED - setting running=false first");
running = false; // Set this FIRST to prevent handleClient from using server running = false; // Set this FIRST to prevent handleClient from using server
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap());
// Close any in-progress WebSocket upload // Close any in-progress WebSocket upload
if (wsUploadInProgress && wsUploadFile) { if (wsUploadInProgress && wsUploadFile) {
@@ -203,11 +201,11 @@ void CrossPointWebServer::stop() {
// Stop WebSocket server // Stop WebSocket server
if (wsServer) { if (wsServer) {
Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); LOG_DBG("WEB", "Stopping WebSocket server...");
wsServer->close(); wsServer->close();
wsServer.reset(); wsServer.reset();
wsInstance = nullptr; wsInstance = nullptr;
Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); LOG_DBG("WEB", "WebSocket server stopped");
} }
if (udpActive) { if (udpActive) {
@@ -219,18 +217,18 @@ void CrossPointWebServer::stop() {
delay(20); delay(20);
server->stop(); server->stop();
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after server->stop(): %d bytes", ESP.getFreeHeap());
// Brief delay before deletion // Brief delay before deletion
delay(10); delay(10);
server.reset(); server.reset();
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); LOG_DBG("WEB", "Web server stopped and deleted");
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared // 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 // later in the file and will be cleared when they go out of scope or on next upload
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServer::handleClient() { void CrossPointWebServer::handleClient() {
@@ -243,13 +241,13 @@ void CrossPointWebServer::handleClient() {
// Double-check server pointer is valid // Double-check server pointer is valid
if (!server) { if (!server) {
Serial.printf("[%lu] [WEB] WARNING: handleClient called with null server!\n", millis()); LOG_DBG("WEB", "WARNING: handleClient called with null server!");
return; return;
} }
// Print debug every 10 seconds to confirm handleClient is being called // Print debug every 10 seconds to confirm handleClient is being called
if (millis() - lastDebugPrint > 10000) { if (millis() - lastDebugPrint > 10000) {
Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port); LOG_DBG("WEB", "handleClient active, server running on port %d", port);
lastDebugPrint = millis(); lastDebugPrint = millis();
} }
@@ -295,9 +293,14 @@ CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() con
return status; return status;
} }
static void sendHtmlContent(WebServer* server, const char* data, size_t len) {
server->sendHeader("Content-Encoding", "gzip");
server->send_P(200, "text/html", data, len);
}
void CrossPointWebServer::handleRoot() const { void CrossPointWebServer::handleRoot() const {
server->send(200, "text/html", HomePageHtml); sendHtmlContent(server.get(), HomePageHtml, sizeof(HomePageHtml));
Serial.printf("[%lu] [WEB] Served root page\n", millis()); LOG_DBG("WEB", "Served root page");
} }
void CrossPointWebServer::handleNotFound() const { void CrossPointWebServer::handleNotFound() const {
@@ -326,17 +329,17 @@ void CrossPointWebServer::handleStatus() const {
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const { void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
FsFile root = Storage.open(path); FsFile root = Storage.open(path);
if (!root) { if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); LOG_DBG("WEB", "Failed to open directory: %s", path);
return; return;
} }
if (!root.isDirectory()) { if (!root.isDirectory()) {
Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); LOG_DBG("WEB", "Not a directory: %s", path);
root.close(); root.close();
return; return;
} }
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); LOG_DBG("WEB", "Scanning files in: %s", path);
FsFile file = root.openNextFile(); FsFile file = root.openNextFile();
char name[500]; char name[500];
@@ -387,7 +390,9 @@ bool CrossPointWebServer::isEpubFile(const String& filename) const {
return lower.endsWith(".epub"); return lower.endsWith(".epub");
} }
void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); } void CrossPointWebServer::handleFileList() const {
sendHtmlContent(server.get(), FilesPageHtml, sizeof(FilesPageHtml));
}
void CrossPointWebServer::handleFileListData() const { void CrossPointWebServer::handleFileListData() const {
// Get current path from query string (default to root) // Get current path from query string (default to root)
@@ -422,7 +427,7 @@ void CrossPointWebServer::handleFileListData() const {
const size_t written = serializeJson(doc, output, outputSize); const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) { if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON // JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str()); LOG_DBG("WEB", "Skipping file entry with oversized JSON for name: %s", info.name.c_str());
return; return;
} }
@@ -436,7 +441,7 @@ void CrossPointWebServer::handleFileListData() const {
server->sendContent("]"); server->sendContent("]");
// End of streamed response, empty chunk to signal client // End of streamed response, empty chunk to signal client
server->sendContent(""); server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); LOG_DBG("WEB", "Served file listing page for path: %s", currentPath.c_str());
} }
void CrossPointWebServer::handleDownload() const { void CrossPointWebServer::handleDownload() const {
@@ -517,8 +522,7 @@ static bool flushUploadBuffer(CrossPointWebServer::UploadState& state) {
esp_task_wdt_reset(); // Reset watchdog after SD write esp_task_wdt_reset(); // Reset watchdog after SD write
if (written != state.bufferPos) { if (written != state.bufferPos) {
Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), state.bufferPos, LOG_DBG("WEB", "[UPLOAD] Buffer flush failed: expected %d, wrote %d", state.bufferPos, written);
written);
state.bufferPos = 0; state.bufferPos = 0;
return false; return false;
} }
@@ -535,7 +539,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
// Safety check: ensure server is still valid // Safety check: ensure server is still valid
if (!running || !server) { if (!running || !server) {
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); LOG_DBG("WEB", "[UPLOAD] ERROR: handleUpload called but server not running!");
return; return;
} }
@@ -572,8 +576,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
state.path = "/"; state.path = "/";
} }
Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), state.fileName.c_str(), state.path.c_str()); LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
// Create file path // Create file path
String filePath = state.path; String filePath = state.path;
@@ -583,7 +587,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
// Check if file already exists - SD operations can be slow // Check if file already exists - SD operations can be slow
esp_task_wdt_reset(); esp_task_wdt_reset();
if (Storage.exists(filePath.c_str())) { if (Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str());
esp_task_wdt_reset(); esp_task_wdt_reset();
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
} }
@@ -592,12 +596,12 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
esp_task_wdt_reset(); esp_task_wdt_reset();
if (!Storage.openFileForWrite("WEB", filePath, state.file)) { if (!Storage.openFileForWrite("WEB", filePath, state.file)) {
state.error = "Failed to create file on SD card"; state.error = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] FAILED to create file: %s", filePath.c_str());
return; return;
} }
esp_task_wdt_reset(); esp_task_wdt_reset();
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] File created successfully: %s", filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) { } else if (upload.status == UPLOAD_FILE_WRITE) {
if (state.file && state.error.isEmpty()) { if (state.file && state.error.isEmpty()) {
// Buffer incoming data and flush when buffer is full // Buffer incoming data and flush when buffer is full
@@ -630,8 +634,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
if (state.size - lastLoggedSize >= 102400) { if (state.size - lastLoggedSize >= 102400) {
const unsigned long elapsed = millis() - uploadStartTime; const unsigned long elapsed = millis() - uploadStartTime;
const float kbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0; const float kbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), state.size, LOG_DBG("WEB", "[UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes", state.size, state.size / 1024.0, kbps,
state.size / 1024.0, kbps, writeCount); writeCount);
lastLoggedSize = state.size; lastLoggedSize = state.size;
} }
} }
@@ -648,10 +652,10 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
const unsigned long elapsed = millis() - uploadStartTime; const unsigned long elapsed = millis() - uploadStartTime;
const float avgKbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0; const float avgKbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), LOG_DBG("WEB", "[UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)", state.fileName.c_str(), state.size,
state.fileName.c_str(), state.size, elapsed, avgKbps); elapsed, avgKbps);
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
writeCount, totalWriteTime, writePercent); writePercent);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = state.path; String filePath = state.path;
@@ -671,7 +675,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
} }
state.error = "Upload aborted"; state.error = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis()); LOG_DBG("WEB", "Upload aborted");
} }
} }
@@ -716,7 +720,7 @@ void CrossPointWebServer::handleCreateFolder() const {
if (!folderPath.endsWith("/")) folderPath += "/"; if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += folderName; folderPath += folderName;
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Creating folder: %s", folderPath.c_str());
// Check if already exists // Check if already exists
if (Storage.exists(folderPath.c_str())) { if (Storage.exists(folderPath.c_str())) {
@@ -726,10 +730,10 @@ void CrossPointWebServer::handleCreateFolder() const {
// Create the folder // Create the folder
if (Storage.mkdir(folderPath.c_str())) { if (Storage.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Folder created successfully: %s", folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName); server->send(200, "text/plain", "Folder created: " + folderName);
} else { } else {
Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Failed to create folder: %s", folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder"); server->send(500, "text/plain", "Failed to create folder");
} }
} }
@@ -808,10 +812,10 @@ void CrossPointWebServer::handleRename() const {
file.close(); file.close();
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_DBG("WEB", "Renamed file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Renamed successfully"); server->send(200, "text/plain", "Renamed successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_ERR("WEB", "Failed to rename file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to rename file"); server->send(500, "text/plain", "Failed to rename file");
} }
} }
@@ -901,10 +905,10 @@ void CrossPointWebServer::handleMove() const {
file.close(); file.close();
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_DBG("WEB", "Moved file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Moved successfully"); server->send(200, "text/plain", "Moved successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_ERR("WEB", "Failed to move file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to move file"); server->send(500, "text/plain", "Failed to move file");
} }
} }
@@ -935,7 +939,7 @@ void CrossPointWebServer::handleDelete() const {
// Check if item starts with a dot (hidden/system file) // Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) { if (itemName.startsWith(".")) {
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete rejected - hidden/system item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete system files"); server->send(403, "text/plain", "Cannot delete system files");
return; return;
} }
@@ -943,7 +947,7 @@ void CrossPointWebServer::handleDelete() const {
// Check against explicitly protected items // Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) { if (itemName.equals(HIDDEN_ITEMS[i])) {
Serial.printf("[%lu] [WEB] Delete rejected - protected item: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete rejected - protected item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete protected items"); server->send(403, "text/plain", "Cannot delete protected items");
return; return;
} }
@@ -951,12 +955,12 @@ void CrossPointWebServer::handleDelete() const {
// Check if item exists // Check if item exists
if (!Storage.exists(itemPath.c_str())) { if (!Storage.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete failed - item not found: %s", itemPath.c_str());
server->send(404, "text/plain", "Item not found"); server->send(404, "text/plain", "Item not found");
return; return;
} }
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str()); LOG_DBG("WEB", "Attempting to delete %s: %s", itemType.c_str(), itemPath.c_str());
bool success = false; bool success = false;
@@ -970,7 +974,7 @@ void CrossPointWebServer::handleDelete() const {
// Folder is not empty // Folder is not empty
entry.close(); entry.close();
dir.close(); dir.close();
Serial.printf("[%lu] [WEB] Delete failed - folder not empty: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete failed - folder not empty: %s", itemPath.c_str());
server->send(400, "text/plain", "Folder is not empty. Delete contents first."); server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
return; return;
} }
@@ -983,17 +987,17 @@ void CrossPointWebServer::handleDelete() const {
} }
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Successfully deleted: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Successfully deleted: %s", itemPath.c_str());
server->send(200, "text/plain", "Deleted successfully"); server->send(200, "text/plain", "Deleted successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str()); LOG_ERR("WEB", "Failed to delete: %s", itemPath.c_str());
server->send(500, "text/plain", "Failed to delete item"); server->send(500, "text/plain", "Failed to delete item");
} }
} }
void CrossPointWebServer::handleSettingsPage() const { void CrossPointWebServer::handleSettingsPage() const {
server->send(200, "text/html", SettingsPageHtml); sendHtmlContent(server.get(), SettingsPageHtml, sizeof(SettingsPageHtml));
Serial.printf("[%lu] [WEB] Served settings page\n", millis()); LOG_DBG("WEB", "Served settings page");
} }
void CrossPointWebServer::handleGetSettings() const { void CrossPointWebServer::handleGetSettings() const {
@@ -1062,7 +1066,7 @@ void CrossPointWebServer::handleGetSettings() const {
const size_t written = serializeJson(doc, output, outputSize); const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) { if (written >= outputSize) {
Serial.printf("[%lu] [WEB] Skipping oversized setting JSON for: %s\n", millis(), s.key); LOG_DBG("WEB", "Skipping oversized setting JSON for: %s", s.key);
continue; continue;
} }
@@ -1076,7 +1080,7 @@ void CrossPointWebServer::handleGetSettings() const {
server->sendContent("]"); server->sendContent("]");
server->sendContent(""); server->sendContent("");
Serial.printf("[%lu] [WEB] Served settings API\n", millis()); LOG_DBG("WEB", "Served settings API");
} }
void CrossPointWebServer::handlePostSettings() { void CrossPointWebServer::handlePostSettings() {
@@ -1149,7 +1153,7 @@ void CrossPointWebServer::handlePostSettings() {
SETTINGS.saveToFile(); SETTINGS.saveToFile();
Serial.printf("[%lu] [WEB] Applied %d setting(s)\n", millis(), applied); LOG_DBG("WEB", "Applied %d setting(s)", applied);
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
} }
@@ -1169,7 +1173,7 @@ void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* p
void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
switch (type) { switch (type) {
case WStype_DISCONNECTED: case WStype_DISCONNECTED:
Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); LOG_DBG("WS", "Client %u disconnected", num);
// Clean up any in-progress upload // Clean up any in-progress upload
if (wsUploadInProgress && wsUploadFile) { if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close(); wsUploadFile.close();
@@ -1178,20 +1182,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
Storage.remove(filePath.c_str()); Storage.remove(filePath.c_str());
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); LOG_DBG("WS", "Deleted incomplete upload: %s", filePath.c_str());
} }
wsUploadInProgress = false; wsUploadInProgress = false;
break; break;
case WStype_CONNECTED: { case WStype_CONNECTED: {
Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); LOG_DBG("WS", "Client %u connected", num);
break; break;
} }
case WStype_TEXT: { case WStype_TEXT: {
// Parse control messages // Parse control messages
String msg = String((char*)payload); String msg = String((char*)payload);
Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str());
if (msg.startsWith("START:")) { if (msg.startsWith("START:")) {
// Parse: START:<filename>:<size>:<path> // Parse: START:<filename>:<size>:<path>
@@ -1216,8 +1220,8 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
wsUploadSize, filePath.c_str()); filePath.c_str());
// Check if file exists and remove it // Check if file exists and remove it
esp_task_wdt_reset(); esp_task_wdt_reset();
@@ -1283,8 +1287,8 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
unsigned long elapsed = millis() - wsUploadStartTime; unsigned long elapsed = millis() - wsUploadStartTime;
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); elapsed, kbps);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = wsUploadPath; String filePath = wsUploadPath;

View File

@@ -1,7 +1,7 @@
#include "HttpDownloader.h" #include "HttpDownloader.h"
#include <HTTPClient.h> #include <HTTPClient.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <StreamString.h> #include <StreamString.h>
#include <WiFiClient.h> #include <WiFiClient.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
@@ -25,7 +25,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
} }
HTTPClient http; HTTPClient http;
Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); LOG_DBG("HTTP", "Fetching: %s", url.c_str());
http.begin(*client, url.c_str()); http.begin(*client, url.c_str());
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
@@ -40,7 +40,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
const int httpCode = http.GET(); const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) { if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); LOG_ERR("HTTP", "Fetch failed: %d", httpCode);
http.end(); http.end();
return false; return false;
} }
@@ -49,7 +49,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
http.end(); http.end();
Serial.printf("[%lu] [HTTP] Fetch success\n", millis()); LOG_DBG("HTTP", "Fetch success");
return true; return true;
} }
@@ -75,8 +75,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
} }
HTTPClient http; HTTPClient http;
Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); LOG_DBG("HTTP", "Downloading: %s", url.c_str());
Serial.printf("[%lu] [HTTP] Destination: %s\n", millis(), destPath.c_str()); LOG_DBG("HTTP", "Destination: %s", destPath.c_str());
http.begin(*client, url.c_str()); http.begin(*client, url.c_str());
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
@@ -91,13 +91,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
const int httpCode = http.GET(); const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) { if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode); LOG_ERR("HTTP", "Download failed: %d", httpCode);
http.end(); http.end();
return HTTP_ERROR; return HTTP_ERROR;
} }
const size_t contentLength = http.getSize(); const size_t contentLength = http.getSize();
Serial.printf("[%lu] [HTTP] Content-Length: %zu\n", millis(), contentLength); LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
// Remove existing file if present // Remove existing file if present
if (Storage.exists(destPath.c_str())) { if (Storage.exists(destPath.c_str())) {
@@ -107,7 +107,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
// Open file for writing // Open file for writing
FsFile file; FsFile file;
if (!Storage.openFileForWrite("HTTP", destPath.c_str(), file)) { if (!Storage.openFileForWrite("HTTP", destPath.c_str(), file)) {
Serial.printf("[%lu] [HTTP] Failed to open file for writing\n", millis()); LOG_ERR("HTTP", "Failed to open file for writing");
http.end(); http.end();
return FILE_ERROR; return FILE_ERROR;
} }
@@ -115,7 +115,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
// Get the stream for chunked reading // Get the stream for chunked reading
WiFiClient* stream = http.getStreamPtr(); WiFiClient* stream = http.getStreamPtr();
if (!stream) { if (!stream) {
Serial.printf("[%lu] [HTTP] Failed to get stream\n", millis()); LOG_ERR("HTTP", "Failed to get stream");
file.close(); file.close();
Storage.remove(destPath.c_str()); Storage.remove(destPath.c_str());
http.end(); http.end();
@@ -143,7 +143,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
const size_t written = file.write(buffer, bytesRead); const size_t written = file.write(buffer, bytesRead);
if (written != bytesRead) { if (written != bytesRead) {
Serial.printf("[%lu] [HTTP] Write failed: wrote %zu of %zu bytes\n", millis(), written, bytesRead); LOG_ERR("HTTP", "Write failed: wrote %zu of %zu bytes", written, bytesRead);
file.close(); file.close();
Storage.remove(destPath.c_str()); Storage.remove(destPath.c_str());
http.end(); http.end();
@@ -160,11 +160,11 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
file.close(); file.close();
http.end(); http.end();
Serial.printf("[%lu] [HTTP] Downloaded %zu bytes\n", millis(), downloaded); LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded);
// Verify download size if known // Verify download size if known
if (contentLength > 0 && downloaded != contentLength) { if (contentLength > 0 && downloaded != contentLength) {
Serial.printf("[%lu] [HTTP] Size mismatch: got %zu, expected %zu\n", millis(), downloaded, contentLength); LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", downloaded, contentLength);
Storage.remove(destPath.c_str()); Storage.remove(destPath.c_str());
return HTTP_ERROR; return HTTP_ERROR;
} }

View File

@@ -1,6 +1,7 @@
#include "OtaUpdater.h" #include "OtaUpdater.h"
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <Logging.h>
#include "esp_http_client.h" #include "esp_http_client.h"
#include "esp_https_ota.h" #include "esp_https_ota.h"
@@ -39,7 +40,7 @@ esp_err_t event_handler(esp_http_client_event_t* event) {
local_buf = static_cast<char*>(calloc(content_len + 1, sizeof(char))); local_buf = static_cast<char*>(calloc(content_len + 1, sizeof(char)));
output_len = 0; output_len = 0;
if (local_buf == NULL) { if (local_buf == NULL) {
Serial.printf("[%lu] [OTA] HTTP Client Out of Memory Failed, Allocation %d\n", millis(), content_len); LOG_ERR("OTA", "HTTP Client Out of Memory Failed, Allocation %d", content_len);
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
} }
@@ -52,7 +53,7 @@ esp_err_t event_handler(esp_http_client_event_t* event) {
/* Code might be hits here, It happened once (for version checking) but I need more logs to handle that */ /* Code might be hits here, It happened once (for version checking) but I need more logs to handle that */
int chunked_len; int chunked_len;
esp_http_client_get_chunk_length(event->client, &chunked_len); esp_http_client_get_chunk_length(event->client, &chunked_len);
Serial.printf("[%lu] [OTA] esp_http_client_is_chunked_response failed, chunked_len: %d\n", millis(), chunked_len); LOG_DBG("OTA", "esp_http_client_is_chunked_response failed, chunked_len: %d", chunked_len);
} }
return ESP_OK; return ESP_OK;
@@ -88,20 +89,20 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
esp_http_client_handle_t client_handle = esp_http_client_init(&client_config); esp_http_client_handle_t client_handle = esp_http_client_init(&client_config);
if (!client_handle) { if (!client_handle) {
Serial.printf("[%lu] [OTA] HTTP Client Handle Failed\n", millis()); LOG_ERR("OTA", "HTTP Client Handle Failed");
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
esp_err = esp_http_client_set_header(client_handle, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); esp_err = esp_http_client_set_header(client_handle, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] esp_http_client_set_header Failed : %s\n", millis(), esp_err_to_name(esp_err)); LOG_ERR("OTA", "esp_http_client_set_header Failed : %s", esp_err_to_name(esp_err));
esp_http_client_cleanup(client_handle); esp_http_client_cleanup(client_handle);
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
esp_err = esp_http_client_perform(client_handle); esp_err = esp_http_client_perform(client_handle);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] esp_http_client_perform Failed : %s\n", millis(), esp_err_to_name(esp_err)); LOG_ERR("OTA", "esp_http_client_perform Failed : %s", esp_err_to_name(esp_err));
esp_http_client_cleanup(client_handle); esp_http_client_cleanup(client_handle);
return HTTP_ERROR; return HTTP_ERROR;
} }
@@ -109,7 +110,7 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
/* esp_http_client_close will be called inside cleanup as well*/ /* esp_http_client_close will be called inside cleanup as well*/
esp_err = esp_http_client_cleanup(client_handle); esp_err = esp_http_client_cleanup(client_handle);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] esp_http_client_cleanupp Failed : %s\n", millis(), esp_err_to_name(esp_err)); LOG_ERR("OTA", "esp_http_client_cleanup Failed : %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
@@ -119,17 +120,17 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
filter["assets"][0]["size"] = true; filter["assets"][0]["size"] = true;
const DeserializationError error = deserializeJson(doc, local_buf, DeserializationOption::Filter(filter)); const DeserializationError error = deserializeJson(doc, local_buf, DeserializationOption::Filter(filter));
if (error) { if (error) {
Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); LOG_ERR("OTA", "JSON parse failed: %s", error.c_str());
return JSON_PARSE_ERROR; return JSON_PARSE_ERROR;
} }
if (!doc["tag_name"].is<std::string>()) { if (!doc["tag_name"].is<std::string>()) {
Serial.printf("[%lu] [OTA] No tag_name found\n", millis()); LOG_ERR("OTA", "No tag_name found");
return JSON_PARSE_ERROR; return JSON_PARSE_ERROR;
} }
if (!doc["assets"].is<JsonArray>()) { if (!doc["assets"].is<JsonArray>()) {
Serial.printf("[%lu] [OTA] No assets found\n", millis()); LOG_ERR("OTA", "No assets found");
return JSON_PARSE_ERROR; return JSON_PARSE_ERROR;
} }
@@ -146,11 +147,11 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
} }
if (!updateAvailable) { if (!updateAvailable) {
Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis()); LOG_ERR("OTA", "No firmware.bin asset found");
return NO_UPDATE; return NO_UPDATE;
} }
Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str()); LOG_DBG("OTA", "Found update: %s", latestVersion.c_str());
return OK; return OK;
} }
@@ -233,7 +234,7 @@ OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() {
esp_err = esp_https_ota_begin(&ota_config, &ota_handle); esp_err = esp_https_ota_begin(&ota_config, &ota_handle);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] HTTP OTA Begin Failed: %s\n", millis(), esp_err_to_name(esp_err)); LOG_DBG("OTA", "HTTP OTA Begin Failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
@@ -249,24 +250,23 @@ OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() {
esp_wifi_set_ps(WIFI_PS_MIN_MODEM); esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] esp_https_ota_perform Failed: %s\n", millis(), esp_err_to_name(esp_err)); LOG_ERR("OTA", "esp_https_ota_perform Failed: %s", esp_err_to_name(esp_err));
esp_https_ota_finish(ota_handle); esp_https_ota_finish(ota_handle);
return HTTP_ERROR; return HTTP_ERROR;
} }
if (!esp_https_ota_is_complete_data_received(ota_handle)) { if (!esp_https_ota_is_complete_data_received(ota_handle)) {
Serial.printf("[%lu] [OTA] esp_https_ota_is_complete_data_received Failed: %s\n", millis(), LOG_ERR("OTA", "esp_https_ota_is_complete_data_received Failed: %s", esp_err_to_name(esp_err));
esp_err_to_name(esp_err));
esp_https_ota_finish(ota_handle); esp_https_ota_finish(ota_handle);
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
esp_err = esp_https_ota_finish(ota_handle); esp_err = esp_https_ota_finish(ota_handle);
if (esp_err != ESP_OK) { if (esp_err != ESP_OK) {
Serial.printf("[%lu] [OTA] esp_https_ota_finish Failed: %s\n", millis(), esp_err_to_name(esp_err)); LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR; return INTERNAL_UPDATE_ERROR;
} }
Serial.printf("[%lu] [OTA] Update completed\n", millis()); LOG_INF("OTA", "Update completed");
return OK; return OK;
} }

60
src/util/BookSettings.cpp Normal file
View File

@@ -0,0 +1,60 @@
#include "BookSettings.h"
#include <HalStorage.h>
#include <Logging.h>
#include <Serialization.h>
namespace {
constexpr uint8_t BOOK_SETTINGS_VERSION = 1;
constexpr uint8_t BOOK_SETTINGS_COUNT = 1; // Number of persisted fields
} // namespace
std::string BookSettings::filePath(const std::string& cachePath) { return cachePath + "/book_settings.bin"; }
BookSettings BookSettings::load(const std::string& cachePath) {
BookSettings settings;
FsFile f;
if (!Storage.openFileForRead("BST", filePath(cachePath), f)) {
return settings;
}
uint8_t version;
serialization::readPod(f, version);
if (version != BOOK_SETTINGS_VERSION) {
f.close();
return settings;
}
uint8_t fieldCount;
serialization::readPod(f, fieldCount);
// Read fields that exist (supports older files with fewer fields)
uint8_t fieldsRead = 0;
do {
serialization::readPod(f, settings.letterboxFillOverride);
if (++fieldsRead >= fieldCount) break;
// New fields added here for forward compatibility
} while (false);
f.close();
LOG_DBG("BST", "Loaded book settings from %s (letterboxFill=%d)", filePath(cachePath).c_str(),
settings.letterboxFillOverride);
return settings;
}
bool BookSettings::save(const std::string& cachePath, const BookSettings& settings) {
FsFile f;
if (!Storage.openFileForWrite("BST", filePath(cachePath), f)) {
LOG_ERR("BST", "Could not save book settings!");
return false;
}
serialization::writePod(f, BOOK_SETTINGS_VERSION);
serialization::writePod(f, BOOK_SETTINGS_COUNT);
serialization::writePod(f, settings.letterboxFillOverride);
// New fields added here
f.close();
LOG_DBG("BST", "Saved book settings to %s", filePath(cachePath).c_str());
return true;
}

31
src/util/BookSettings.h Normal file
View File

@@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
#include <string>
#include "CrossPointSettings.h"
// Per-book settings stored in the book's cache directory.
// Fields default to sentinel values (0xFF) meaning "use global setting".
class BookSettings {
public:
// 0xFF = use global default; otherwise one of SLEEP_SCREEN_LETTERBOX_FILL values (0-2).
uint8_t letterboxFillOverride = USE_GLOBAL;
static constexpr uint8_t USE_GLOBAL = 0xFF;
// Returns the effective letterbox fill mode: the per-book override if set,
// otherwise the global setting from CrossPointSettings.
uint8_t getEffectiveLetterboxFill() const {
if (letterboxFillOverride != USE_GLOBAL &&
letterboxFillOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) {
return letterboxFillOverride;
}
return SETTINGS.sleepScreenLetterboxFill;
}
static BookSettings load(const std::string& cachePath);
static bool save(const std::string& cachePath, const BookSettings& settings);
private:
static std::string filePath(const std::string& cachePath);
};

View File

@@ -1,6 +1,7 @@
#include "BookmarkStore.h" #include "BookmarkStore.h"
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h>
#include <algorithm> #include <algorithm>
@@ -78,7 +79,7 @@ std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) { bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) {
FsFile f; FsFile f;
if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) { if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) {
Serial.printf("[%lu] [BKM] Could not save bookmarks!\n", millis()); LOG_ERR("BKM", "Could not save bookmarks!");
return false; return false;
} }
@@ -107,7 +108,7 @@ bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmar
} }
f.close(); f.close();
Serial.printf("[%lu] [BKM] Saved %d bookmarks\n", millis(), count); LOG_DBG("BKM", "Saved %d bookmarks", count);
return true; return true;
} }

View File

@@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function<void
if (onProgress) onProgress(100); if (onProgress) onProgress(100);
return result; return result;
} }
std::vector<std::string> Dictionary::getStemVariants(const std::string& word) {
std::vector<std::string> variants;
size_t len = word.size();
if (len < 3) return variants;
auto endsWith = [&word, len](const char* suffix) {
size_t slen = strlen(suffix);
return len >= slen && word.compare(len - slen, slen, suffix) == 0;
};
auto add = [&variants](const std::string& s) {
if (s.size() >= 2) variants.push_back(s);
};
// Plurals (longer suffixes first to avoid partial matches)
if (endsWith("sses")) add(word.substr(0, len - 2));
if (endsWith("ses")) add(word.substr(0, len - 2) + "is"); // analyses -> analysis
if (endsWith("ies")) {
add(word.substr(0, len - 3) + "y");
add(word.substr(0, len - 2)); // dies -> die, ties -> tie
}
if (endsWith("ves")) {
add(word.substr(0, len - 3) + "f"); // wolves -> wolf
add(word.substr(0, len - 3) + "fe"); // knives -> knife
add(word.substr(0, len - 1)); // misgives -> misgive
}
if (endsWith("men")) add(word.substr(0, len - 3) + "man"); // firemen -> fireman
if (endsWith("es") && !endsWith("sses") && !endsWith("ies") && !endsWith("ves")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
}
if (endsWith("s") && !endsWith("ss") && !endsWith("us") && !endsWith("es")) {
add(word.substr(0, len - 1));
}
// Past tense
if (endsWith("ied")) {
add(word.substr(0, len - 3) + "y");
add(word.substr(0, len - 1));
}
if (endsWith("ed") && !endsWith("ied")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
if (len > 4 && word[len - 3] == word[len - 4]) {
add(word.substr(0, len - 3));
}
}
// Progressive
if (endsWith("ying")) {
add(word.substr(0, len - 4) + "ie");
}
if (endsWith("ing") && !endsWith("ying")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 3) + "e");
if (len > 5 && word[len - 4] == word[len - 5]) {
add(word.substr(0, len - 4));
}
}
// Adverb
if (endsWith("ically")) {
add(word.substr(0, len - 6) + "ic"); // historically -> historic
add(word.substr(0, len - 4)); // basically -> basic
}
if (endsWith("ally") && !endsWith("ically")) {
add(word.substr(0, len - 4) + "al"); // accidentally -> accidental
add(word.substr(0, len - 2)); // naturally -> natur... (fallback to -ly strip)
}
if (endsWith("ily") && !endsWith("ally")) {
add(word.substr(0, len - 3) + "y");
}
if (endsWith("ly") && !endsWith("ily") && !endsWith("ally")) {
add(word.substr(0, len - 2));
}
// Comparative / superlative
if (endsWith("ier")) {
add(word.substr(0, len - 3) + "y");
}
if (endsWith("er") && !endsWith("ier")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
if (len > 4 && word[len - 3] == word[len - 4]) {
add(word.substr(0, len - 3));
}
}
if (endsWith("iest")) {
add(word.substr(0, len - 4) + "y");
}
if (endsWith("est") && !endsWith("iest")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 2));
if (len > 5 && word[len - 4] == word[len - 5]) {
add(word.substr(0, len - 4));
}
}
// Derivational suffixes
if (endsWith("ness")) add(word.substr(0, len - 4));
if (endsWith("ment")) add(word.substr(0, len - 4));
if (endsWith("ful")) add(word.substr(0, len - 3));
if (endsWith("less")) add(word.substr(0, len - 4));
if (endsWith("able")) {
add(word.substr(0, len - 4));
add(word.substr(0, len - 4) + "e");
}
if (endsWith("ible")) {
add(word.substr(0, len - 4));
add(word.substr(0, len - 4) + "e");
}
if (endsWith("ation")) {
add(word.substr(0, len - 5)); // information -> inform
add(word.substr(0, len - 5) + "e"); // exploration -> explore
add(word.substr(0, len - 5) + "ate"); // donation -> donate
}
if (endsWith("tion") && !endsWith("ation")) {
add(word.substr(0, len - 4) + "te"); // completion -> complete
add(word.substr(0, len - 3)); // action -> act
add(word.substr(0, len - 3) + "e"); // reduction -> reduce
}
if (endsWith("ion") && !endsWith("tion")) {
add(word.substr(0, len - 3)); // revision -> revis (-> revise via +e)
add(word.substr(0, len - 3) + "e"); // revision -> revise
}
if (endsWith("al") && !endsWith("ial")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 2) + "e");
}
if (endsWith("ial")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 3) + "e");
}
if (endsWith("ous")) {
add(word.substr(0, len - 3)); // dangerous -> danger
add(word.substr(0, len - 3) + "e"); // famous -> fame
}
if (endsWith("ive")) {
add(word.substr(0, len - 3)); // active -> act
add(word.substr(0, len - 3) + "e"); // creative -> create
}
if (endsWith("ize")) {
add(word.substr(0, len - 3)); // modernize -> modern
add(word.substr(0, len - 3) + "e");
}
if (endsWith("ise")) {
add(word.substr(0, len - 3)); // advertise -> advert
add(word.substr(0, len - 3) + "e");
}
if (endsWith("en")) {
add(word.substr(0, len - 2)); // darken -> dark
add(word.substr(0, len - 2) + "e"); // widen -> wide
}
// Prefix removal
if (len > 5 && word.compare(0, 2, "un") == 0) add(word.substr(2));
if (len > 6 && word.compare(0, 3, "dis") == 0) add(word.substr(3));
if (len > 6 && word.compare(0, 3, "mis") == 0) add(word.substr(3));
if (len > 6 && word.compare(0, 3, "pre") == 0) add(word.substr(3));
if (len > 7 && word.compare(0, 4, "over") == 0) add(word.substr(4));
if (len > 5 && word.compare(0, 2, "re") == 0) add(word.substr(2));
// Deduplicate while preserving insertion order (inflectional stems first, prefixes last)
std::vector<std::string> deduped;
for (const auto& v : variants) {
if (std::find(deduped.begin(), deduped.end(), v) != deduped.end()) continue;
// cppcheck-suppress useStlAlgorithm
deduped.push_back(v);
}
return deduped;
}
int Dictionary::editDistance(const std::string& a, const std::string& b, int maxDist) {
int m = static_cast<int>(a.size());
int n = static_cast<int>(b.size());
if (std::abs(m - n) > maxDist) return maxDist + 1;
std::vector<int> dp(n + 1);
for (int j = 0; j <= n; j++) dp[j] = j;
for (int i = 1; i <= m; i++) {
int prev = dp[0];
dp[0] = i;
int rowMin = dp[0];
for (int j = 1; j <= n; j++) {
int temp = dp[j];
if (a[i - 1] == b[j - 1]) {
dp[j] = prev;
} else {
dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]});
}
prev = temp;
if (dp[j] < rowMin) rowMin = dp[j];
}
if (rowMin > maxDist) return maxDist + 1;
}
return dp[n];
}
std::vector<std::string> Dictionary::findSimilar(const std::string& word, int maxResults) {
if (!indexLoaded || sparseOffsets.empty()) return {};
FsFile idx;
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return {};
// Binary search to find the segment containing or nearest to the word
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
while (lo < hi) {
int mid = lo + (hi - lo + 1) / 2;
idx.seekSet(sparseOffsets[mid]);
std::string key = readWord(idx);
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
lo = mid;
} else {
hi = mid - 1;
}
}
// Scan entries from the segment before through the segment after the target
int startSeg = std::max(0, lo - 1);
int endSeg = std::min(static_cast<int>(sparseOffsets.size()) - 1, lo + 1);
idx.seekSet(sparseOffsets[startSeg]);
int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL;
int remaining = static_cast<int>(totalWords) - startSeg * SPARSE_INTERVAL;
if (totalToScan > remaining) totalToScan = remaining;
int maxDist = std::max(2, static_cast<int>(word.size()) / 3 + 1);
struct Candidate {
std::string text;
int distance;
};
std::vector<Candidate> candidates;
for (int i = 0; i < totalToScan; i++) {
std::string key = readWord(idx);
if (key.empty()) break;
uint8_t skip[8];
if (idx.read(skip, 8) != 8) break;
if (key == word) continue;
int dist = editDistance(key, word, maxDist);
if (dist <= maxDist) {
candidates.push_back({key, dist});
}
}
idx.close();
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b) { return a.distance < b.distance; });
std::vector<std::string> results;
for (size_t i = 0; i < candidates.size() && static_cast<int>(results.size()) < maxResults; i++) {
results.push_back(candidates[i].text);
}
return results;
}

View File

@@ -14,6 +14,8 @@ class Dictionary {
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr, static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
const std::function<bool()>& shouldCancel = nullptr); const std::function<bool()>& shouldCancel = nullptr);
static std::string cleanWord(const std::string& word); static std::string cleanWord(const std::string& word);
static std::vector<std::string> getStemVariants(const std::string& word);
static std::vector<std::string> findSimilar(const std::string& word, int maxResults = 6);
private: private:
static constexpr int SPARSE_INTERVAL = 512; static constexpr int SPARSE_INTERVAL = 512;
@@ -28,4 +30,5 @@ class Dictionary {
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel); static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
static std::string readWord(FsFile& file); static std::string readWord(FsFile& file);
static std::string readDefinition(uint32_t offset, uint32_t size); static std::string readDefinition(uint32_t offset, uint32_t size);
static int editDistance(const std::string& a, const std::string& b, int maxDist);
}; };