33 Commits

Author SHA1 Message Date
cottongin
b965ce9fb7 fix: Port upstream cover extraction fallback and outline improvements
Port PR #838 (epub cover fallback logic) and PR #907 (cover outlines):

- Add fallback cover filename probing when EPUB metadata lacks cover info
- Case-insensitive extension checking for cover images
- Detect and re-generate corrupt/empty thumbnail BMPs
- Always draw outline rect on cover tiles for legibility (PR #907)
- Upgrade Storage.exists() checks to Epub::isValidThumbnailBmp()
- Fallback chain: Real Cover → PlaceholderCoverGenerator → X-pattern marker
- Add epub.load retry logic (cache-only first, then full build)
- Adapt upstream Serial.printf calls to LOG_DBG/LOG_ERR macros

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:20:27 -05:00
cottongin
744d6160e8 Merge branch 'master' into mod/master-img
Merge upstream perf: Improve large CSS files handling (#779)

Conflicts resolved:
- Section.cpp: Combined mod's image support variables with master's
  CSS parser loading pattern
- CssParser.cpp: Accepted master's streaming parser rewrite, ported
  mod's width property handler into parseDeclarationIntoStyle()

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 20:36:48 -05:00
cottongin
66f703df69 fix: Fix cover thumbnail pipeline for home screen
Remove empty sentinel BMP file from generateThumbBmp() that blocked
placeholder generation for books without covers. Add removeBook() to
RecentBooksStore and clear book from recents on cache delete. Ensure
home screen always generates placeholder when thumbnail generation fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 19:53:52 -05:00
cottongin
19004eefaa feat: Add EPUB embedded image support (JPEG/PNG)
Cherry-pick merge from pablohc/crosspoint-reader@2d8cbcf, based on
upstream PR #556 by martinbrook with pablohc's refresh optimization.

- Add JPEG decoder (picojpeg) and PNG decoder (PNGdec) with 4-level
  grayscale Bayer dithering for e-ink display
- Add pixel caching system (.pxc files) for fast image re-rendering
- Integrate image extraction from EPUB HTML parser (<img> tag support)
- Add ImageBlock/PageImage types with serialization support
- Add image-aware refresh optimization (double FAST_REFRESH technique)
- Add experimental displayWindow() partial refresh support
- Bump section cache version 12->13 to invalidate stale caches
- Resolve TAG_PageImage=3 to avoid conflict with mod's TAG_PageTableRow=2

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 17:29:39 -05:00
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
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
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
cottongin
21a75c624d feat: Implement bookmark functionality for epub reader
Replace bookmark stubs with full add/remove/navigate implementation:

- BookmarkStore: per-book binary persistence on SD card with v2 format
  supporting text snippets (backward-compatible with v1)
- Visual bookmark ribbon indicator drawn on bookmarked pages via fillPolygon
- Reader menu dynamically shows Add/Remove Bookmark based on current page state
- Bookmark selection activity with chapter name, first sentence snippet, and
  page number display; long-press to delete with confirmation
- Go to Bookmark falls back to Table of Contents when no bookmarks exist
- Smart snippet extraction: skips partial sentences (lowercase first word)
  to capture the first full sentence on the page
- Label truncation reserves space for page suffix so it's never cut off
- Half refresh forced on menu exit to clear popup/menu artifacts

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 20:40:07 -05:00
cottongin
8d4bbf284d feat: Add dictionary word lookup feature with cached index
Implements StarDict-based dictionary lookup from the reader menu,
adapted from upstream PR #857 with /.dictionary/ folder path,
std::vector compatibility (PR #802), HTML definition rendering,
orientation-aware button hints, side button hints with CCW text
rotation, sparse index caching to SD card, pronunciation line
filtering, and reorganized reader menu with bookmark stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 19:36:14 -05:00
cottongin
905f694576 prerender book covers and thumbnails when opening a book for the first time
Moves cover/thumbnail generation from lazy (Home screen, Sleep screen) into
each reader activity's onEnter(). On first open, generates all needed BMPs
(cover, cover_crop, thumbnails for all theme heights) with a "Preparing
book..." progress popup. Subsequent opens skip instantly when files exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:13:55 -05:00
cottongin
e798065a5c merge upstream PR #852: feat: lower CPU freq on idle, add HalPowerManager 2026-02-12 12:09:20 -05:00
cottongin
5e269f912f merge upstream PR #802: perf: Replace std::list with std::vector in text layout 2026-02-12 12:09:10 -05:00
cottongin
182c236050 Merge branch 'master' into mod/master
Resolve single conflict in SleepActivity.cpp: adopt upstream millis()
timestamp log format while preserving mod's edgeCachePath argument to
renderBitmapSleepScreen().

Upstream changes (14 commits): unified navigation handling, Italian
hyphenation, natural file sort, auto WiFi reconnect, power saving on
idle, OPDS fixes, uniform debug logging, and more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 11:54:40 -05:00
Xuan Son Nguyen
73cd05827a move IDLE_POWER_SAVING_MS 2026-02-12 13:19:37 +01:00
Xuan Son Nguyen
ea32ba0f8d add HalPowerManager 2026-02-12 13:12:13 +01:00
Xuan Son Nguyen
f7b1113819 Merge branch 'master' into xsn/idle_cpu_freq 2026-02-12 11:37:32 +01:00
Xuan Son Nguyen
228a1cb511 rm test 2026-02-12 11:37:12 +01:00
Xuan Son Nguyen
b72283d304 change cpu freq on idle 2026-02-10 23:27:45 +01:00
Xuan Son Nguyen
8cf226613b clang format 2026-02-10 14:19:16 +01:00
Xuan Son Nguyen
d4f25c44bf lower to 3 seconds 2026-02-10 11:31:28 +01:00
Kuanysh Bekkulov
bc12556da1 perf: Replace std::list with std::vector in TextBlock and ParsedText
Replace std::list with std::vector for the words, wordStyles,
wordXpos, and wordContinues containers in TextBlock and ParsedText.

Vectors provide contiguous memory layout for better cache locality
and O(1) random access, eliminating per-node heap allocation and
the 16-byte prev/next pointer overhead of doubly-linked list nodes.
The indexed access also removes the need for a separate continuesVec
copy that was previously built from the list for O(1) layout access.
2026-02-09 23:46:08 +05:00
Xuan Son Nguyen
4e7bb8979c revert test 2026-02-09 19:20:36 +01:00
cottongin
4edb14bdd9 feat: Sleep screen letterbox fill and image upscaling
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
Add configurable letterbox fill for sleep screen cover images that don't
match the display aspect ratio. Four fill modes are available: Solid
(single dominant edge shade), Blended (per-pixel edge colors), Gradient
(edge colors interpolated toward white/black), and None.

Enable upscaling of cover images smaller than the display in Fit mode by
modifying drawBitmap/drawBitmap1Bit to support both up and downscaling
via a unified block-fill approach.

Edge sampling data is cached to .crosspoint alongside the cover BMP to
avoid redundant bitmap scanning on subsequent sleeps. Cache is validated
against screen dimensions and auto-regenerated when stale.

New settings: Letterbox Fill (None/Solid/Blended/Gradient) and Gradient
Direction (To White/To Black).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 11:52:55 -05:00
Xuan Son Nguyen
eb79b98f2b power saving on idle 2026-02-09 12:45:16 +01:00
cottongin
a85d5e627b .gitignore tweaks for mod fork 2026-02-09 04:15:00 -05:00
88 changed files with 8844 additions and 355 deletions

5
.gitignore vendored
View File

@@ -9,3 +9,8 @@ build
**/__pycache__/
/compile_commands.json
/.cache
# mod
mod/*
.cursor/*
chat-summaries/*

View File

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

View File

@@ -1,11 +1,14 @@
#include "Epub.h"
#include <FsHelpers.h>
#include <HalDisplay.h>
#include <HalStorage.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <ZipFile.h>
#include <algorithm>
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNavParser.h"
@@ -440,9 +443,18 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
}
bool Epub::generateCoverBmp(bool cropped) const {
bool invalid = false;
// Already generated, return true
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
return true;
// is this a valid cover or just an empty file we created to mark generation attempts?
invalid = !isValidThumbnailBmp(getCoverBmpPath(cropped));
if (invalid) {
// Remove the old invalid cover so we can attempt to generate a new one
Storage.remove(getCoverBmpPath(cropped).c_str());
LOG_DBG("EBP", "Previous cover generation attempt failed for %s mode, retrying", cropped ? "cropped" : "fit");
} else {
return true;
}
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
@@ -451,13 +463,33 @@ bool Epub::generateCoverBmp(bool cropped) const {
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
std::string effectiveCoverImageHref = coverImageHref;
if (coverImageHref.empty()) {
// Fallback: try common cover filenames
std::vector<std::string> coverCandidates = getCoverCandidates();
for (const auto& candidate : coverCandidates) {
effectiveCoverImageHref = candidate;
// Try to read a small amount to check if exists
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
if (test) {
free(test);
break;
} else {
effectiveCoverImageHref.clear();
}
}
}
if (effectiveCoverImageHref.empty()) {
LOG_ERR("EBP", "No known cover image");
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
// Check for JPG/JPEG extensions (case insensitive)
std::string lowerHref = effectiveCoverImageHref;
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
bool isJpg =
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
if (isJpg) {
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@@ -465,7 +497,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
@@ -499,9 +531,18 @@ std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Epub::generateThumbBmp(int height) const {
bool invalid = false;
// Already generated, return true
if (Storage.exists(getThumbBmpPath(height).c_str())) {
return true;
// is this a valid thumbnail or just an empty file we created to mark generation attempts?
invalid = !isValidThumbnailBmp(getThumbBmpPath(height));
if (invalid) {
// Remove the old invalid thumbnail so we can attempt to generate a new one
Storage.remove(getThumbBmpPath(height).c_str());
LOG_DBG("EBP", "Previous thumbnail generation attempt failed for height %d, retrying", height);
} else {
return true;
}
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
@@ -510,56 +551,246 @@ bool Epub::generateThumbBmp(int height) const {
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
std::string effectiveCoverImageHref = coverImageHref;
if (coverImageHref.empty()) {
// Fallback: try common cover filenames
std::vector<std::string> coverCandidates = getCoverCandidates();
for (const auto& candidate : coverCandidates) {
effectiveCoverImageHref = candidate;
// Try to read a small amount to check if exists
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
if (test) {
free(test);
break;
} else {
effectiveCoverImageHref.clear();
}
}
}
if (effectiveCoverImageHref.empty()) {
LOG_DBG("EBP", "No known cover image for thumbnail");
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
Storage.remove(coverJpgTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
Storage.remove(getThumbBmpPath(height).c_str());
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
// Check for JPG/JPEG extensions (case insensitive)
std::string lowerHref = effectiveCoverImageHref;
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
bool isJpg =
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
if (isJpg) {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
Storage.remove(coverJpgTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
Storage.remove(getThumbBmpPath(height).c_str());
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
}
}
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close();
return false;
}
bool Epub::generateInvalidFormatThumbBmp(int height) const {
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
// This BMP is a valid 1-bit file used as a marker to prevent repeated
// generation attempts when conversion fails (e.g., progressive JPG).
const int width = height * 0.6; // Same aspect ratio as normal thumbnails
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
const int imageSize = rowBytes * height;
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
const int dataOffset = 14 + 40 + 8;
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
return false;
}
// BMP file header (14 bytes)
thumbBmp.write('B');
thumbBmp.write('M');
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header (BITMAPINFOHEADER - 40 bytes)
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t bmpWidth = width;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
int32_t bmpHeight = -height; // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835; // 72 DPI
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit)
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
thumbBmp.write(black, 4);
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
thumbBmp.write(white, 4);
// Generate X pattern bitmap data
// In BMP, 0 = black (first color in palette), 1 = white
// We'll draw black pixels on white background
for (int y = 0; y < height; y++) {
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
// Map this row to a horizontal position for diagonals
const int scaledY = (y * width) / height;
const int thickness = 2; // thickness of diagonal lines in pixels
for (int x = 0; x < width; x++) {
bool drawPixel = false;
// Main diagonal (top-left to bottom-right)
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
// Other diagonal (top-right to bottom-left)
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
if (drawPixel) {
const int byteIndex = x / 8;
const int bitIndex = 7 - (x % 8); // MSB first
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
}
}
// Write the row data
thumbBmp.write(rowData.data(), rowBytes);
}
thumbBmp.close();
LOG_DBG("EBP", "Generated invalid format thumbnail BMP");
return true;
}
bool Epub::generateInvalidFormatCoverBmp(bool cropped) const {
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
// This BMP is intentionally a valid image that visually indicates a
// malformed/unsupported cover image instead of leaving an empty marker
// file that would cause repeated generation attempts.
// Derive logical portrait dimensions from the display hardware constants
// EInkDisplay reports native panel orientation as 800x480; use min/max
const int hwW = HalDisplay::DISPLAY_WIDTH;
const int hwH = HalDisplay::DISPLAY_HEIGHT;
const int width = std::min(hwW, hwH); // logical portrait width (480)
const int height = std::max(hwW, hwH); // logical portrait height (800)
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
const int imageSize = rowBytes * height;
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
const int dataOffset = 14 + 40 + 8;
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
return false;
}
// BMP file header (14 bytes)
coverBmp.write('B');
coverBmp.write('M');
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header (BITMAPINFOHEADER - 40 bytes)
uint32_t dibHeaderSize = 40;
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t bmpWidth = width;
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
int32_t bmpHeight = -height; // Negative for top-down
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
uint16_t planes = 1;
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1;
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835; // 72 DPI
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit)
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
coverBmp.write(black, 4);
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
coverBmp.write(white, 4);
// Generate X pattern bitmap data
// In BMP, 0 = black (first color in palette), 1 = white
// We'll draw black pixels on white background
for (int y = 0; y < height; y++) {
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
const int scaledY = (y * width) / height;
const int thickness = 6; // thicker lines for full-cover visibility
for (int x = 0; x < width; x++) {
bool drawPixel = false;
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
if (drawPixel) {
const int byteIndex = x / 8;
const int bitIndex = 7 - (x % 8);
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
}
}
coverBmp.write(rowData.data(), rowBytes);
}
coverBmp.close();
LOG_DBG("EBP", "Generated invalid format cover BMP");
return true;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) {
LOG_DBG("EBP", "Failed to read item, empty href");
@@ -707,3 +938,45 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
return totalProgress / static_cast<float>(bookSize);
}
bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
if (!Storage.exists(bmpPath.c_str())) {
return false;
}
FsFile file = Storage.open(bmpPath.c_str());
if (!file) {
LOG_ERR("EBP", "Failed to open thumbnail BMP at path: %s", bmpPath.c_str());
return false;
}
size_t fileSize = file.size();
if (fileSize == 0) {
// Empty file is a marker for "no cover available"
LOG_DBG("EBP", "Thumbnail BMP is empty (no cover marker) at path: %s", bmpPath.c_str());
file.close();
return false;
}
// BMP header starts with 'B' 'M'
uint8_t header[2];
size_t bytesRead = file.read(header, 2);
if (bytesRead != 2) {
LOG_ERR("EBP", "Failed to read thumbnail BMP header at path: %s", bmpPath.c_str());
file.close();
return false;
}
LOG_DBG("EBP", "Thumbnail BMP header: %c%c", header[0], header[1]);
file.close();
return header[0] == 'B' && header[1] == 'M';
}
std::vector<std::string> Epub::getCoverCandidates() const {
std::vector<std::string> coverDirectories = {".", "images", "Images", "OEBPS", "OEBPS/images", "OEBPS/Images"};
std::vector<std::string> coverExtensions = {".jpg", ".jpeg"}; // add ".png" when PNG cover support is implemented
std::vector<std::string> coverCandidates;
for (const auto& ext : coverExtensions) {
for (const auto& dir : coverDirectories) {
std::string candidate = (dir == ".") ? "cover" + ext : dir + "/cover" + ext;
coverCandidates.push_back(candidate);
}
}
return coverCandidates;
}

View File

@@ -52,10 +52,23 @@ class Epub {
const std::string& getAuthor() const;
const std::string& getLanguage() const;
std::string getCoverBmpPath(bool cropped = false) const;
// Generate a 1-bit BMP cover image from the EPUB cover image.
// Returns true on success. On conversion failure, callers may use
// `generateInvalidFormatCoverBmp` to create a valid marker BMP.
bool generateCoverBmp(bool cropped = false) const;
// Create a valid 1-bit BMP that visually indicates an invalid/unsupported
// cover format (an X pattern). This prevents repeated generation attempts
// by providing a valid BMP file that `isValidThumbnailBmp` accepts.
bool generateInvalidFormatCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
std::string getThumbBmpPath(int height) const;
// Generate a thumbnail BMP at the requested `height`. Returns true on
// successful conversion. If conversion fails, `generateInvalidFormatThumbBmp`
// can be used to write a valid marker image that prevents retries.
bool generateThumbBmp(int height) const;
// Create a valid 1-bit thumbnail BMP with an X marker indicating an
// invalid/unsupported cover image instead of leaving an empty marker file.
bool generateInvalidFormatThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
@@ -72,4 +85,9 @@ class Epub {
size_t getBookSize() const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
CssParser* getCssParser() const { return cssParser.get(); }
static bool isValidThumbnailBmp(const std::string& bmpPath);
private:
std::vector<std::string> getCoverCandidates() const;
};

View File

@@ -1,8 +1,17 @@
#include "Page.h"
#include <GfxRenderer.h>
#include <Logging.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) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
}
@@ -25,6 +34,142 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
// ---------------------------------------------------------------------------
// PageImage
// ---------------------------------------------------------------------------
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
// Images don't use fontId or text rendering
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
}
bool PageImage::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
// serialize ImageBlock
return imageBlock->serialize(file);
}
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
int16_t xPos;
int16_t yPos;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
auto ib = ImageBlock::deserialize(file);
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), 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 {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@@ -36,8 +181,7 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count);
for (const auto& el : elements) {
// Only PageLine exists currently
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
if (!el->serialize(file)) {
return false;
}
@@ -59,6 +203,16 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file);
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 if (tag == TAG_PageImage) {
auto pi = PageImage::deserialize(file);
page->elements.push_back(std::move(pi));
} else {
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr;
@@ -67,3 +221,50 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
return page;
}
bool Page::getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const {
bool firstImage = true;
for (const auto& el : elements) {
if (el->getTag() == TAG_PageImage) {
PageImage* pi = static_cast<PageImage*>(el.get());
ImageBlock* ib = pi->getImageBlock();
if (firstImage) {
// Initialize with first image bounds
outX = pi->xPos;
outY = pi->yPos;
outWidth = ib->getWidth();
outHeight = ib->getHeight();
firstImage = false;
} else {
// Expand bounding box to include this image
int imgX = pi->xPos;
int imgY = pi->yPos;
int imgW = ib->getWidth();
int imgH = ib->getHeight();
// Expand right boundary
if (imgX + imgW > outX + outWidth) {
outWidth = (imgX + imgW) - outX;
}
// Expand left boundary
if (imgX < outX) {
int oldRight = outX + outWidth;
outX = imgX;
outWidth = oldRight - outX;
}
// Expand bottom boundary
if (imgY + imgH > outY + outHeight) {
outHeight = (imgY + imgH) - outY;
}
// Expand top boundary
if (imgY < outY) {
int oldBottom = outY + outHeight;
outY = imgY;
outHeight = oldBottom - outY;
}
}
}
}
return !firstImage; // Return true if at least one image was found
}

View File

@@ -1,13 +1,17 @@
#pragma once
#include <HalStorage.h>
#include <algorithm>
#include <utility>
#include <vector>
#include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h"
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageTableRow = 2,
TAG_PageImage = 3,
};
// represents something that has been added to a page
@@ -17,6 +21,7 @@ class PageElement {
int16_t yPos;
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default;
virtual PageElementTag getTag() const = 0;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0;
};
@@ -28,11 +33,59 @@ class PageLine final : public PageElement {
public:
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(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;
bool serialize(FsFile& file) override;
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);
};
// An image element on a page
class PageImage final : public PageElement {
std::shared_ptr<ImageBlock> imageBlock;
public:
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; }
static std::unique_ptr<PageImage> deserialize(FsFile& file);
// Helper to get image block dimensions (needed for bounding box calculation)
ImageBlock* getImageBlock() const { return imageBlock.get(); }
};
class Page {
public:
// the list of block index and line numbers on this page
@@ -40,4 +93,15 @@ class Page {
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file);
// Check if page contains any images (used to force full refresh)
bool hasImages() const {
return std::any_of(elements.begin(), elements.end(),
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
}
// Get the bounding box of all images on this page.
// Returns true if page has images and fills out the bounding box coordinates.
// If no images, returns false.
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const;
};

View File

@@ -5,7 +5,6 @@
#include <algorithm>
#include <cmath>
#include <functional>
#include <iterator>
#include <limits>
#include <vector>
@@ -63,6 +62,13 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
}
wordStyles.push_back(combinedStyle);
wordContinues.push_back(attachToPrevious);
forceBreakAfter.push_back(false);
}
void ParsedText::addLineBreak() {
if (!words.empty()) {
forceBreakAfter.back() = true;
}
}
// Consumes data to minimize memory usage
@@ -80,37 +86,26 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
const int spaceWidth = renderer.getSpaceWidth(fontId);
auto wordWidths = calculateWordWidths(renderer, fontId);
// Build indexed continues vector from the parallel list for O(1) access during layout
std::vector<bool> continuesVec(wordContinues.begin(), wordContinues.end());
std::vector<size_t> lineBreakIndices;
if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
}
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, continuesVec, lineBreakIndices, processLine);
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
}
}
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
const size_t totalWordCount = words.size();
std::vector<uint16_t> wordWidths;
wordWidths.reserve(totalWordCount);
wordWidths.reserve(words.size());
auto wordsIt = words.begin();
auto wordStylesIt = wordStyles.begin();
while (wordsIt != words.end()) {
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
std::advance(wordsIt, 1);
std::advance(wordStylesIt, 1);
for (size_t i = 0; i < words.size(); ++i) {
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
}
return wordWidths;
@@ -135,8 +130,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// First word needs to fit in reduced width if there's an indent
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
while (wordWidths[i] > effectiveWidth) {
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true,
&continuesVec)) {
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
break;
}
}
@@ -161,6 +155,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
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
const int gap = j > static_cast<size_t>(i) && !continuesVec[j] ? spaceWidth : 0;
currlen += wordWidths[j] + gap;
@@ -169,8 +168,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
break;
}
// Cannot break after word j if the next word attaches to it (continuation group)
if (j + 1 < totalWordCount && continuesVec[j + 1]) {
// Forced line break after word j overrides continuation (must end line here)
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;
}
@@ -193,6 +195,11 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
dp[i] = cost;
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
@@ -267,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
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 int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth;
const int candidateWidth = spacing + wordWidths[currentIndex];
@@ -275,6 +287,11 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
if (lineWidth + candidateWidth <= effectivePageWidth) {
lineWidth += candidateWidth;
++currentIndex;
// If the word we just added has a forced break, end this line now
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
break;
}
continue;
}
@@ -282,8 +299,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const int availableWidth = effectivePageWidth - lineWidth - spacing;
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
allowFallbackBreaks, &continuesVec)) {
if (availableWidth > 0 &&
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
// Prefix now fits; append it to this line and move to next line
lineWidth += spacing + wordWidths[currentIndex];
++currentIndex;
@@ -300,7 +317,12 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// 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.
// But don't backtrack past a forced break point.
while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) {
// Don't backtrack past a forced break
if (!forceBreakAfter.empty() && forceBreakAfter[currentIndex - 1]) {
break;
}
--currentIndex;
}
@@ -315,20 +337,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// available width.
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
const int fontId, std::vector<uint16_t>& wordWidths,
const bool allowFallbackBreaks, std::vector<bool>* continuesVec) {
const bool allowFallbackBreaks) {
// Guard against invalid indices or zero available width before attempting to split.
if (availableWidth <= 0 || wordIndex >= words.size()) {
return false;
}
// Get iterators to target word and style.
auto wordIt = words.begin();
auto styleIt = wordStyles.begin();
std::advance(wordIt, wordIndex);
std::advance(styleIt, wordIndex);
const std::string& word = *wordIt;
const auto style = *styleIt;
const std::string& word = words[wordIndex];
const auto style = wordStyles[wordIndex];
// Collect candidate breakpoints (byte offsets and hyphen requirements).
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
@@ -365,31 +381,26 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// Split the word at the selected breakpoint and append a hyphen if required.
std::string remainder = word.substr(chosenOffset);
wordIt->resize(chosenOffset);
words[wordIndex].resize(chosenOffset);
if (chosenNeedsHyphen) {
wordIt->push_back('-');
words[wordIndex].push_back('-');
}
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
auto insertWordIt = std::next(wordIt);
auto insertStyleIt = std::next(styleIt);
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The remainder inherits whatever continuation status the original word had with the word after it.
// Find the continues entry for the original word and insert the remainder's entry after it.
auto continuesIt = wordContinues.begin();
std::advance(continuesIt, wordIndex);
const bool originalContinuedToNext = *continuesIt;
const bool originalContinuedToNext = wordContinues[wordIndex];
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
*continuesIt = false;
const auto insertContinuesIt = std::next(continuesIt);
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Keep the indexed vector in sync if provided
if (continuesVec) {
(*continuesVec)[wordIndex] = false;
continuesVec->insert(continuesVec->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.
@@ -450,7 +461,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Pre-calculate X positions for words
// Continuation words attach to the previous word with no space before them
std::list<uint16_t> lineXPos;
std::vector<uint16_t> lineXPos;
lineXPos.reserve(lineWordCount);
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
@@ -463,23 +475,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
}
// Iterators always start at the beginning as we are moving content with splice below
auto wordEndIt = words.begin();
auto wordStyleEndIt = wordStyles.begin();
auto wordContinuesEndIt = wordContinues.begin();
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
std::advance(wordContinuesEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
// Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync)
std::list<bool> lineContinues;
lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt);
// Build line data by moving from the original vectors using index range
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
std::make_move_iterator(words.begin() + lineBreak));
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
for (auto& word : lineWords) {
if (containsSoftHyphen(word)) {
@@ -490,3 +489,22 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
processLine(
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

@@ -3,7 +3,6 @@
#include <EpdFontFamily.h>
#include <functional>
#include <list>
#include <memory>
#include <string>
#include <vector>
@@ -14,9 +13,10 @@
class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordContinues; // true = word attaches to previous (no space before it)
std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles;
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;
bool extraParagraphSpacing;
bool hyphenationEnabled;
@@ -28,8 +28,7 @@ class ParsedText {
int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
std::vector<bool>* continuesVec = nullptr);
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
@@ -42,6 +41,10 @@ class ParsedText {
~ParsedText() = default;
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; }
BlockStyle& getBlockStyle() { return blockStyle; }
size_t size() const { return words.size(); }
@@ -49,4 +52,9 @@ class ParsedText {
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
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

@@ -9,7 +9,7 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 12;
constexpr uint8_t SECTION_FILE_VERSION = 13;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint32_t);
@@ -181,6 +181,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
viewportHeight, hyphenationEnabled, embeddedStyle);
std::vector<uint32_t> lut = {};
// Derive the content base directory and image cache path prefix for the parser
size_t lastSlash = localPath.find_last_of('/');
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
CssParser* cssParser = nullptr;
if (embeddedStyle) {
cssParser = epub->getCssParser();
@@ -191,10 +196,10 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
}
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, popupFn, cssParser);
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();

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

@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
class Block {
public:
virtual ~Block() = default;
virtual void layout(GfxRenderer& renderer) = 0;
virtual BlockType getType() = 0;
virtual bool isEmpty() = 0;
virtual void finish() {}

View File

@@ -0,0 +1,175 @@
#include "ImageBlock.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include "../converters/DitherUtils.h"
#include "../converters/ImageDecoderFactory.h"
// Cache file format:
// - uint16_t width
// - uint16_t height
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
: imagePath(imagePath), width(width), height(height) {}
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
namespace {
std::string getCachePath(const std::string& imagePath) {
// Replace extension with .pxc (pixel cache)
size_t dotPos = imagePath.rfind('.');
if (dotPos != std::string::npos) {
return imagePath.substr(0, dotPos) + ".pxc";
}
return imagePath + ".pxc";
}
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
FsFile cacheFile;
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
}
uint16_t cachedWidth, cachedHeight;
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
cacheFile.close();
return false;
}
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
int widthDiff = abs(cachedWidth - expectedWidth);
int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
expectedWidth, expectedHeight);
cacheFile.close();
return false;
}
// Use cached dimensions for rendering (they're the actual decoded size)
expectedWidth = cachedWidth;
expectedHeight = cachedHeight;
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
cacheFile.close();
return false;
}
for (int row = 0; row < cachedHeight; row++) {
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
free(rowBuffer);
cacheFile.close();
return false;
}
int destY = y + row;
for (int col = 0; col < cachedWidth; col++) {
int byteIdx = col / 4;
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
}
}
free(rowBuffer);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
return true;
}
} // namespace
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Bounds check render position using logical screen dimensions
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
height, screenWidth, screenHeight);
return;
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
return; // Successfully rendered from cache
}
// No cache - need to decode the image
// Check if image file exists
FsFile file;
if (!Storage.openFileForRead("IMG", imagePath, file)) {
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
return;
}
size_t fileSize = file.size();
file.close();
if (fileSize == 0) {
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
RenderConfig config;
config.x = x;
config.y = y;
config.maxWidth = width;
config.maxHeight = height;
config.useGrayscale = true;
config.useDithering = true;
config.performanceMode = false;
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
config.cachePath = cachePath; // Enable caching during decode
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
if (!decoder) {
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
if (!success) {
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
}
bool ImageBlock::serialize(FsFile& file) {
serialization::writeString(file, imagePath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
}
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
std::string path;
serialization::readString(file, path);
int16_t w, h;
serialization::readPod(file, w);
serialization::readPod(file, h);
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
#include "Block.h"
class ImageBlock final : public Block {
public:
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
~ImageBlock() override = default;
const std::string& getImagePath() const { return imagePath; }
int16_t getWidth() const { return width; }
int16_t getHeight() const { return height; }
bool imageExists() const;
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
void render(GfxRenderer& renderer, const int x, const int y);
bool serialize(FsFile& file);
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
private:
std::string imagePath;
int16_t width;
int16_t height;
};

View File

@@ -12,16 +12,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return;
}
auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
for (size_t i = 0; i < words.size(); i++) {
const int wordX = *wordXposIt + x;
const EpdFontFamily::Style currentStyle = *wordStylesIt;
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = *wordIt;
const std::string& w = words[i];
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
@@ -41,10 +38,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
}
std::advance(wordIt, 1);
std::advance(wordStylesIt, 1);
std::advance(wordXposIt, 1);
}
}
@@ -80,15 +73,15 @@ bool TextBlock::serialize(FsFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint16_t wc;
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
// Word count
serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
if (wc > 10000) {
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
return nullptr;

View File

@@ -2,9 +2,9 @@
#include <EpdFontFamily.h>
#include <HalStorage.h>
#include <list>
#include <memory>
#include <string>
#include <vector>
#include "Block.h"
#include "BlockStyle.h"
@@ -12,14 +12,14 @@
// Represents a line of text on a page
class TextBlock final : public Block {
private:
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
@@ -27,8 +27,10 @@ class TextBlock final : public Block {
~TextBlock() override = default;
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
const std::vector<std::string>& getWords() const { return words; }
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; }

View File

@@ -0,0 +1,40 @@
#pragma once
#include <GfxRenderer.h>
#include <stdint.h>
// 4x4 Bayer matrix for ordered dithering
inline const uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
{12, 4, 14, 6},
{3, 11, 1, 9},
{15, 7, 13, 5},
};
// Apply Bayer dithering and quantize to 4 levels (0-3)
// Stateless - works correctly with any pixel processing order
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
int bayer = bayer4x4[y & 3][x & 3];
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
int adjusted = gray + dither;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
if (adjusted < 64) return 0;
if (adjusted < 128) return 1;
if (adjusted < 192) return 2;
return 3;
}
// Draw a pixel respecting the current render mode for grayscale support
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
renderer.drawPixel(x, y, true);
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
renderer.drawPixel(x, y, false);
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
renderer.drawPixel(x, y, false);
}
}

View File

@@ -0,0 +1,42 @@
#include "ImageDecoderFactory.h"
#include <HardwareSerial.h>
#include <memory>
#include <string>
#include "JpegToFramebufferConverter.h"
#include "PngToFramebufferConverter.h"
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
std::string ext = imagePath;
size_t dotPos = ext.rfind('.');
if (dotPos != std::string::npos) {
ext = ext.substr(dotPos);
for (auto& c : ext) {
c = tolower(c);
}
} else {
ext = "";
}
if (JpegToFramebufferConverter::supportsFormat(ext)) {
if (!jpegDecoder) {
jpegDecoder.reset(new JpegToFramebufferConverter());
}
return jpegDecoder.get();
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
if (!pngDecoder) {
pngDecoder.reset(new PngToFramebufferConverter());
}
return pngDecoder.get();
}
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
return nullptr;
}
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }

View File

@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter;
class PngToFramebufferConverter;
class ImageDecoderFactory {
public:
// Returns non-owning pointer - factory owns the decoder lifetime
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
static bool isFormatSupported(const std::string& imagePath);
private:
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
};

View File

@@ -0,0 +1,18 @@
#include "ImageToFramebufferDecoder.h"
#include <Arduino.h>
#include <HardwareSerial.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
if (width * height > MAX_SOURCE_PIXELS) {
Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width,
height, width * height, format.c_str(), MAX_SOURCE_PIXELS);
return false;
}
return true;
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
millis(), feature.c_str(), imagePath.c_str());
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
class GfxRenderer;
struct ImageDimensions {
int16_t width;
int16_t height;
};
struct RenderConfig {
int x, y;
int maxWidth, maxHeight;
bool useGrayscale = true;
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
class ImageToFramebufferDecoder {
public:
virtual ~ImageToFramebufferDecoder() = default;
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
virtual const char* getFormatName() const = 0;
protected:
// Size validation helpers
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
bool validateImageDimensions(int width, int height, const std::string& format);
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
};

View File

@@ -0,0 +1,298 @@
#include "JpegToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include "DitherUtils.h"
#include "PixelCache.h"
struct JpegContext {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
};
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
file.close();
if (status != 0) {
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
return true;
}
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
file.close();
return false;
}
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
file.close();
return false;
}
// Calculate output dimensions
int destWidth, destHeight;
float scale;
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
destWidth = config.maxWidth;
destHeight = config.maxHeight;
scale = (float)destWidth / imageInfo.m_width;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
? (float)config.maxWidth / imageInfo.m_width
: 1.0f;
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
? (float)config.maxHeight / imageInfo.m_height
: 1.0f;
scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
destWidth = (int)(imageInfo.m_width * scale);
destHeight = (int)(imageInfo.m_height * scale);
}
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
file.close();
return false;
}
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Allocate pixel cache if cachePath is provided
PixelCache cache;
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
caching = false;
}
}
int mcuX = 0;
int mcuY = 0;
while (mcuY < imageInfo.m_MCUSPerCol) {
status = pjpeg_decode_mcu();
if (status == PJPG_NO_MORE_BLOCKS) {
break;
}
if (status != 0) {
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
file.close();
return false;
}
// Source position in image coordinates
int srcStartX = mcuX * imageInfo.m_MCUWidth;
int srcStartY = mcuY * imageInfo.m_MCUHeight;
switch (imageInfo.m_scanType) {
case PJPG_GRAYSCALE:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (col < 8) ? 0 : 1;
int pixelIndex = row * 8 + (col % 8);
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (row < 8) ? 0 : 1;
int pixelIndex = (row % 8) * 8 + col;
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockX = (col < 8) ? 0 : 1;
int blockY = (row < 8) ? 0 : 1;
int blockIndex = blockY * 2 + blockX;
int pixelIndex = (row % 8) * 8 + (col % 8);
int blockOffset = blockIndex * 64;
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
}
mcuX++;
if (mcuX >= imageInfo.m_MCUSPerRow) {
mcuX = 0;
mcuY++;
}
}
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
file.close();
// Write cache file if caching was enabled
if (caching) {
cache.writeToFile(config.cachePath);
}
return true;
}
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
if (context->bufferPos >= context->bufferFilled) {
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
if (readCount <= 0) {
*pBytes_actually_read = 0;
return 0;
}
context->bufferFilled = readCount;
context->bufferPos = 0;
}
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
context->bufferPos += bytesToCopy;
*pBytes_actually_read = bytesToCopy;
return 0;
}
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".jpg" || ext == ".jpeg");
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <stdint.h>
#include <string>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "JPEG"; }
private:
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
};

View File

@@ -0,0 +1,85 @@
#pragma once
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <stdint.h>
#include <cstring>
#include <string>
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
// Packs 4 pixels per byte, MSB first.
struct PixelCache {
uint8_t* buffer;
int width;
int height;
int bytesPerRow;
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
PixelCache(const PixelCache&) = delete;
PixelCache& operator=(const PixelCache&) = delete;
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
bool allocate(int w, int h, int ox, int oy) {
width = w;
height = h;
originX = ox;
originY = oy;
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
size_t bufferSize = (size_t)bytesPerRow * h;
if (bufferSize > MAX_CACHE_BYTES) {
Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h,
MAX_CACHE_BYTES);
return false;
}
buffer = (uint8_t*)malloc(bufferSize);
if (buffer) {
memset(buffer, 0, bufferSize);
Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
}
return buffer != nullptr;
}
void setPixel(int screenX, int screenY, uint8_t value) {
if (!buffer) return;
int localX = screenX - originX;
int localY = screenY - originY;
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
int byteIdx = localY * bytesPerRow + localX / 4;
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
bool writeToFile(const std::string& cachePath) {
if (!buffer) return false;
FsFile cacheFile;
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
return false;
}
uint16_t w = width;
uint16_t h = height;
cacheFile.write(&w, 2);
cacheFile.write(&h, 2);
cacheFile.write(buffer, bytesPerRow * height);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
4 + bytesPerRow * height);
return true;
}
~PixelCache() {
if (buffer) {
free(buffer);
buffer = nullptr;
}
}
};

View File

@@ -0,0 +1,364 @@
#include "PngToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <PNGdec.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <cstdlib>
#include <new>
#include "DitherUtils.h"
#include "PixelCache.h"
namespace {
// Context struct passed through PNGdec callbacks to avoid global mutable state.
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
// Scaling state
float scale;
int srcWidth;
int srcHeight;
int dstWidth;
int dstHeight;
int lastDstY; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching;
uint8_t* grayLineBuffer;
PngContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scale(1.0f),
srcWidth(0),
srcHeight(0),
dstWidth(0),
dstHeight(0),
lastDstY(-1),
caching(false),
grayLineBuffer(nullptr) {}
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
void* pngOpenWithHandle(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
delete f;
return nullptr;
}
*size = f->size();
return f;
}
void pngCloseWithHandle(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
if (f) {
f->close();
delete f;
}
}
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
return f->read(pBuf, len);
}
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return -1;
return f->seek(pos);
}
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
// We heap-allocate it on demand rather than using a static instance, so this memory
// is only consumed while actually decoding/querying PNG images. This is critical on
// the ESP32-C3 where total RAM is ~320 KB.
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
// Convert entire source line to grayscale with alpha blending to white background.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
memcpy(grayLine, pPixels, width);
break;
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = pPixels[x];
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t* p = &palette[pPixels[x] * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
}
} else {
memcpy(grayLine, pPixels, width);
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
default:
memset(grayLine, 128, width);
break;
}
}
int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
// Calculate destination Y with scaling
int dstY = (int)(srcY * ctx->scale);
// Skip if we already rendered this destination row (multiple source rows map to same dest)
if (dstY == ctx->lastDstY) return 1;
ctx->lastDstY = dstY;
// Check bounds
if (dstY >= ctx->dstHeight) return 1;
int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) return 1;
// Convert entire source line to grayscale (improves cache locality)
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
pDraw->iHasAlpha);
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
int outXBase = ctx->config->x;
int screenWidth = ctx->screenWidth;
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
int srcX = 0;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
}
}
return 1;
}
} // namespace
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
MIN_FREE_HEAP_FOR_PNG);
return false;
}
PNG* png = new (std::nothrow) PNG();
if (!png) {
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis());
return false;
}
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
nullptr);
if (rc != 0) {
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
delete png;
return false;
}
out.width = png->getWidth();
out.height = png->getHeight();
png->close();
delete png;
return true;
}
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
MIN_FREE_HEAP_FOR_PNG);
return false;
}
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
PNG* png = new (std::nothrow) PNG();
if (!png) {
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis());
return false;
}
PngContext ctx;
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
pngDrawCallback);
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
delete png;
return false;
}
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
png->close();
delete png;
return false;
}
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight,
ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp());
if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
}
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
if (!ctx.grayLineBuffer) {
Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis());
png->close();
delete png;
return false;
}
// Allocate cache buffer using SCALED dimensions
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
ctx.caching = false;
}
}
unsigned long decodeStart = millis();
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
free(ctx.grayLineBuffer);
ctx.grayLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
png->close();
delete png;
return false;
}
png->close();
delete png;
Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
// Write cache file if caching was enabled and buffer was allocated
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
}
return true;
}
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".png");
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include "ImageToFramebufferDecoder.h"
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "PNG"; }
};

View File

@@ -295,6 +295,9 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
1;
}
} else if (propNameBuf == "width") {
style.width = interpretLength(propValueBuf);
style.defined.width = 1;
}
}

View File

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

View File

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

View File

@@ -1,11 +1,17 @@
#include "ChapterHtmlSlimParser.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include "../../Epub.h"
#include "../Page.h"
#include "../converters/ImageDecoderFactory.h"
#include "../converters/ImageToFramebufferDecoder.h"
#include "../htmlEntities.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
@@ -32,8 +38,30 @@ constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
const char* SKIP_TAGS[] = {"head"};
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'; }
// 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
bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) {
for (int i = 0; i < possible_tag_count; i++) {
@@ -91,13 +119,37 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
// flush the buffer
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;
nextWordContinues = false;
}
// start a new text block if needed
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
if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it
@@ -140,46 +192,304 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
centeredBlockStyle.textAlignDefined = true;
centeredBlockStyle.alignment = CssTextAlign::Center;
// Special handling for tables - show placeholder text instead of dropping silently
// --- Table handling ---
if (strcmp(name, "table") == 0) {
// Add placeholder text
self->startNewTextBlock(centeredBlockStyle);
if (self->inTable) {
// 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->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;
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
std::string alt = "[Image]";
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "alt") == 0) {
if (strlen(atts[i + 1]) > 0) {
alt = "[Image: " + std::string(atts[i + 1]) + "]";
}
break;
}
}
// 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;
}
LOG_DBG("EHP", "Image alt: %s", alt.c_str());
// <col> — capture width hint for column sizing
if (strcmp(name, "col") == 0) {
CssLength widthHint;
bool hasHint = false;
self->startNewTextBlock(centeredBlockStyle);
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->characterData(userData, alt.c_str(), alt.length());
// 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;
}
}
}
// Skip table contents (skip until parent as we pre-advanced depth above)
self->skipUntilDepth = self->depth - 1;
return;
// 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)) {
std::string src;
std::string alt;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "src") == 0) {
src = atts[i + 1];
} else if (strcmp(atts[i], "alt") == 0) {
alt = atts[i + 1];
}
}
if (!src.empty()) {
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
{
// Resolve the image path relative to the HTML file
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
// Create a unique filename for the cached image
std::string ext;
size_t extPos = resolvedPath.rfind('.');
if (extPos != std::string::npos) {
ext = resolvedPath.substr(extPos);
}
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
// Extract image to cache file
FsFile cachedImageFile;
bool extractSuccess = false;
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
cachedImageFile.flush();
cachedImageFile.close();
delay(50); // Give SD card time to sync
}
if (extractSuccess) {
// Get image dimensions
ImageDimensions dims = {0, 0};
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
// Scale to fit viewport while maintaining aspect ratio
int maxWidth = self->viewportWidth;
int maxHeight = self->viewportHeight;
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
int displayWidth = (int)(dims.width * scale);
int displayHeight = (int)(dims.height * scale);
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
// Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->currentPage.reset(new Page());
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create new page");
return;
}
self->currentPageNextY = 0;
} else if (!self->currentPage) {
self->currentPage.reset(new Page());
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create initial page");
return;
}
self->currentPageNextY = 0;
}
// Create ImageBlock and add to page
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
if (!imageBlock) {
LOG_ERR("EHP", "Failed to create ImageBlock");
return;
}
int xPos = (self->viewportWidth - displayWidth) / 2;
auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY);
if (!pageImage) {
LOG_ERR("EHP", "Failed to create PageImage");
return;
}
self->currentPage->elements.push_back(pageImage);
self->currentPageNextY += displayHeight;
self->depth += 1;
return;
} else {
LOG_ERR("EHP", "Failed to get image dimensions");
Storage.remove(cachedImagePath.c_str());
}
} else {
LOG_ERR("EHP", "Failed to extract image");
}
}
}
// Fallback to alt text if image processing fails
if (!alt.empty()) {
alt = "[Image: " + alt + "]";
self->startNewTextBlock(centeredBlockStyle);
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
// Skip any child content (skip until parent as we pre-advanced depth above)
self->skipUntilDepth = self->depth - 1;
return;
}
// No alt text, skip
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
@@ -408,7 +718,8 @@ 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
// memory.
// 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.
if (!self->inTable && self->currentTextBlock->size() > 750) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth,
@@ -446,15 +757,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
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
if (self->partWordBufferIndex > 0) {
// 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;
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_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;
if (shouldFlush) {
@@ -466,6 +779,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;
// Leaving skip
@@ -653,3 +1017,335 @@ void ChapterHtmlSlimParser::makePages() {
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,16 +7,21 @@
#include <memory>
#include "../ParsedText.h"
#include "../TableData.h"
#include "../blocks/ImageBlock.h"
#include "../blocks/TextBlock.h"
#include "../css/CssParser.h"
#include "../css/CssStyle.h"
class Page;
class PageTableRow;
class GfxRenderer;
class Epub;
#define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser {
std::shared_ptr<Epub> epub;
const std::string& filepath;
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn;
@@ -43,6 +48,9 @@ class ChapterHtmlSlimParser {
bool hyphenationEnabled;
const CssParser* cssParser;
bool embeddedStyle;
std::string contentBase;
std::string imageBasePath;
int imageCounter = 0;
// Style tracking (replaces depth-based approach)
struct StyleStackEntry {
@@ -57,10 +65,16 @@ class ChapterHtmlSlimParser {
bool effectiveItalic = false;
bool effectiveUnderline = false;
// Table buffering state
bool inTable = false;
std::unique_ptr<TableData> tableData;
void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
void makePages();
void processTable();
void addTableRowToPage(std::shared_ptr<PageTableRow> row);
// XML callbacks
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);
@@ -68,15 +82,17 @@ class ChapterHtmlSlimParser {
static void XMLCALL endElement(void* userData, const XML_Char* name);
public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const bool extraParagraphSpacing,
explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, const std::string& filepath, GfxRenderer& renderer,
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const bool embeddedStyle, const std::function<void()>& popupFn = nullptr,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr,
const CssParser* cssParser = nullptr)
: filepath(filepath),
: epub(epub),
filepath(filepath),
renderer(renderer),
fontId(fontId),
lineCompression(lineCompression),
@@ -88,7 +104,9 @@ class ChapterHtmlSlimParser {
completePageFn(completePageFn),
popupFn(popupFn),
cssParser(cssParser),
embeddedStyle(embeddedStyle) {}
embeddedStyle(embeddedStyle),
contentBase(contentBase),
imageBasePath(imageBasePath) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();

View File

@@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) {
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}
// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config.
// Produces smooth-looking gradients on the 4-level e-ink display.
uint8_t quantizeNoiseDither(int gray, int x, int y) {
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
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;
}
}

View File

@@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
uint8_t quantizeNoiseDither(int gray, int x, int y);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):

View File

@@ -73,6 +73,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
}
}
void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const {
if (renderMode == BW && val2bit < 3) {
drawPixel(x, y);
} else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) {
drawPixel(x, y, false);
} else if (renderMode == GRAYSCALE_LSB && val2bit == 1) {
drawPixel(x, y, false);
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) {
LOG_ERR("GFX", "Font %d not found", fontId);
@@ -423,12 +433,20 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
if (maxWidth > 0 && maxHeight > 0) {
const float scaleX = static_cast<float>(maxWidth) / effectiveWidth;
const float scaleY = static_cast<float>(maxHeight) / effectiveHeight;
scale = std::min(scaleX, scaleY);
isScaled = (scale < 0.999f || scale > 1.001f);
} else if (maxWidth > 0 && effectiveWidth > static_cast<float>(maxWidth)) {
scale = static_cast<float>(maxWidth) / effectiveWidth;
isScaled = true;
}
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
} else if (maxHeight > 0 && effectiveHeight > static_cast<float>(maxHeight)) {
scale = static_cast<float>(maxHeight) / effectiveHeight;
isScaled = true;
}
LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
@@ -449,12 +467,17 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner.
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
const int logicalY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
int screenYStart, screenYEnd;
if (isScaled) {
screenY = std::floor(screenY * scale);
screenYStart = static_cast<int>(std::floor(logicalY * scale)) + y;
screenYEnd = static_cast<int>(std::floor((logicalY + 1) * scale)) + y;
} else {
screenYStart = logicalY + y;
screenYEnd = screenYStart + 1;
}
screenY += y; // the offset should not be scaled
if (screenY >= getScreenHeight()) {
if (screenYStart >= getScreenHeight()) {
break;
}
@@ -465,7 +488,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
return;
}
if (screenY < 0) {
if (screenYEnd <= 0) {
continue;
}
@@ -474,27 +497,42 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
continue;
}
const int syStart = std::max(screenYStart, 0);
const int syEnd = std::min(screenYEnd, getScreenHeight());
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
int screenX = bmpX - cropPixX;
const int outX = bmpX - cropPixX;
int screenXStart, screenXEnd;
if (isScaled) {
screenX = std::floor(screenX * scale);
screenXStart = static_cast<int>(std::floor(outX * scale)) + x;
screenXEnd = static_cast<int>(std::floor((outX + 1) * scale)) + x;
} else {
screenXStart = outX + x;
screenXEnd = screenXStart + 1;
}
screenX += x; // the offset should not be scaled
if (screenX >= getScreenWidth()) {
if (screenXStart >= getScreenWidth()) {
break;
}
if (screenX < 0) {
if (screenXEnd <= 0) {
continue;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
if (renderMode == BW && val < 3) {
drawPixel(screenX, screenY);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false);
const int sxStart = std::max(screenXStart, 0);
const int sxEnd = std::min(screenXEnd, getScreenWidth());
for (int sy = syStart; sy < syEnd; sy++) {
for (int sx = sxStart; sx < sxEnd; sx++) {
if (renderMode == BW && val < 3) {
drawPixel(sx, sy);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(sx, sy, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(sx, sy, false);
}
}
}
}
}
@@ -507,11 +545,16 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
if (maxWidth > 0 && maxHeight > 0) {
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight());
scale = std::min(scaleX, scaleY);
isScaled = (scale < 0.999f || scale > 1.001f);
} else if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
} else if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
isScaled = true;
}
@@ -539,20 +582,37 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen Y based on whether BMP is top-down or bottom-up
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
int screenYStart, screenYEnd;
if (isScaled) {
screenYStart = static_cast<int>(std::floor(bmpYOffset * scale)) + y;
screenYEnd = static_cast<int>(std::floor((bmpYOffset + 1) * scale)) + y;
} else {
screenYStart = bmpYOffset + y;
screenYEnd = screenYStart + 1;
}
if (screenYStart >= getScreenHeight()) {
continue; // Continue reading to keep row counter in sync
}
if (screenY < 0) {
if (screenYEnd <= 0) {
continue;
}
const int syStart = std::max(screenYStart, 0);
const int syEnd = std::min(screenYEnd, getScreenHeight());
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
int screenXStart, screenXEnd;
if (isScaled) {
screenXStart = static_cast<int>(std::floor(bmpX * scale)) + x;
screenXEnd = static_cast<int>(std::floor((bmpX + 1) * scale)) + x;
} else {
screenXStart = bmpX + x;
screenXEnd = screenXStart + 1;
}
if (screenXStart >= getScreenWidth()) {
break;
}
if (screenX < 0) {
if (screenXEnd <= 0) {
continue;
}
@@ -562,7 +622,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
const int sxStart = std::max(screenXStart, 0);
const int sxEnd = std::min(screenXEnd, getScreenWidth());
for (int sy = syStart; sy < syEnd; sy++) {
for (int sx = sxStart; sx < sxEnd; sx++) {
drawPixel(sx, sy, true);
}
}
}
// White pixels (val == 3) are not drawn (leave background)
}
@@ -660,6 +726,23 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
display.displayBuffer(refreshMode, fadingFix);
}
// EXPERIMENTAL: Display only a rectangular region with specified refresh mode
void GfxRenderer::displayWindow(int x, int y, int width, int height,
HalDisplay::RefreshMode mode) const {
LOG_DBG("GFX", "Displaying window at (%d,%d) size (%dx%d) with mode %d", x, y, width, height,
static_cast<int>(mode));
// Validate bounds
if (x < 0 || y < 0 || x + width > getScreenWidth() || y + height > getScreenHeight()) {
LOG_ERR("GFX", "Window bounds exceed display dimensions!");
return;
}
display.displayWindow(static_cast<uint16_t>(x), static_cast<uint16_t>(y),
static_cast<uint16_t>(width), static_cast<uint16_t>(height), mode,
fadingFix);
}
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
@@ -840,6 +923,92 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontMap.count(fontId) == 0) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
}
const auto font = fontMap.at(fontId);
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
// For 90° counter-clockwise rotation:
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
// Text reads from top to bottom
const int advanceY = font.getData(style)->advanceY;
const int ascender = font.getData(style)->ascender;
int yPos = y; // Current Y position (increases as we draw characters)
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!glyph) {
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° counter-clockwise rotation transformation:
// screenX = mirrored CW X (right-to-left within advanceY span)
// screenY = yPos + (left + glyphX) (downward)
const int screenX = x + advanceY - 1 - (ascender - top + glyphY);
const int screenY = yPos + left + glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going down, so increase Y)
yPos += glyph->advanceX;
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }

View File

@@ -70,13 +70,15 @@ class GfxRenderer {
int getScreenHeight() const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region
// void displayWindow(int x, int y, int width, int height) const;
void displayWindow(int x, int y, int width, int height,
HalDisplay::RefreshMode mode = HalDisplay::FAST_REFRESH) const;
void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
// Drawing
void drawPixel(int x, int y, bool state = true) const;
void drawPixelGray(int x, int y, uint8_t val2bit) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
@@ -110,13 +112,16 @@ class GfxRenderer {
std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
// Helpers for drawing rotated text (for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const;
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
RenderMode getRenderMode() const { return renderMode; }
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;

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

@@ -97,6 +97,9 @@ std::string Txt::findCoverImage() const {
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 {
// Already generated, return true
if (Storage.exists(getCoverBmpPath().c_str())) {

View File

@@ -28,6 +28,10 @@ class Txt {
[[nodiscard]] bool generateCoverBmp() 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
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
};

View File

@@ -32,6 +32,13 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
}
// EXPERIMENTAL: Display only a rectangular region
void HalDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
HalDisplay::RefreshMode mode, bool turnOffScreen) {
(void)mode; // EInkDisplay::displayWindow does not take mode yet
einkDisplay.displayWindow(x, y, w, h, turnOffScreen);
}
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
}

View File

@@ -34,6 +34,10 @@ class HalDisplay {
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// EXPERIMENTAL: Display only a rectangular region
void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Power management
void deepSleep();

View File

@@ -1,11 +1,9 @@
#include <HalGPIO.h>
#include <SPI.h>
#include <esp_sleep.h>
void HalGPIO::begin() {
inputMgr.begin();
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
@@ -23,23 +21,6 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
int HalGPIO::getBatteryPercentage() const {
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage();
}
bool HalGPIO::isUsbConnected() const {
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;

View File

@@ -38,12 +38,6 @@ class HalGPIO {
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
// Setup wake up GPIO and enter deep sleep
void startDeepSleep();
// Get battery percentage (range 0-100)
int getBatteryPercentage() const;
// Check if USB is connected
bool isUsbConnected() const;

View File

@@ -0,0 +1,49 @@
#include "HalPowerManager.h"
#include <Logging.h>
#include <esp_sleep.h>
#include "HalGPIO.h"
void HalPowerManager::begin() {
pinMode(BAT_GPIO0, INPUT);
normalFreq = getCpuFrequencyMhz();
}
void HalPowerManager::setPowerSaving(bool enabled) {
if (normalFreq <= 0) {
return; // invalid state
}
if (enabled && !isLowPower) {
LOG_DBG("PWR", "Going to low-power mode");
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", LOW_POWER_FREQ);
return;
}
}
if (!enabled && isLowPower) {
LOG_DBG("PWR", "Restoring normal CPU frequency");
if (!setCpuFrequencyMhz(normalFreq)) {
LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", normalFreq);
return;
}
}
isLowPower = enabled;
}
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50);
gpio.update();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
int HalPowerManager::getBatteryPercentage() const {
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage();
}

27
lib/hal/HalPowerManager.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
#include "HalGPIO.h"
class HalPowerManager {
int normalFreq = 0; // MHz
bool isLowPower = false;
public:
static constexpr int LOW_POWER_FREQ = 10; // MHz
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
void begin();
// Control CPU frequency for power saving
void setPowerSaving(bool enabled);
// Setup wake up GPIO and enter deep sleep
void startDeepSleep(HalGPIO& gpio) const;
// Get battery percentage (range 0-100)
int getBatteryPercentage() const;
};

View File

@@ -30,6 +30,9 @@ build_flags =
-std=gnu++2a
# Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1
# Increase PNG scanline buffer to support up to 800px wide images
# Default is (320*4+1)*2=2562, we need more for larger images
-DPNG_MAX_BUFFERED_PIXELS=6402
build_unflags =
-std=gnu++11
@@ -50,6 +53,7 @@ lib_deps =
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1
bitbank2/PNGdec @ ^1.0.0
links2004/WebSockets @ 2.7.3
[env:default]
@@ -61,6 +65,22 @@ build_flags =
-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]
extends = base
build_flags =

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,501 @@
#!/usr/bin/env python3
"""
Generate test EPUBs for image rendering verification.
Creates EPUBs with annotated JPEG and PNG images to verify:
- Grayscale rendering (4 levels)
- Image scaling
- Image centering
- Cache performance
- Page serialization
"""
import os
import zipfile
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Please install Pillow: pip install Pillow")
exit(1)
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
SCREEN_WIDTH = 480
SCREEN_HEIGHT = 800
def get_font(size=20):
"""Get a font, falling back to default if needed."""
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
except:
try:
return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
except:
return ImageFont.load_default()
def draw_text_centered(draw, y, text, font, fill=0):
"""Draw centered text at given y position."""
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
x = (draw.im.size[0] - text_width) // 2
draw.text((x, y), text, font=font, fill=fill)
def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
"""Draw text with word wrapping."""
words = text.split()
lines = []
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
if bbox[2] - bbox[0] <= max_width:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
line_height = font.size + 4 if hasattr(font, 'size') else 20
for i, line in enumerate(lines):
draw.text((x, y + i * line_height), line, font=font, fill=fill)
return len(lines) * line_height
def create_grayscale_test_image(filename, is_png=True):
"""
Create image with 4 grayscale squares to verify 4-level rendering.
"""
width, height = 400, 600
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(16)
font_small = get_font(14)
# Title
draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0)
draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64)
# Draw 4 grayscale squares
square_size = 70
start_y = 65
gap = 10
levels = [
(0, "Level 0: BLACK"),
(96, "Level 1: DARK GRAY"),
(160, "Level 2: LIGHT GRAY"),
(255, "Level 3: WHITE"),
]
for i, (gray_value, label) in enumerate(levels):
y = start_y + i * (square_size + gap + 22)
x = (width - square_size) // 2
# Draw square with border
draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
# Label below square
bbox = draw.textbbox((0, 0), label, font=font_small)
label_width = bbox[2] - bbox[0]
draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
# Instructions at bottom
y = height - 70
draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64)
draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_centering_test_image(filename, is_png=True):
"""
Create image with border markers to verify centering.
"""
width, height = 350, 400
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(16)
font_small = get_font(14)
# Draw border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
# Corner markers
marker_size = 20
for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
# Center cross
cx, cy = width // 2, height // 2
draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2)
draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2)
# Title
draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0)
# Instructions
y = 80
draw_text_centered(draw, y, "Image should be centered", font_small, fill=0)
draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0)
y = 150
draw_text_centered(draw, y, "Check:", font_small, fill=0)
draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
# Pass/fail
y = height - 80
draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_scaling_test_image(filename, is_png=True):
"""
Create large image to verify scaling works.
"""
width, height = 1200, 1500
img = Image.new('L', (width, height), 240)
draw = ImageDraw.Draw(img)
font = get_font(48)
font_medium = get_font(32)
font_small = get_font(24)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
# Title
draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
# Grid pattern
grid_start_y = 220
grid_size = 400
cell_size = 50
draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
grid_x = (width - grid_size) // 2
for row in range(grid_size // cell_size):
for col in range(grid_size // cell_size):
x = grid_x + col * cell_size
y = grid_start_y + row * cell_size
if (row + col) % 2 == 0:
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
else:
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
# Pass/fail
y = height - 100
draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_cache_test_image(filename, page_num, is_png=True):
"""
Create image for cache performance testing.
"""
width, height = 400, 300
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(18)
font_small = get_font(14)
font_large = get_font(36)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
# Page number prominent
draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0)
# Instructions
y = 140
draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
y = 220
draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_format_test_image(filename, format_name, is_png=True):
"""
Create simple image to verify format support.
"""
width, height = 350, 250
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(20)
font_large = get_font(36)
font_small = get_font(14)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
# Format name
draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
draw_text_centered(draw, 80, format_name, font_large, fill=0)
# Checkmark area
y = 140
draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
y = height - 40
draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_epub(epub_path, title, chapters):
"""
Create an EPUB file with the given chapters.
chapters: list of (chapter_title, html_content, images)
images: list of (image_filename, image_data)
"""
with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
# mimetype (must be first, uncompressed)
epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
# Container
container_xml = '''<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>'''
epub.writestr('META-INF/container.xml', container_xml)
# Collect all images and chapters
manifest_items = []
spine_items = []
# Add chapters and images
for i, (chapter_title, html_content, images) in enumerate(chapters):
chapter_id = f'chapter{i+1}'
chapter_file = f'chapter{i+1}.xhtml'
# Add images for this chapter
for img_filename, img_data in images:
media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg'
manifest_items.append(f' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>')
epub.writestr(f'OEBPS/images/{img_filename}', img_data)
# Add chapter
manifest_items.append(f' <item id="{chapter_id}" href="{chapter_file}" media-type="application/xhtml+xml"/>')
spine_items.append(f' <itemref idref="{chapter_id}"/>')
epub.writestr(f'OEBPS/{chapter_file}', html_content)
# content.opf
content_opf = f'''<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">test-epub-{title.lower().replace(" ", "-")}</dc:identifier>
<dc:title>{title}</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
{chr(10).join(manifest_items)}
</manifest>
<spine>
{chr(10).join(spine_items)}
</spine>
</package>'''
epub.writestr('OEBPS/content.opf', content_opf)
# Navigation document
nav_items = '\n'.join([f' <li><a href="chapter{i+1}.xhtml">{chapters[i][0]}</a></li>'
for i in range(len(chapters))])
nav_xhtml = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc">
<h1>Contents</h1>
<ol>
{nav_items}
</ol>
</nav>
</body>
</html>'''
epub.writestr('OEBPS/nav.xhtml', nav_xhtml)
def make_chapter(title, body_content):
"""Create XHTML chapter content."""
return f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>{title}</title></head>
<body>
<h1>{title}</h1>
{body_content}
</body>
</html>'''
def main():
OUTPUT_DIR.mkdir(exist_ok=True)
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
print("Generating test images...")
images = {}
# JPEG tests
create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False)
create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False)
create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False)
create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False)
create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False)
create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False)
# PNG tests
create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True)
create_centering_test_image(tmpdir / 'centering_test.png', is_png=True)
create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True)
create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True)
create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True)
create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True)
# Read all images
for img_file in tmpdir.glob('*.*'):
images[img_file.name] = img_file.read_bytes()
print("Creating JPEG test EPUB...")
jpeg_chapters = [
("Introduction", make_chapter("JPEG Image Tests", """
<p>This EPUB tests JPEG image rendering.</p>
<p>Navigate through chapters to verify each test case.</p>
"""), []),
("1. JPEG Format", make_chapter("JPEG Format Test", """
<p>Basic JPEG decoding test.</p>
<img src="images/jpeg_format.jpg" alt="JPEG format test"/>
<p>If the image above is visible, JPEG decoding works.</p>
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
("2. Grayscale", make_chapter("Grayscale Test", """
<p>Verify 4 distinct gray levels are visible.</p>
<img src="images/grayscale_test.jpg" alt="Grayscale test"/>
"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]),
("3. Centering", make_chapter("Centering Test", """
<p>Verify image is centered horizontally.</p>
<img src="images/centering_test.jpg" alt="Centering test"/>
"""), [('centering_test.jpg', images['centering_test.jpg'])]),
("4. Scaling", make_chapter("Scaling Test", """
<p>This image is 1200x1500 pixels - larger than the screen.</p>
<p>It should be scaled down to fit.</p>
<img src="images/scaling_test.jpg" alt="Scaling test"/>
"""), [('scaling_test.jpg', images['scaling_test.jpg'])]),
("5. Cache Test A", make_chapter("Cache Test - Page A", """
<p>First cache test page. Note the load time.</p>
<img src="images/cache_test_1.jpg" alt="Cache test 1"/>
<p>Navigate to next page, then come back.</p>
"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]),
("6. Cache Test B", make_chapter("Cache Test - Page B", """
<p>Second cache test page.</p>
<img src="images/cache_test_2.jpg" alt="Cache test 2"/>
<p>Navigate back to Page A - it should load faster from cache.</p>
"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]),
]
create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters)
print("Creating PNG test EPUB...")
png_chapters = [
("Introduction", make_chapter("PNG Image Tests", """
<p>This EPUB tests PNG image rendering.</p>
<p>Navigate through chapters to verify each test case.</p>
"""), []),
("1. PNG Format", make_chapter("PNG Format Test", """
<p>Basic PNG decoding test.</p>
<img src="images/png_format.png" alt="PNG format test"/>
<p>If the image above is visible and no crash occurred, PNG decoding works.</p>
"""), [('png_format.png', images['png_format.png'])]),
("2. Grayscale", make_chapter("Grayscale Test", """
<p>Verify 4 distinct gray levels are visible.</p>
<img src="images/grayscale_test.png" alt="Grayscale test"/>
"""), [('grayscale_test.png', images['grayscale_test.png'])]),
("3. Centering", make_chapter("Centering Test", """
<p>Verify image is centered horizontally.</p>
<img src="images/centering_test.png" alt="Centering test"/>
"""), [('centering_test.png', images['centering_test.png'])]),
("4. Scaling", make_chapter("Scaling Test", """
<p>This image is 1200x1500 pixels - larger than the screen.</p>
<p>It should be scaled down to fit.</p>
<img src="images/scaling_test.png" alt="Scaling test"/>
"""), [('scaling_test.png', images['scaling_test.png'])]),
("5. Cache Test A", make_chapter("Cache Test - Page A", """
<p>First cache test page. Note the load time.</p>
<img src="images/cache_test_1.png" alt="Cache test 1"/>
<p>Navigate to next page, then come back.</p>
"""), [('cache_test_1.png', images['cache_test_1.png'])]),
("6. Cache Test B", make_chapter("Cache Test - Page B", """
<p>Second cache test page.</p>
<img src="images/cache_test_2.png" alt="Cache test 2"/>
<p>Navigate back to Page A - it should load faster from cache.</p>
"""), [('cache_test_2.png', images['cache_test_2.png'])]),
]
create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters)
print("Creating mixed format test EPUB...")
mixed_chapters = [
("Introduction", make_chapter("Mixed Image Format Tests", """
<p>This EPUB contains both JPEG and PNG images.</p>
<p>Tests format detection and mixed rendering.</p>
"""), []),
("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """
<p>This is a JPEG image:</p>
<img src="images/jpeg_format.jpg" alt="JPEG"/>
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
("2. PNG Image", make_chapter("PNG in Mixed EPUB", """
<p>This is a PNG image:</p>
<img src="images/png_format.png" alt="PNG"/>
"""), [('png_format.png', images['png_format.png'])]),
("3. Both Formats", make_chapter("Both Formats on One Page", """
<p>JPEG image:</p>
<img src="images/grayscale_test.jpg" alt="JPEG grayscale"/>
<p>PNG image:</p>
<img src="images/grayscale_test.png" alt="PNG grayscale"/>
<p>Both should render with proper grayscale.</p>
"""), [('grayscale_test.jpg', images['grayscale_test.jpg']),
('grayscale_test.png', images['grayscale_test.png'])]),
]
create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters)
print(f"\nTest EPUBs created in: {OUTPUT_DIR}")
print("Files:")
for f in OUTPUT_DIR.glob('*.epub'):
print(f" - {f.name}")
if __name__ == '__main__':
main()

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

@@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 30;
constexpr uint8_t SETTINGS_COUNT = 31;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
// Validate front button mapping to ensure each hardware button is unique.
@@ -118,6 +118,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, frontButtonRight);
serialization::writePod(outputFile, fadingFix);
serialization::writePod(outputFile, embeddedStyle);
serialization::writePod(outputFile, sleepScreenLetterboxFill);
// New fields added at end for backward compatibility
outputFile.close();
@@ -223,6 +224,10 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, embeddedStyle);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
if (++settingsRead >= fileSettingsCount) break;
{ uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility
} while (false);
@@ -239,8 +244,8 @@ bool CrossPointSettings::loadFromFile() {
float CrossPointSettings::getReaderLineCompression() const {
switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY:
default:
switch (lineSpacing) {
case TIGHT:
return 0.95f;
@@ -250,6 +255,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
return 1.1f;
}
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS:
switch (lineSpacing) {
case TIGHT:
@@ -260,6 +267,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
return 1.0f;
}
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC:
switch (lineSpacing) {
case TIGHT:
@@ -270,6 +279,30 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
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
}
}
@@ -307,8 +340,8 @@ int CrossPointSettings::getRefreshFrequency() const {
int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY:
default:
switch (fontSize) {
case SMALL:
return BOOKERLY_12_FONT_ID;
@@ -320,6 +353,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
return BOOKERLY_18_FONT_ID;
}
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS:
switch (fontSize) {
case SMALL:
@@ -332,6 +367,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
return NOTOSANS_18_FONT_ID;
}
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC:
switch (fontSize) {
case SMALL:
@@ -344,5 +381,17 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
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

@@ -31,6 +31,12 @@ class CrossPointSettings {
INVERTED_BLACK_AND_WHITE = 2,
SLEEP_SCREEN_COVER_FILTER_COUNT
};
enum SLEEP_SCREEN_LETTERBOX_FILL {
LETTERBOX_DITHERED = 0,
LETTERBOX_SOLID = 1,
LETTERBOX_NONE = 2,
SLEEP_SCREEN_LETTERBOX_FILL_COUNT
};
// Status bar display type enum
enum STATUS_BAR_MODE {
@@ -125,6 +131,8 @@ class CrossPointSettings {
uint8_t sleepScreenCoverMode = FIT;
// Sleep screen cover filter
uint8_t sleepScreenCoverFilter = NO_FILTER;
// Sleep screen letterbox fill mode (Dithered / Solid / None)
uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings

View File

@@ -38,6 +38,15 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
saveToFile();
}
void RecentBooksStore::removeBook(const std::string& path) {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
recentBooks.erase(it);
saveToFile();
}
}
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
auto it =

View File

@@ -30,6 +30,9 @@ class RecentBooksStore {
void updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath);
// Remove a book from the recent list by path
void removeBook(const std::string& path);
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@@ -6,10 +6,36 @@
#include "KOReaderCredentialStore.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.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
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 {
// --- Display ---
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
@@ -18,6 +44,8 @@ inline std::vector<SettingInfo> getSettingsList() {
"sleepScreenCoverMode", "Display"),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
{"Dithered", "Solid", "None"}, "sleepScreenLetterboxFill", "Display"),
SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
@@ -30,8 +58,20 @@ inline std::vector<SettingInfo> getSettingsList() {
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
// --- Reader ---
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"},
"fontFamily", "Reader"),
SettingInfo::DynamicEnum(
"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",
"Reader"),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",

View File

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

View File

@@ -3,16 +3,347 @@
#include <Epub.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include <Serialization.h>
#include <Txt.h>
#include <Xtc.h>
#include <algorithm>
#include <cmath>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "util/BookSettings.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "images/Logo120.h"
#include "util/StringUtils.h"
namespace {
// Number of source pixels along the image edge to average for the dominant color
constexpr int EDGE_SAMPLE_DEPTH = 20;
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
// Letterbox fill data: one average gray value per edge (top/bottom or left/right).
struct LetterboxFillData {
uint8_t avgA = 128; // average gray of edge A (top or left)
uint8_t avgB = 128; // average gray of edge B (bottom or right)
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)
bool horizontal = false; // true = top/bottom letterbox, false = left/right
bool valid = false;
};
// Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
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;
}
// 4x4 Bayer ordered dithering matrix, values 0-255.
// Produces a structured halftone pattern for 4-level quantization.
// 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;
if (!Storage.openFileForRead("SLP", path, file)) return false;
uint8_t version;
serialization::readPod(file, version);
if (version != EDGE_CACHE_VERSION) {
file.close();
return false;
}
uint16_t cachedW, cachedH;
serialization::readPod(file, cachedW);
serialization::readPod(file, cachedH);
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
file.close();
return false;
}
uint8_t horizontal;
serialization::readPod(file, horizontal);
data.horizontal = (horizontal != 0);
serialization::readPod(file, data.avgA);
serialization::readPod(file, data.avgB);
int16_t lbA, lbB;
serialization::readPod(file, lbA);
serialization::readPod(file, lbB);
data.letterboxA = lbA;
data.letterboxB = lbB;
file.close();
data.valid = true;
LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB);
return true;
}
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
if (!data.valid) return false;
FsFile file;
if (!Storage.openFileForWrite("SLP", path, file)) return false;
serialization::writePod(file, EDGE_CACHE_VERSION);
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
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.letterboxB));
file.close();
LOG_DBG("SLP", "Saved edge cache to %s", path.c_str());
return true;
}
// Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
// Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
// After sampling the bitmap is rewound via rewindToData().
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
float scale, float cropX, float cropY) {
LetterboxFillData data;
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 visibleWidth = bitmap.getWidth() - 2 * cropPixX;
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
free(outputRow);
free(rowBytes);
return data;
}
if (imgY > 0) {
// Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
data.horizontal = true;
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
data.letterboxA = imgY;
data.letterboxB = pageHeight - imgY - scaledHeight;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
uint64_t sumTop = 0, sumBot = 0;
int countTop = 0, countBot = 0;
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
const bool inTop = (outY < sampleRows);
const bool inBot = (outY >= visibleHeight - sampleRows);
if (!inTop && !inBot) continue;
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
const uint8_t gray = val2bitToGray(val);
if (inTop) {
sumTop += gray;
countTop++;
}
if (inBot) {
sumBot += gray;
countBot++;
}
}
}
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
data.valid = true;
} else if (imgX > 0) {
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
data.horizontal = false;
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
data.letterboxA = imgX;
data.letterboxB = pageWidth - imgX - scaledWidth;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
uint64_t sumLeft = 0, sumRight = 0;
int countLeft = 0, countRight = 0;
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
sumLeft += val2bitToGray(val);
countLeft++;
}
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
sumRight += val2bitToGray(val);
countRight++;
}
}
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
data.valid = true;
}
bitmap.rewindToData();
free(outputRow);
free(rowBytes);
return data;
}
// Draw letterbox fill in the areas around the cover image.
// DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
// SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
if (!data.valid) return;
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
//
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
// 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
// Bayer grid at the same block size.
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
// For solid mode: snap to nearest e-ink level
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
if (data.horizontal) {
if (data.letterboxA > 0) {
for (int y = 0; y < data.letterboxA; y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv;
if (isSolid) lv = levelA;
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
else lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
if (data.letterboxB > 0) {
const int start = renderer.getScreenHeight() - data.letterboxB;
for (int y = start; y < renderer.getScreenHeight(); y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv;
if (isSolid) lv = levelB;
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
else lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
} else {
if (data.letterboxA > 0) {
for (int x = 0; x < data.letterboxA; x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv;
if (isSolid) lv = levelA;
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
else lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
if (data.letterboxB > 0) {
const int start = renderer.getScreenWidth() - data.letterboxB;
for (int x = start; x < renderer.getScreenWidth(); x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv;
if (isSolid) lv = levelB;
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
else lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
}
}
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
GUI.drawPopup(renderer, "Entering Sleep...");
@@ -121,52 +452,92 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
uint8_t fillModeOverride) const {
int x, y;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
float cropX = 0, cropY = 0;
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
if (ratio > screenRatio) {
// image wider than viewport ratio, scaled down image needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio);
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
}
x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
} else {
// image taller than viewport ratio, scaled down image needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio);
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
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);
y = 0;
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
// 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());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
if (ratio > screenRatio) {
// image wider than viewport ratio, needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio);
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
}
x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
} else {
// center the image
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
// image taller than or equal to viewport ratio, needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio);
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
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);
y = 0;
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
}
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
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
const float scale =
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
// Determine letterbox fill settings (per-book override takes precedence)
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);
static const char* fillModeNames[] = {"dithered", "solid", "none"};
const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
// Compute edge averages if letterbox fill is requested (try cache first)
LetterboxFillData fillData;
const bool hasLetterbox = (x > 0 || y > 0);
if (hasLetterbox && wantFill) {
bool cacheLoaded = false;
if (!edgeCachePath.empty()) {
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
}
if (!cacheLoaded) {
LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName);
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
if (fillData.valid && !edgeCachePath.empty()) {
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
}
}
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();
const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass)
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
@@ -179,12 +550,18 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();
@@ -209,6 +586,7 @@ void SleepActivity::renderCoverSleepScreen() const {
}
std::string coverBmpPath;
std::string bookCachePath;
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Check if the current book is XTC, TXT, or EPUB
@@ -222,11 +600,17 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastXtc.generateCoverBmp()) {
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)();
}
coverBmpPath = lastXtc.getCoverBmpPath();
bookCachePath = lastXtc.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
// Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
@@ -236,11 +620,17 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastTxt.generateCoverBmp()) {
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)();
}
coverBmpPath = lastTxt.getCoverBmpPath();
bookCachePath = lastTxt.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
// Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
@@ -251,21 +641,44 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastEpub.generateCoverBmp(cropped)) {
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
if (!PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
lastEpub.getAuthor(), 480, 800)) {
LOG_DBG("SLP", "Placeholder generation failed, creating X-pattern marker");
lastEpub.generateInvalidFormatCoverBmp(cropped);
}
}
if (!Epub::isValidThumbnailBmp(lastEpub.getCoverBmpPath(cropped))) {
LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)();
}
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
bookCachePath = lastEpub.getCachePath();
} else {
return (this->*renderNoCoverSleepScreen)();
}
// Load per-book letterbox fill override (falls back to global if not set)
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
if (!bookCachePath.empty()) {
auto bookSettings = BookSettings::load(bookCachePath);
fillModeOverride = bookSettings.letterboxFillOverride;
}
FsFile file;
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap);
// 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;
}
}

View File

@@ -1,4 +1,7 @@
#pragma once
#include <string>
#include "../Activity.h"
class Bitmap;
@@ -13,6 +16,8 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap) 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;
};

View File

@@ -5,6 +5,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include <Xtc.h>
#include <cstring>
@@ -64,46 +65,61 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
// If epub, try to load the metadata for title/author and cover
if (!Epub::isValidThumbnailBmp(coverPath)) {
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 (Real Cover)
if (StringUtils::checkFileExtension(book.path, ".epub")) {
Epub epub(book.path, "/.crosspoint");
// Skip loading css since we only need metadata here
epub.load(false, true);
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
// Try fast cache-only load first; only build cache if missing
if (!epub.load(false, true)) {
// Cache missing — build it (may take longer)
epub.load(true, true);
}
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 = "";
success = epub.generateThumbBmp(coverHeight);
if (success) {
const std::string thumbPath = epub.getThumbBmpPath(coverHeight);
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
book.coverBmpPath = thumbPath;
} else {
// Fallback: generate a placeholder thumbnail with title/author
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
if (!success) {
// Last resort: X-pattern marker to prevent repeated generation attempts
epub.generateInvalidFormatThumbBmp(coverHeight);
}
}
coverRendered = false;
updateRequired = true;
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
StringUtils::checkFileExtension(book.path, ".xtc")) {
// Handle XTC file
Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
success = xtc.generateThumbBmp(coverHeight);
if (success) {
const std::string thumbPath = xtc.getThumbBmpPath(coverHeight);
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
book.coverBmpPath = thumbPath;
}
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;
}
if (!success) {
// Fallback: generate a placeholder thumbnail with title/author
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
} else {
// Unknown format: generate a placeholder thumbnail
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
coverRendered = false;
updateRequired = true;
}
}
progress++;

View File

@@ -0,0 +1,545 @@
#include "DictionaryDefinitionActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void DictionaryDefinitionActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionaryDefinitionActivity*>(param);
self->displayTaskLoop();
}
void DictionaryDefinitionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionaryDefinitionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
wrapText();
updateRequired = true;
xTaskCreate(&DictionaryDefinitionActivity::taskTrampoline, "DictDefTask", 4096, this, 1, &displayTaskHandle);
}
void DictionaryDefinitionActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
// ---------------------------------------------------------------------------
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
// ---------------------------------------------------------------------------
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
return false;
}
// ---------------------------------------------------------------------------
// HTML entity decoder
// ---------------------------------------------------------------------------
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
// Named entities
if (entity == "amp") return "&";
if (entity == "lt") return "<";
if (entity == "gt") return ">";
if (entity == "quot") return "\"";
if (entity == "apos") return "'";
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
if (entity == "lsquo") return "\xE2\x80\x98";
if (entity == "rsquo") return "\xE2\x80\x99";
if (entity == "ldquo") return "\xE2\x80\x9C";
if (entity == "rdquo") return "\xE2\x80\x9D";
if (entity == "hellip") return "\xE2\x80\xA6";
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
// Numeric entities: &#123; or &#x1F;
if (!entity.empty() && entity[0] == '#') {
unsigned long cp = 0;
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
} else {
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
}
if (cp > 0 && cp < 0x80) {
return std::string(1, static_cast<char>(cp));
}
if (cp >= 0x80 && cp < 0x800) {
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 2);
}
if (cp >= 0x800 && cp < 0x10000) {
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 3);
}
if (cp >= 0x10000 && cp < 0x110000) {
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 4);
}
}
return ""; // unknown entity — drop it
}
// ---------------------------------------------------------------------------
// HTML → TextAtom list
// ---------------------------------------------------------------------------
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
std::vector<TextAtom> atoms;
bool isBold = false;
bool isItalic = false;
bool inSvg = false;
int svgDepth = 0;
std::vector<ListState> listStack;
std::string currentWord;
auto currentStyle = [&]() -> EpdFontFamily::Style {
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
if (isBold) return EpdFontFamily::BOLD;
if (isItalic) return EpdFontFamily::ITALIC;
return EpdFontFamily::REGULAR;
};
auto flushWord = [&]() {
if (!currentWord.empty() && !inSvg) {
atoms.push_back({currentWord, currentStyle(), false, 0});
currentWord.clear();
}
};
auto indentPx = [&]() -> int {
// 15 pixels per nesting level (the first level has no extra indent)
int depth = static_cast<int>(listStack.size());
return (depth > 1) ? (depth - 1) * 15 : 0;
};
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
// that appears before the first tag in sametypesequence=h entries.
size_t i = 0;
{
size_t firstTag = html.find('<');
if (firstTag != std::string::npos) i = firstTag;
}
while (i < html.size()) {
// ------- HTML tag -------
if (html[i] == '<') {
flushWord();
size_t tagEnd = html.find('>', i);
if (tagEnd == std::string::npos) break;
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
// Extract tag name: first token, lowercased, trailing '/' stripped.
size_t space = tagContent.find(' ');
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
// --- SVG handling (skip all content inside <svg>…</svg>) ---
if (tagName == "svg") {
inSvg = true;
svgDepth = 1;
} else if (inSvg) {
if (tagName == "svg") {
svgDepth++;
} else if (tagName == "/svg") {
svgDepth--;
if (svgDepth <= 0) inSvg = false;
}
}
if (!inSvg) {
// --- Inline style tags ---
if (tagName == "b" || tagName == "strong") {
isBold = true;
} else if (tagName == "/b" || tagName == "/strong") {
isBold = false;
} else if (tagName == "i" || tagName == "em") {
isItalic = true;
} else if (tagName == "/i" || tagName == "/em") {
isItalic = false;
// --- Block-level tags → newlines ---
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// Headings get bold style applied to following text
if (tagName != "p") isBold = true;
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
isBold = false;
} else if (tagName == "br") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// --- Separator between definition entries ---
} else if (tagName == "/html") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
isBold = false;
isItalic = false;
// Skip any raw text between </html> and the next tag — this is where
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
size_t nextTag = html.find('<', tagEnd + 1);
i = (nextTag != std::string::npos) ? nextTag : html.size();
continue;
// --- Lists ---
} else if (tagName == "ol") {
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
listStack.push_back({0, alpha});
} else if (tagName == "ul") {
listStack.push_back({0, false});
} else if (tagName == "/ol" || tagName == "/ul") {
if (!listStack.empty()) listStack.pop_back();
} else if (tagName == "li") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
if (!listStack.empty()) {
auto& ls = listStack.back();
ls.counter++;
std::string marker;
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
} else if (ls.isAlpha) {
marker = std::to_string(ls.counter) + ". ";
} else {
marker = std::to_string(ls.counter) + ". ";
}
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
} else {
// Unordered list or bare <li>
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
}
}
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
// their text content will still be emitted.
}
i = tagEnd + 1;
continue;
}
// Skip content inside SVG
if (inSvg) {
i++;
continue;
}
// ------- HTML entity -------
if (html[i] == '&') {
size_t semicolon = html.find(';', i);
if (semicolon != std::string::npos && semicolon - i < 16) {
std::string entity = html.substr(i + 1, semicolon - i - 1);
std::string decoded = decodeEntity(entity);
if (!decoded.empty()) {
// Treat decoded chars like normal text (could be space etc.)
for (char dc : decoded) {
if (dc == ' ') {
flushWord();
} else {
currentWord += dc;
}
}
}
i = semicolon + 1;
continue;
}
// Not a valid entity — emit '&' literally
currentWord += '&';
i++;
continue;
}
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
if (html[i] == '/' || html[i] == '[') {
char closeDelim = (html[i] == '/') ? '/' : ']';
size_t end = html.find(closeDelim, i + 1);
if (end != std::string::npos && end - i < 80) {
bool hasNonAscii = false;
for (size_t j = i + 1; j < end; j++) {
if (static_cast<unsigned char>(html[j]) > 127) {
hasNonAscii = true;
break;
}
}
if (hasNonAscii) {
flushWord();
i = end + 1; // skip entire IPA section including delimiters
continue;
}
}
// Not IPA — fall through to treat as regular character
}
// ------- Whitespace -------
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
flushWord();
i++;
continue;
}
// ------- Regular character (with non-renderable character filter) -------
{
unsigned char byte = static_cast<unsigned char>(html[i]);
if (byte < 0x80) {
// ASCII — always renderable
currentWord += html[i];
i++;
} else {
// Multi-byte UTF-8: decode codepoint and check if renderable
int seqLen = 1;
uint32_t cp = 0;
if ((byte & 0xE0) == 0xC0) {
seqLen = 2;
cp = byte & 0x1F;
} else if ((byte & 0xF0) == 0xE0) {
seqLen = 3;
cp = byte & 0x0F;
} else if ((byte & 0xF8) == 0xF0) {
seqLen = 4;
cp = byte & 0x07;
} else {
i++;
continue;
} // invalid start byte
if (i + static_cast<size_t>(seqLen) > html.size()) {
i++;
continue;
}
bool valid = true;
for (int j = 1; j < seqLen; j++) {
unsigned char cb = static_cast<unsigned char>(html[i + j]);
if ((cb & 0xC0) != 0x80) {
valid = false;
break;
}
cp = (cp << 6) | (cb & 0x3F);
}
if (valid && isRenderableCodepoint(cp)) {
for (int j = 0; j < seqLen; j++) {
currentWord += html[i + j];
}
}
// else: silently skip non-renderable character
i += valid ? seqLen : 1;
}
}
}
flushWord();
return atoms;
}
// ---------------------------------------------------------------------------
// Word-wrap the parsed HTML atoms into positioned line segments
// ---------------------------------------------------------------------------
void DictionaryDefinitionActivity::wrapText() {
wrappedLines.clear();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int screenWidth = renderer.getScreenWidth();
const int lineHeight = renderer.getLineHeight(readerFontId);
const int sidePadding = landscape ? 50 : 20;
constexpr int topArea = 50;
constexpr int bottomArea = 50;
const int maxWidth = screenWidth - 2 * sidePadding;
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
if (linesPerPage < 1) linesPerPage = 1;
auto atoms = parseHtml(definition);
std::vector<Segment> currentLine;
int currentX = 0;
int baseIndent = 0; // indent for continuation lines within the same block
for (const auto& atom : atoms) {
// ---- Newline directive ----
if (atom.isNewline) {
// Collapse multiple consecutive blank lines
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
// Already have a blank line; update indent but don't push another
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
// ---- Text word ----
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
int gap = (currentX > baseIndent) ? spaceWidth : 0;
// Wrap if this word won't fit
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
currentX = baseIndent;
gap = 0;
}
int16_t x = static_cast<int16_t>(currentX + gap);
currentLine.push_back({atom.text, x, atom.style});
currentX = x + wordWidth;
}
// Flush last line
if (!currentLine.empty()) {
wrappedLines.push_back(std::move(currentLine));
}
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
if (totalPages < 1) totalPages = 1;
}
void DictionaryDefinitionActivity::loop() {
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (prevPage && currentPage > 0) {
currentPage--;
updateRequired = true;
}
if (nextPage && currentPage < totalPages - 1) {
currentPage++;
updateRequired = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (onDone) {
onDone();
} else {
onBack();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
void DictionaryDefinitionActivity::renderScreen() {
renderer.clearScreen();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int sidePadding = landscape ? 50 : 20;
constexpr int titleY = 10;
const int lineHeight = renderer.getLineHeight(readerFontId);
constexpr int bodyStartY = 50;
// Title: the word in bold (UI font)
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
// Separator line
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
// Body: styled definition lines
int startLine = currentPage * linesPerPage;
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
int y = bodyStartY + i * lineHeight;
const auto& line = wrappedLines[startLine + i];
for (const auto& seg : line) {
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
}
}
// Pagination indicator (bottom right)
if (totalPages > 1) {
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
renderer.getScreenHeight() - 50, pageInfo.c_str());
}
// Button hints (bottom face buttons)
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);
// Side button hints (drawn in portrait coordinates for correct placement)
{
const auto origOrientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth();
constexpr int sideButtonWidth = 30;
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345;
constexpr int cornerRadius = 6;
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
if (useCCW) {
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
} else {
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
firstRender = false;
}

View File

@@ -0,0 +1,77 @@
#pragma once
#include <EpdFontFamily.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
class DictionaryDefinitionActivity final : public Activity {
public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone = nullptr)
: Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword),
definition(definition),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack),
onDone(onDone) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
struct Segment {
std::string text;
int16_t x;
EpdFontFamily::Style style;
};
// An intermediate token produced by the HTML parser before word-wrapping.
struct TextAtom {
std::string text;
EpdFontFamily::Style style;
bool isNewline;
int indent; // pixels to indent the new line (for nested lists)
};
// Tracks ordered/unordered list nesting during HTML parsing.
struct ListState {
int counter; // incremented per <li>, 0 = not yet used
bool isAlpha; // true for list-style-type: lower-alpha
};
std::string headword;
std::string definition;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void()> onDone;
std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0;
int linesPerPage = 0;
int totalPages = 0;
bool updateRequired = false;
bool firstRender = true;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::vector<TextAtom> parseHtml(const std::string& html);
static std::string decodeEntity(const std::string& entity);
static bool isRenderableCodepoint(uint32_t cp);
void wrapText();
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

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

@@ -0,0 +1,655 @@
#include "DictionaryWordSelectActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <climits>
#include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
void DictionaryWordSelectActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionaryWordSelectActivity*>(param);
self->displayTaskLoop();
}
void DictionaryWordSelectActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionaryWordSelectActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
extractWords();
mergeHyphenatedWords();
if (!rows.empty()) {
currentRow = static_cast<int>(rows.size()) / 3;
currentWordInRow = 0;
}
updateRequired = true;
xTaskCreate(&DictionaryWordSelectActivity::taskTrampoline, "DictWordSelTask", 4096, this, 1, &displayTaskHandle);
}
void DictionaryWordSelectActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
bool DictionaryWordSelectActivity::isLandscape() const {
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
}
bool DictionaryWordSelectActivity::isInverted() const {
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
}
void DictionaryWordSelectActivity::extractWords() {
words.clear();
rows.clear();
for (const auto& element : page->elements) {
// PageLine is the only concrete PageElement type, identified by tag
const auto* line = static_cast<const PageLine*>(element.get());
const auto& block = line->getBlock();
if (!block) continue;
const auto& wordList = block->getWords();
const auto& xPosList = block->getWordXpos();
auto wordIt = wordList.begin();
auto xIt = xPosList.begin();
while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop;
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});
}
}
++wordIt;
++xIt;
}
}
// Group words into rows by Y position
if (words.empty()) return;
int16_t currentY = words[0].screenY;
rows.push_back({currentY, {}});
for (size_t i = 0; i < words.size(); i++) {
// Allow small Y tolerance (words on same line may differ by a pixel)
if (std::abs(words[i].screenY - currentY) > 2) {
currentY = words[i].screenY;
rows.push_back({currentY, {}});
}
words[i].row = static_cast<int16_t>(rows.size() - 1);
rows.back().wordIndices.push_back(static_cast<int>(i));
}
}
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
for (size_t r = 0; r + 1 < rows.size(); r++) {
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
int lastWordIdx = rows[r].wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (lastWord.empty()) continue;
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
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) continue;
int nextWordIdx = rows[r + 1].wordIndices.front();
// Set bidirectional continuation links for highlighting both parts
words[lastWordIdx].continuationIndex = nextWordIdx;
words[nextWordIdx].continuationOf = lastWordIdx;
// Build merged lookup text: remove trailing hyphen and combine
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 + words[nextWordIdx].text;
words[lastWordIdx].lookupText = merged;
words[nextWordIdx].lookupText = merged;
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)
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
}
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 (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
}
return;
}
bool changed = false;
const bool landscape = isLandscape();
const bool inverted = isInverted();
// Button mapping depends on physical orientation:
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
// - Inverted: same axes but reversed directions (device is flipped 180)
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
} else if (landscape) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
} else if (inverted) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
} else {
// Portrait (default)
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
}
const int rowCount = static_cast<int>(rows.size());
// Helper: find closest word by X position in a target row
auto findClosestWord = [&](int targetRow) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
int bestMatch = 0;
int bestDist = INT_MAX;
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
int idx = rows[targetRow].wordIndices[i];
int centerX = words[idx].screenX + words[idx].width / 2;
int dist = std::abs(centerX - currentCenterX);
if (dist < bestDist) {
bestDist = dist;
bestMatch = i;
}
}
return bestMatch;
};
// Move to previous row (wrap to bottom)
if (rowPrevPressed) {
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to next row (wrap to top)
if (rowNextPressed) {
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to previous word (wrap to end of previous row)
if (wordPrevPressed) {
if (currentWordInRow > 0) {
currentWordInRow--;
} else if (rowCount > 1) {
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
}
changed = true;
}
// Move to next word (wrap to start of next row)
if (wordNextPressed) {
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
currentWordInRow++;
} else if (rowCount > 1) {
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = 0;
}
changed = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const std::string& rawWord = words[wordIdx].lookupText;
std::string cleaned = Dictionary::cleanWord(rawWord);
if (cleaned.empty()) {
GUI.drawPopup(renderer, "No word");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
// Show looking up popup, then release mutex so display task can run
xSemaphoreTake(renderingMutex, portMAX_DELAY);
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
xSemaphoreGive(renderingMutex);
bool cancelled = false;
std::string definition = Dictionary::lookup(
cleaned,
[this, &popupLayout](int percent) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.fillPopupProgress(renderer, popupLayout, percent);
xSemaphoreGive(renderingMutex);
},
[this, &cancelled]() -> bool {
mappedInput.update();
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
cancelled = true;
return true;
}
return false;
});
if (cancelled) {
updateRequired = true;
return;
}
LookupHistory::addWord(cachePath, cleaned);
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, cleaned, definition, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
// Try stem variants (e.g., "jumped" -> "jump")
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;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (changed) {
updateRequired = true;
}
}
void DictionaryWordSelectActivity::renderScreen() {
renderer.clearScreen();
// Render the page content
page->render(renderer, fontId, marginLeft, marginTop);
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& w = words[wordIdx];
// Draw inverted highlight behind selected word
const int lineHeight = renderer.getLineHeight(fontId);
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
// Highlight the other half of a hyphenated word (whether selecting first or second part)
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
otherIdx = w.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
}
}
drawHints();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void DictionaryWordSelectActivity::drawHints() {
// Draw button hints in portrait orientation (matching physical buttons and theme).
// Any hint whose area would overlap the selected word highlight is completely skipped,
// leaving the page content underneath visible.
const auto origOrientation = renderer.getOrientation();
// Get portrait dimensions for overlap math
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth(); // 480 in portrait
const int portH = renderer.getScreenHeight(); // 800 in portrait
renderer.setOrientation(origOrientation);
// Bottom button constants (match LyraTheme::drawButtonHints)
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
constexpr int buttonWidth = 80;
constexpr int cornerRadius = 6;
constexpr int textYOffset = 7;
constexpr int smallButtonHeight = 15;
constexpr int buttonPositions[] = {58, 146, 254, 342};
// Side button constants (match LyraTheme::drawSideButtonHints)
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345; // topHintButtonY
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
// Labels for face and side buttons depend on orientation,
// because the physical-to-logical mapping rotates with the screen.
const char* facePrev; // label for physical Left face button
const char* faceNext; // label for physical Right face button
const char* sideTop; // label for physical top side button (PageBack)
const char* sideBottom; // label for physical bottom side button (PageForward)
const bool landscape = isLandscape();
const bool inverted = isInverted();
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
facePrev = "Line Up"; faceNext = "Line Dn";
sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word";
} else if (landscape) { // LANDSCAPE_CCW
facePrev = "Line Dn"; faceNext = "Line Up";
sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB";
} else if (inverted) {
facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word";
sideTop = "Line Dn"; sideBottom = "Line Up";
} else { // Portrait (default)
facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB";
sideTop = "Line Up"; sideBottom = "Line Dn";
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
const char* sideLabels[] = {sideTop, sideBottom};
// ---- Determine which hints overlap the selected word ----
bool hideHint[4] = {false, false, false, false};
bool hideSide[2] = {false, false};
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
const int lineHeight = renderer.getLineHeight(fontId);
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
struct Box {
int x, y, w, h;
};
Box boxes[2];
int boxCount = 0;
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& sel = words[wordIdx];
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
boxCount = 1;
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
otherIdx = sel.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
boxCount = 2;
}
// Convert each box from the current orientation to portrait coordinates,
// then check overlap against both bottom and side button hints.
for (int b = 0; b < boxCount; b++) {
int px, py, pw, ph;
if (origOrientation == GfxRenderer::Orientation::Portrait) {
px = boxes[b].x;
py = boxes[b].y;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
px = portW - boxes[b].x - boxes[b].w;
py = portH - boxes[b].y - boxes[b].h;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
px = boxes[b].y;
py = portH - boxes[b].x - boxes[b].w;
pw = boxes[b].h;
ph = boxes[b].w;
} else {
px = portW - boxes[b].y - boxes[b].h;
py = boxes[b].x;
pw = boxes[b].h;
ph = boxes[b].w;
}
// Bottom button overlap
int hintTop = portH - buttonHeight;
if (py + ph > hintTop) {
for (int i = 0; i < 4; i++) {
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
hideHint[i] = true;
}
}
}
// Side button overlap
if (px + pw > sideX) {
for (int s = 0; s < 2; s++) {
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
hideSide[s] = true;
}
}
}
}
}
// ---- Draw all hints in portrait mode ----
// Hidden buttons are skipped entirely so the page content underneath stays visible.
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
// Bottom face buttons
for (int i = 0; i < 4; i++) {
if (hideHint[i]) continue;
const int x = buttonPositions[i];
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
false, true);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
const int tx = x + (buttonWidth - 1 - tw) / 2;
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
} else {
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
true, false, false, true);
}
}
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
if (hideSide[i]) continue;
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
// Solid background
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
// Outline (rounded on inner side, square on screen edge — matches theme)
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
// Truncate text if it would overflow the button height
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
if (useCCW) {
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
} else {
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}

View File

@@ -0,0 +1,82 @@
#pragma once
#include <Epub/Page.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
class DictionaryWordSelectActivity final : public ActivityWithSubactivity {
public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation,
const std::function<void()>& onBack,
const std::string& nextPageFirstWord = "")
: ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)),
fontId(fontId),
marginLeft(marginLeft),
marginTop(marginTop),
cachePath(cachePath),
orientation(orientation),
onBack(onBack),
nextPageFirstWord(nextPageFirstWord) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
struct WordInfo {
std::string text;
std::string lookupText;
int16_t screenX;
int16_t screenY;
int16_t width;
int16_t row;
int continuationIndex;
int continuationOf;
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
};
struct Row {
int16_t yPos;
std::vector<int> wordIndices;
};
std::unique_ptr<Page> page;
int fontId;
int marginLeft;
int marginTop;
std::string cachePath;
uint8_t orientation;
const std::function<void()> onBack;
std::string nextPageFirstWord;
std::vector<WordInfo> words;
std::vector<Row> rows;
int currentRow = 0;
int currentWordInRow = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool isLandscape() const;
bool isInverted() const;
void extractWords();
void mergeHyphenatedWords();
void renderScreen();
void drawHints();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -6,8 +6,11 @@
#include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderBookmarkSelectionActivity.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "KOReaderCredentialStore.h"
@@ -16,6 +19,13 @@
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
// Image refresh optimization strategy:
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
// 1 = Use displayWindow() for partial refresh (experimental)
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -102,6 +112,67 @@ void EpubReaderActivity::onEnter() {
}
}
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) totalSteps++;
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->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 (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
epub->generateCoverBmp(false);
// Fallback: generate placeholder if real cover extraction failed
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(),
480, 800)) {
// Last resort: X-pattern marker
epub->generateInvalidFormatCoverBmp(false);
}
}
updateProgress();
}
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
epub->generateCoverBmp(true);
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(),
480, 800)) {
// Last resort: X-pattern marker
epub->generateInvalidFormatCoverBmp(true);
}
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
if (!PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
epub->getAuthor(), thumbWidth, thumbHeight)) {
// Last resort: X-pattern marker
epub->generateInvalidFormatThumbBmp(thumbHeight);
}
}
updateProgress();
}
}
}
}
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
@@ -196,10 +267,14 @@ void EpubReaderActivity::loop() {
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
const bool hasDictionary = Dictionary::exists();
const bool isBookmarked = BookmarkStore::hasBookmark(
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
exitActivity();
enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex);
}
@@ -293,6 +368,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
// Apply the user-selected orientation when the menu is dismissed.
// This ensures the menu can be navigated without immediately rotating the screen.
applyOrientation(orientation);
// Force a half refresh on the next render to clear menu/popup artifacts
pagesUntilFullRefresh = 1;
updateRequired = true;
}
@@ -360,6 +437,170 @@ void EpubReaderActivity::jumpToPercent(int percent) {
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) {
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
const int page = section ? section->currentPage : 0;
// Extract first full sentence from the current page for the bookmark snippet.
// If the first word is lowercase, the page starts mid-sentence — skip to the
// next sentence boundary and start collecting from there.
std::string snippet;
if (section) {
auto p = section->loadPageFromSectionFile();
if (p) {
// Gather all words on the page into a flat list for easier traversal
std::vector<std::string> allWords;
for (const auto& element : p->elements) {
const auto* line = static_cast<const PageLine*>(element.get());
if (!line) continue;
const auto& block = line->getBlock();
if (!block) continue;
for (const auto& word : block->getWords()) {
allWords.push_back(word);
}
}
if (!allWords.empty()) {
size_t startIdx = 0;
// Check if the first word starts with a lowercase letter (mid-sentence)
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
if (firstChar >= 'a' && firstChar <= 'z') {
// Skip past the end of this partial sentence
for (size_t i = 0; i < allWords.size(); i++) {
if (!allWords[i].empty()) {
char last = allWords[i].back();
if (last == '.' || last == '!' || last == '?' || last == ':') {
startIdx = i + 1;
break;
}
}
}
// If no sentence boundary found, fall back to using everything from the start
if (startIdx >= allWords.size()) {
startIdx = 0;
}
}
// Collect words from startIdx until the next sentence boundary
for (size_t i = startIdx; i < allWords.size(); i++) {
if (!snippet.empty()) snippet += " ";
snippet += allWords[i];
if (!allWords[i].empty()) {
char last = allWords[i].back();
if (last == '.' || last == '!' || last == '?' || last == ':') {
break;
}
}
}
}
}
}
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Bookmark added");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
vTaskDelay(750 / portTICK_PERIOD_MS);
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
// and next menu open will reflect the updated state.
exitActivity();
pagesUntilFullRefresh = 1;
updateRequired = true;
break;
}
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
const int page = section ? section->currentPage : 0;
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Bookmark removed");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
vTaskDelay(750 / portTICK_PERIOD_MS);
exitActivity();
pagesUntilFullRefresh = 1;
updateRequired = true;
break;
}
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
auto bookmarks = BookmarkStore::load(epub->getCachePath());
if (bookmarks.empty()) {
// No bookmarks: fall back to Table of Contents if available, otherwise go back
if (epub->getTocItemsCount() > 0) {
const int currentP = section ? section->currentPage : 0;
const int totalP = section ? section->pageCount : 0;
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
// If no TOC either, just return to reader (menu already closed by callback)
break;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
if (Dictionary::cacheExists()) {
Dictionary::deleteCache();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Dictionary cache deleted");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
} else {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "No cache to delete");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
break;
}
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
@@ -427,6 +668,70 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::LOOKUP: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Compute margins (same logic as renderScreen)
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin;
orientedMarginRight += SETTINGS.screenMargin;
orientedMarginBottom += SETTINGS.screenMargin;
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
auto metrics = UITheme::getInstance().getMetrics();
const bool showProgressBar =
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
}
// Load the current page
auto pageForLookup = section ? section->loadPageFromSectionFile() : nullptr;
const int readerFontId = SETTINGS.getReaderFontId();
const std::string bookCachePath = epub->getCachePath();
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();
if (pageForLookup) {
enterNewActivity(new DictionaryWordSelectActivity(
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
}
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// Defer go home to avoid race condition with display task
pendingGoHome = true;
@@ -449,6 +754,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
epub->setupCacheDir();
saveProgress(backupSpine, backupPage, backupPageCount);
// 5. Remove from recent books so the home screen doesn't show a stale/placeholder cover
RECENT_BOOKS.removeBook(epub->getPath());
}
xSemaphoreGive(renderingMutex);
// Defer go home to avoid race condition with display task
@@ -480,6 +788,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
break;
}
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
break;
}
}
@@ -566,6 +878,8 @@ void EpubReaderActivity::renderScreen() {
}
if (!section) {
loadingSection = true;
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
@@ -585,6 +899,7 @@ void EpubReaderActivity::renderScreen() {
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) {
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
loadingSection = false;
return;
}
} else {
@@ -617,6 +932,8 @@ void EpubReaderActivity::renderScreen() {
section->currentPage = newPage;
pendingPercentJump = false;
}
loadingSection = false;
}
renderer.clearScreen();
@@ -672,12 +989,68 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
// Determine if this page needs special image handling
bool pageHasImages = page->hasImages();
bool useAntiAliasing = SETTINGS.textAntiAliasing;
// Force half refresh for pages with images when anti-aliasing is on,
// as grayscale tones require half refresh to display correctly
bool forceFullRefresh = pageHasImages && useAntiAliasing;
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
const int screenWidth = renderer.getScreenWidth();
const int bkWidth = 12;
const int bkHeight = 22;
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
const int bkY = 0;
const int notchDepth = bkHeight / 3;
const int centerX = bkX + bkWidth / 2;
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
renderer.fillPolygon(xPoints, yPoints, 5, true);
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
// Check if half-refresh is needed (either entering Reader or pages counter reached)
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else if (forceFullRefresh) {
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
int imgX, imgY, imgW, imgH;
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
int screenX = imgX + orientedMarginLeft;
int screenY = imgY + orientedMarginTop;
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)",
imgX, imgY, imgW, imgH, screenX, screenY, imgW, imgH);
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
// Method A: Fill blank area + two FAST_REFRESH operations
renderer.fillRect(screenX, screenY, imgW, imgH, false);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
#else
// Method B (experimental): Use displayWindow() for partial refresh
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
#endif
} else {
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
pagesUntilFullRefresh--;
} else {
// Normal page without images, or images without anti-aliasing
renderer.displayBuffer();
pagesUntilFullRefresh--;
}

View File

@@ -5,7 +5,9 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "DictionaryWordSelectActivity.h"
#include "EpubReaderMenuActivity.h"
#include "LookedUpWordsActivity.h"
#include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public ActivityWithSubactivity {
@@ -27,6 +29,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
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 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()> onGoHome;
@@ -53,4 +56,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void onEnter() override;
void onExit() 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

@@ -0,0 +1,262 @@
#include "EpubReaderBookmarkSelectionActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int startY = 60 + hintGutterHeight;
const int availableHeight = screenHeight - startY - lineHeight;
return std::max(1, availableHeight / lineHeight);
}
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
std::string label;
if (epub) {
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
label = epub->getTocItem(tocIndex).title;
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
if (!bookmark.snippet.empty()) {
label += " - " + bookmark.snippet;
}
return label;
}
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
return " - Page " + std::to_string(bookmark.pageNumber + 1);
}
void EpubReaderBookmarkSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderBookmarkSelectionActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderBookmarkSelectionActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderBookmarkSelectionActivity::taskTrampoline, "BookmarkSelTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderBookmarkSelectionActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderBookmarkSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
const int totalItems = getTotalItems();
if (totalItems == 0) {
// All bookmarks deleted, go back
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onGoBack();
}
return;
}
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
// Ignore the release from the initial long press
ignoreNextConfirmRelease = false;
} else {
// Confirm delete
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
bookmarks[pendingDeleteIndex].pageNumber);
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
}
deleteConfirmMode = false;
updateRequired = true;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
updateRequired = true;
}
return;
}
// Detect long press on Confirm to trigger delete
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectorIndex;
updateRequired = true;
}
return;
}
const int pageItems = getPageItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex >= 0 && selectorIndex < totalItems) {
const auto& b = bookmarks[selectorIndex];
onSelectBookmark(b.spineIndex, b.pageNumber);
} else {
onGoBack();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void EpubReaderBookmarkSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderBookmarkSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
// Title
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
if (totalItems == 0) {
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
} else {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
// Truncate the prefix (chapter + snippet) to leave room for the page suffix
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
const std::string truncatedPrefix =
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
const std::string label = truncatedPrefix + suffix;
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
}
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
// Draw delete confirmation overlay
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
std::string msg = "Delete bookmark" + suffix + "?";
constexpr int margin = 15;
constexpr int popupY = 200;
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 w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
if (!bookmarks.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2,
renderer.getScreenHeight() - 70, deleteHint);
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,60 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/BookmarkStore.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::vector<Bookmark> bookmarks;
std::string cachePath;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool updateRequired = false;
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex, int newPage)> onSelectBookmark;
// Number of items that fit on a page, derived from logical screen height.
int getPageItems() const;
int getTotalItems() const;
// Build the prefix portion of a bookmark label (chapter + snippet, without page suffix)
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
// Build the page suffix (e.g. " - Page 5")
static std::string getPageSuffix(const Bookmark& bookmark);
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub,
std::vector<Bookmark> bookmarks,
const std::string& cachePath,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex, int newPage)>& onSelectBookmark)
: ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput),
epub(epub),
bookmarks(std::move(bookmarks)),
cachePath(cachePath),
onGoBack(onGoBack),
onSelectBookmark(onSelectBookmark) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@@ -68,6 +68,14 @@ void EpubReaderMenuActivity::loop() {
updateRequired = true;
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
auto actionCallback = onAction;
@@ -139,6 +147,12 @@ void EpubReaderMenuActivity::renderScreen() {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
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

View File

@@ -9,25 +9,48 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/BookSettings.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
// Menu actions available from the reader menu.
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
enum class MenuAction {
ADD_BOOKMARK,
REMOVE_BOOKMARK,
LOOKUP,
LOOKED_UP_WORDS,
ROTATE_SCREEN,
LETTERBOX_FILL,
SELECT_CHAPTER,
GO_TO_BOOKMARK,
GO_TO_PERCENT,
GO_HOME,
SYNC,
DELETE_CACHE,
DELETE_DICT_CACHE
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
const uint8_t currentOrientation, const bool hasDictionary,
const bool isBookmarked, const std::string& bookCachePath,
const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title),
pendingOrientation(currentOrientation),
bookCachePath(bookCachePath),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
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 onExit() override;
@@ -39,11 +62,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string label;
};
// Fixed menu layout (order matters for up/down navigation).
const std::vector<MenuItem> menuItems = {
{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"},
{MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"},
{MenuAction::SYNC, "Sync Progress"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
std::vector<MenuItem> menuItems;
int selectedIndex = 0;
bool updateRequired = false;
@@ -53,6 +72,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
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 totalPages = 0;
int bookProgressPercent = 0;
@@ -60,6 +84,50 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
const std::function<void(uint8_t)> onBack;
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) {
std::vector<MenuItem> items;
if (isBookmarked) {
items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"});
} else {
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
}
if (hasDictionary) {
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
}
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::GO_TO_BOOKMARK, "Go to Bookmark"});
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});
items.push_back({MenuAction::GO_HOME, "Close Book"});
items.push_back({MenuAction::SYNC, "Sync Progress"});
items.push_back({MenuAction::DELETE_CACHE, "Delete Book Cache"});
if (hasDictionary) {
items.push_back({MenuAction::DELETE_DICT_CACHE, "Delete Dictionary Cache"});
}
return items;
}
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();

View File

@@ -0,0 +1,269 @@
#include "LookedUpWordsActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
void LookedUpWordsActivity::taskTrampoline(void* param) {
auto* self = static_cast<LookedUpWordsActivity*>(param);
self->displayTaskLoop();
}
void LookedUpWordsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
updateRequired = true;
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
}
void LookedUpWordsActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void LookedUpWordsActivity::loop() {
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return;
}
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onBack();
}
return;
}
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
// Ignore the release from the initial long press
ignoreNextConfirmRelease = false;
} else {
// Confirm delete
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
words.erase(words.begin() + pendingDeleteIndex);
if (selectedIndex >= static_cast<int>(words.size())) {
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
}
deleteConfirmMode = false;
updateRequired = true;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
updateRequired = true;
}
return;
}
// Detect long press on Confirm to trigger delete
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectedIndex;
updateRequired = true;
return;
}
const int totalItems = static_cast<int>(words.size());
const int pageItems = getPageItems();
buttonNavigator.onNextRelease([this, totalItems] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
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;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
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;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
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() {
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 pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Header
GUI.drawHeader(
renderer,
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()) {
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
} else {
GUI.drawList(
renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
[this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
// Draw delete confirmation overlay
const std::string& word = words[pendingDeleteIndex];
std::string displayWord = word;
if (displayWord.size() > 20) {
displayWord.erase(17);
displayWord += "...";
}
std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15;
const int popupY = 200 + hintGutterHeight;
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 w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
// Button hints for delete mode
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
// "Hold select to delete" hint above button hints
if (!words.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
renderer.drawText(SMALL_FONT_ID, hintX,
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
deleteHint);
}
// Normal 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();
}

View File

@@ -0,0 +1,55 @@
#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 LookedUpWordsActivity final : public ActivityWithSubactivity {
public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack),
onDone(onDone) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string cachePath;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void()> onDone;
std::vector<std::string> words;
int selectedIndex = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator;
// Delete confirmation state
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int getPageItems() const;
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -5,6 +5,8 @@
#include <Serialization.h>
#include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
@@ -57,12 +59,51 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir();
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
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
auto filePath = txt->getPath();
auto fileName = filePath.substr(filePath.rfind('/') + 1);
APP_STATE.openEpubPath = filePath;
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(filePath, fileName, "", "");
RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath());
// Trigger first update
updateRequired = true;

View File

@@ -57,4 +57,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
void onEnter() override;
void onExit() 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 <HalStorage.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
@@ -43,6 +45,48 @@ void XtcReaderActivity::onEnter() {
// Load saved progress
loadProgress();
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->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(xtc->getCoverBmpPath().c_str())) {
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();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
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();
}
}
}
}
// Save current XTC as last opened book and add to recent books
APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile();

View File

@@ -173,6 +173,9 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
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) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
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) {
const uint8_t value = SETTINGS.*(settings[i].valuePtr);
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) {
valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
}

View File

@@ -27,5 +27,10 @@ class UITheme {
const BaseTheme* currentTheme;
};
// Known theme thumbnail heights to prerender when opening a book for the first time.
// These correspond to homeCoverHeight values across all themes (Lyra=226, Base=400).
static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400};
static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2;
// Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme()

View File

@@ -274,11 +274,10 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
i++) {
std::string coverPath = recentBooks[i].coverBmpPath;
bool hasCover = true;
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
if (coverPath.empty()) {
hasCover = false;
} else {
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
if (!coverPath.empty()) {
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
// First time: load cover from SD and render
@@ -292,20 +291,12 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
static_cast<float>(LyraMetrics::values.homeCoverHeight);
float cropX = 1.0f - (tileRatio / ratio);
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
} else {
hasCover = false;
}
file.close();
}
}
if (!hasCover) {
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
}
}
coverBufferStored = storeCoverBuffer();

View File

@@ -3,6 +3,7 @@
#include <GfxRenderer.h>
#include <HalDisplay.h>
#include <HalGPIO.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <Logging.h>
#include <SPI.h>
@@ -32,18 +33,22 @@
HalDisplay display;
HalGPIO gpio;
HalPowerManager powerManager;
MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
Activity* currentActivity;
// Fonts
#ifndef OMIT_BOOKERLY
EpdFont bookerly14RegularFont(&bookerly_14_regular);
EpdFont bookerly14BoldFont(&bookerly_14_bold);
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
&bookerly14BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
EpdFont bookerly12RegularFont(&bookerly_12_regular);
EpdFont bookerly12BoldFont(&bookerly_12_bold);
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
@@ -62,7 +67,9 @@ EpdFont bookerly18ItalicFont(&bookerly_18_italic);
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
&bookerly18BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
EpdFont notosans12RegularFont(&notosans_12_regular);
EpdFont notosans12BoldFont(&notosans_12_bold);
EpdFont notosans12ItalicFont(&notosans_12_italic);
@@ -87,7 +94,9 @@ EpdFont notosans18ItalicFont(&notosans_18_italic);
EpdFont notosans18BoldItalicFont(&notosans_18_bolditalic);
EpdFontFamily notosans18FontFamily(&notosans18RegularFont, &notosans18BoldFont, &notosans18ItalicFont,
&notosans18BoldItalicFont);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
@@ -112,6 +121,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
&opendyslexic14BoldItalicFont);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS
EpdFont smallFont(&notosans_8_regular);
@@ -182,7 +192,7 @@ void verifyPowerButtonDuration() {
if (abort) {
// Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
}
}
@@ -205,7 +215,7 @@ void enterDeepSleep() {
LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
LOG_DBG("MAIN", "Entering deep sleep");
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
}
void onGoHome();
@@ -257,20 +267,28 @@ void setupDisplayAndFonts() {
display.begin();
renderer.begin();
LOG_DBG("MAIN", "Display initialized");
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#endif
#ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily);
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
@@ -282,6 +300,7 @@ void setup() {
t1 = millis();
gpio.begin();
powerManager.begin();
// Only start serial if USB connected
if (gpio.isUsbConnected()) {
@@ -317,7 +336,7 @@ void setup() {
case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep
LOG_DBG("MAIN", "Wakeup reason: After USB Power");
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
break;
case HalGPIO::WakeupReason::AfterFlash:
// After flashing, just proceed to boot
@@ -389,7 +408,8 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
}
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
@@ -420,15 +440,22 @@ void loop() {
}
}
// 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
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
// Otherwise, use longer delay to save power
if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds
if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) {
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
delay(50);
} else {
// Short delay to prevent tight loop while still being responsive

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);
};

159
src/util/BookmarkStore.cpp Normal file
View File

@@ -0,0 +1,159 @@
#include "BookmarkStore.h"
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; }
std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
std::vector<Bookmark> bookmarks;
FsFile f;
if (!Storage.openFileForRead("BKM", filePath(cachePath), f)) {
return bookmarks;
}
// File format v2: [version(1)] [count(2)] [entries...]
// Each entry: [spine(2)] [page(2)] [snippetLen(1)] [snippet(snippetLen)]
// v1 (no version byte): [count(2)] [entries of 4 bytes each]
// We detect v1 by checking if the first byte could be a version marker (0xFF).
uint8_t firstByte;
if (f.read(&firstByte, 1) != 1) {
f.close();
return bookmarks;
}
uint16_t count;
bool hasSnippets;
if (firstByte == 0xFF) {
// v2 format: version marker was 0xFF
hasSnippets = true;
uint8_t countBytes[2];
if (f.read(countBytes, 2) != 2) {
f.close();
return bookmarks;
}
count = static_cast<uint16_t>(countBytes[0]) | (static_cast<uint16_t>(countBytes[1]) << 8);
} else {
// v1 format: first byte was part of the count
hasSnippets = false;
uint8_t secondByte;
if (f.read(&secondByte, 1) != 1) {
f.close();
return bookmarks;
}
count = static_cast<uint16_t>(firstByte) | (static_cast<uint16_t>(secondByte) << 8);
}
if (count > MAX_BOOKMARKS) {
count = MAX_BOOKMARKS;
}
for (uint16_t i = 0; i < count; i++) {
uint8_t entry[4];
if (f.read(entry, 4) != 4) break;
Bookmark b;
b.spineIndex = static_cast<int16_t>(static_cast<uint16_t>(entry[0]) | (static_cast<uint16_t>(entry[1]) << 8));
b.pageNumber = static_cast<int16_t>(static_cast<uint16_t>(entry[2]) | (static_cast<uint16_t>(entry[3]) << 8));
if (hasSnippets) {
uint8_t snippetLen;
if (f.read(&snippetLen, 1) != 1) break;
if (snippetLen > 0) {
std::vector<uint8_t> buf(snippetLen);
if (f.read(buf.data(), snippetLen) != snippetLen) break;
b.snippet = std::string(buf.begin(), buf.end());
}
}
bookmarks.push_back(b);
}
f.close();
return bookmarks;
}
bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) {
FsFile f;
if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) {
LOG_ERR("BKM", "Could not save bookmarks!");
return false;
}
// Write v2 format: version marker + count + entries with snippets
uint8_t version = 0xFF;
f.write(&version, 1);
uint16_t count = static_cast<uint16_t>(bookmarks.size());
uint8_t header[2] = {static_cast<uint8_t>(count & 0xFF), static_cast<uint8_t>((count >> 8) & 0xFF)};
f.write(header, 2);
for (const auto& b : bookmarks) {
uint8_t entry[4];
entry[0] = static_cast<uint8_t>(b.spineIndex & 0xFF);
entry[1] = static_cast<uint8_t>((b.spineIndex >> 8) & 0xFF);
entry[2] = static_cast<uint8_t>(b.pageNumber & 0xFF);
entry[3] = static_cast<uint8_t>((b.pageNumber >> 8) & 0xFF);
f.write(entry, 4);
// Write snippet: length byte + string data
uint8_t snippetLen = static_cast<uint8_t>(std::min(static_cast<int>(b.snippet.size()), MAX_SNIPPET_LENGTH));
f.write(&snippetLen, 1);
if (snippetLen > 0) {
f.write(reinterpret_cast<const uint8_t*>(b.snippet.c_str()), snippetLen);
}
}
f.close();
LOG_DBG("BKM", "Saved %d bookmarks", count);
return true;
}
bool BookmarkStore::addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet) {
auto bookmarks = load(cachePath);
// Check for duplicate
for (const auto& b : bookmarks) {
if (b.spineIndex == spineIndex && b.pageNumber == page) {
return true; // Already bookmarked
}
}
if (static_cast<int>(bookmarks.size()) >= MAX_BOOKMARKS) {
return false;
}
Bookmark b;
b.spineIndex = static_cast<int16_t>(spineIndex);
b.pageNumber = static_cast<int16_t>(page);
b.snippet = snippet.substr(0, MAX_SNIPPET_LENGTH);
bookmarks.push_back(b);
return save(cachePath, bookmarks);
}
bool BookmarkStore::removeBookmark(const std::string& cachePath, int spineIndex, int page) {
auto bookmarks = load(cachePath);
auto it = std::remove_if(bookmarks.begin(), bookmarks.end(),
[spineIndex, page](const Bookmark& b) {
return b.spineIndex == spineIndex && b.pageNumber == page;
});
if (it == bookmarks.end()) {
return false; // Not found
}
bookmarks.erase(it, bookmarks.end());
return save(cachePath, bookmarks);
}
bool BookmarkStore::hasBookmark(const std::string& cachePath, int spineIndex, int page) {
auto bookmarks = load(cachePath);
return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) {
return b.spineIndex == spineIndex && b.pageNumber == page;
});
}

24
src/util/BookmarkStore.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
struct Bookmark {
int16_t spineIndex;
int16_t pageNumber;
std::string snippet; // First sentence or text excerpt from the page
};
class BookmarkStore {
public:
static std::vector<Bookmark> load(const std::string& cachePath);
static bool save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks);
static bool addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet = "");
static bool removeBookmark(const std::string& cachePath, int spineIndex, int page);
static bool hasBookmark(const std::string& cachePath, int spineIndex, int page);
private:
static std::string filePath(const std::string& cachePath);
static constexpr int MAX_BOOKMARKS = 200;
static constexpr int MAX_SNIPPET_LENGTH = 120;
};

589
src/util/Dictionary.cpp Normal file
View File

@@ -0,0 +1,589 @@
#include "Dictionary.h"
#include <HalStorage.h>
#include <algorithm>
#include <cctype>
#include <cstring>
namespace {
constexpr const char* IDX_PATH = "/.dictionary/dictionary.idx";
constexpr const char* DICT_PATH = "/.dictionary/dictionary.dict";
constexpr const char* CACHE_PATH = "/.dictionary/dictionary.cache";
constexpr uint32_t CACHE_MAGIC = 0x44494358; // "DICX"
// g_ascii_strcasecmp equivalent: compare lowercasing only ASCII A-Z.
int asciiCaseCmp(const char* s1, const char* s2) {
const auto* p1 = reinterpret_cast<const unsigned char*>(s1);
const auto* p2 = reinterpret_cast<const unsigned char*>(s2);
while (*p1 && *p2) {
unsigned char c1 = *p1, c2 = *p2;
if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
if (c1 != c2) return static_cast<int>(c1) - static_cast<int>(c2);
++p1;
++p2;
}
return static_cast<int>(*p1) - static_cast<int>(*p2);
}
// StarDict index comparison: case-insensitive first, then case-sensitive tiebreaker.
// This matches the stardict_strcmp used by StarDict to sort .idx entries.
int stardictCmp(const char* s1, const char* s2) {
int ci = asciiCaseCmp(s1, s2);
if (ci != 0) return ci;
return std::strcmp(s1, s2);
}
} // namespace
std::vector<uint32_t> Dictionary::sparseOffsets;
uint32_t Dictionary::totalWords = 0;
bool Dictionary::indexLoaded = false;
bool Dictionary::exists() { return Storage.exists(IDX_PATH); }
bool Dictionary::cacheExists() { return Storage.exists(CACHE_PATH); }
void Dictionary::deleteCache() {
Storage.remove(CACHE_PATH);
// Reset in-memory state so next lookup rebuilds from the .idx file.
sparseOffsets.clear();
totalWords = 0;
indexLoaded = false;
}
std::string Dictionary::cleanWord(const std::string& word) {
if (word.empty()) return "";
// Find first alphanumeric character
size_t start = 0;
while (start < word.size() && !std::isalnum(static_cast<unsigned char>(word[start]))) {
start++;
}
// Find last alphanumeric character
size_t end = word.size();
while (end > start && !std::isalnum(static_cast<unsigned char>(word[end - 1]))) {
end--;
}
if (start >= end) return "";
std::string result = word.substr(start, end - start);
// Lowercase
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); });
return result;
}
// ---------------------------------------------------------------------------
// Cache: persists the sparse offset table to SD card so subsequent boots skip
// the full .idx scan. The cache is invalidated when the .idx file size changes.
//
// Format: [magic 4B][idxFileSize 4B][totalWords 4B][count 4B][offsets N×4B]
// All values are stored in native byte order (little-endian on ESP32).
// ---------------------------------------------------------------------------
bool Dictionary::loadCachedIndex() {
FsFile idx;
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
const uint32_t idxFileSize = static_cast<uint32_t>(idx.fileSize());
idx.close();
FsFile cache;
if (!Storage.openFileForRead("DICT", CACHE_PATH, cache)) return false;
// Read and validate header
uint32_t header[4]; // magic, idxFileSize, totalWords, count
if (cache.read(reinterpret_cast<uint8_t*>(header), 16) != 16) {
cache.close();
return false;
}
if (header[0] != CACHE_MAGIC || header[1] != idxFileSize) {
cache.close();
return false;
}
totalWords = header[2];
const uint32_t count = header[3];
sparseOffsets.resize(count);
const int bytesToRead = static_cast<int>(count * sizeof(uint32_t));
if (cache.read(reinterpret_cast<uint8_t*>(sparseOffsets.data()), bytesToRead) != bytesToRead) {
cache.close();
sparseOffsets.clear();
totalWords = 0;
return false;
}
cache.close();
indexLoaded = true;
return true;
}
void Dictionary::saveCachedIndex(uint32_t idxFileSize) {
FsFile cache;
if (!Storage.openFileForWrite("DICT", CACHE_PATH, cache)) return;
const uint32_t count = static_cast<uint32_t>(sparseOffsets.size());
uint32_t header[4] = {CACHE_MAGIC, idxFileSize, totalWords, count};
cache.write(reinterpret_cast<const uint8_t*>(header), 16);
cache.write(reinterpret_cast<const uint8_t*>(sparseOffsets.data()), count * sizeof(uint32_t));
cache.close();
}
// Scan the .idx file to build a sparse offset table for fast lookups.
// Records the file offset of every SPARSE_INTERVAL-th entry.
bool Dictionary::loadIndex(const std::function<void(int percent)>& onProgress,
const std::function<bool()>& shouldCancel) {
// Try loading from cache first (nearly instant)
if (loadCachedIndex()) return true;
FsFile idx;
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
const uint32_t fileSize = static_cast<uint32_t>(idx.fileSize());
sparseOffsets.clear();
totalWords = 0;
uint32_t pos = 0;
int lastReportedPercent = -1;
while (pos < fileSize) {
if (shouldCancel && (totalWords % 100 == 0) && shouldCancel()) {
idx.close();
sparseOffsets.clear();
totalWords = 0;
return false;
}
if (totalWords % SPARSE_INTERVAL == 0) {
sparseOffsets.push_back(pos);
}
// Skip word (read until null terminator)
int ch;
do {
ch = idx.read();
if (ch < 0) {
pos = fileSize;
break;
}
pos++;
} while (ch != 0);
if (pos >= fileSize) break;
// Skip 8 bytes (4-byte offset + 4-byte size)
uint8_t skip[8];
if (idx.read(skip, 8) != 8) break;
pos += 8;
totalWords++;
if (onProgress && fileSize > 0) {
int percent = static_cast<int>(static_cast<uint64_t>(pos) * 90 / fileSize);
if (percent > lastReportedPercent + 4) {
lastReportedPercent = percent;
onProgress(percent);
}
}
}
idx.close();
indexLoaded = true;
// Persist to cache so next boot is instant
if (totalWords > 0) saveCachedIndex(fileSize);
return totalWords > 0;
}
// Read a null-terminated word string from the current file position.
std::string Dictionary::readWord(FsFile& file) {
std::string word;
while (true) {
int ch = file.read();
if (ch <= 0) break; // null terminator (0) or error (-1)
word += static_cast<char>(ch);
}
return word;
}
// Read a definition from the .dict file at the given offset and size.
std::string Dictionary::readDefinition(uint32_t offset, uint32_t size) {
FsFile dict;
if (!Storage.openFileForRead("DICT", DICT_PATH, dict)) return "";
dict.seekSet(offset);
std::string def(size, '\0');
int bytesRead = dict.read(reinterpret_cast<uint8_t*>(&def[0]), size);
dict.close();
if (bytesRead < 0) return "";
if (static_cast<uint32_t>(bytesRead) < size) def.resize(bytesRead);
return def;
}
// Binary search the sparse offset table, then linear scan within the matching segment.
// Uses StarDict's sort order: case-insensitive first, then case-sensitive tiebreaker.
// The exact match is case-insensitive so e.g. "simple" matches "Simple".
std::string Dictionary::searchIndex(const std::string& word, const std::function<bool()>& shouldCancel) {
if (sparseOffsets.empty()) return "";
FsFile idx;
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return "";
// Binary search the sparse offset table to find the right segment.
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
while (lo < hi) {
if (shouldCancel && shouldCancel()) {
idx.close();
return "";
}
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;
}
}
// Linear scan within the segment starting at sparseOffsets[lo].
idx.seekSet(sparseOffsets[lo]);
int maxEntries = SPARSE_INTERVAL;
if (lo == static_cast<int>(sparseOffsets.size()) - 1) {
maxEntries = static_cast<int>(totalWords - static_cast<uint32_t>(lo) * SPARSE_INTERVAL);
}
// Scan entries, preferring an exact case-sensitive match over a case-insensitive one.
// In stardict order, all case variants of a word are adjacent (e.g. "Professor" then "professor"),
// and they may have different definitions. We want the lowercase entry when the user searched
// for a lowercase word, falling back to any case variant.
uint32_t bestOffset = 0, bestSize = 0;
bool found = false;
for (int i = 0; i < maxEntries; i++) {
if (shouldCancel && shouldCancel()) {
idx.close();
return "";
}
std::string key = readWord(idx);
if (key.empty()) break;
// Read offset and size (4 bytes each, big-endian)
uint8_t buf[8];
if (idx.read(buf, 8) != 8) break;
uint32_t dictOffset = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
(static_cast<uint32_t>(buf[2]) << 8) | static_cast<uint32_t>(buf[3]);
uint32_t dictSize = (static_cast<uint32_t>(buf[4]) << 24) | (static_cast<uint32_t>(buf[5]) << 16) |
(static_cast<uint32_t>(buf[6]) << 8) | static_cast<uint32_t>(buf[7]);
if (asciiCaseCmp(key.c_str(), word.c_str()) == 0) {
// Case-insensitive match — remember the first one as fallback
if (!found) {
bestOffset = dictOffset;
bestSize = dictSize;
found = true;
}
// Exact case-sensitive match — use immediately
if (key == word) {
idx.close();
return readDefinition(dictOffset, dictSize);
}
} else if (found) {
// We've moved past all case variants of this word — stop
break;
} else if (stardictCmp(key.c_str(), word.c_str()) > 0) {
// Past the target in StarDict sort order — stop scanning
break;
}
}
idx.close();
return found ? readDefinition(bestOffset, bestSize) : "";
}
std::string Dictionary::lookup(const std::string& word, const std::function<void(int percent)>& onProgress,
const std::function<bool()>& shouldCancel) {
if (!indexLoaded) {
if (!loadIndex(onProgress, shouldCancel)) return "";
}
// searchIndex uses StarDict sort order + case-insensitive match,
// so a single pass handles all casing variants.
std::string result = searchIndex(word, shouldCancel);
if (onProgress) onProgress(100);
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;
}

34
src/util/Dictionary.h Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
class FsFile;
class Dictionary {
public:
static bool exists();
static bool cacheExists();
static void deleteCache();
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
const std::function<bool()>& shouldCancel = nullptr);
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:
static constexpr int SPARSE_INTERVAL = 512;
static std::vector<uint32_t> sparseOffsets;
static uint32_t totalWords;
static bool indexLoaded;
static bool loadIndex(const std::function<void(int percent)>& onProgress, const std::function<bool()>& shouldCancel);
static bool loadCachedIndex();
static void saveCachedIndex(uint32_t idxFileSize);
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
static std::string readWord(FsFile& file);
static std::string readDefinition(uint32_t offset, uint32_t size);
static int editDistance(const std::string& a, const std::string& b, int maxDist);
};

View File

@@ -0,0 +1,88 @@
#include "LookupHistory.h"
#include <HalStorage.h>
#include <algorithm>
std::string LookupHistory::filePath(const std::string& cachePath) { return cachePath + "/lookups.txt"; }
bool LookupHistory::hasHistory(const std::string& cachePath) {
FsFile f;
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
return false;
}
bool nonEmpty = f.available() > 0;
f.close();
return nonEmpty;
}
std::vector<std::string> LookupHistory::load(const std::string& cachePath) {
std::vector<std::string> words;
FsFile f;
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
return words;
}
std::string line;
while (f.available() && static_cast<int>(words.size()) < MAX_ENTRIES) {
char c;
if (f.read(reinterpret_cast<uint8_t*>(&c), 1) != 1) break;
if (c == '\n') {
if (!line.empty()) {
words.push_back(line);
line.clear();
}
} else {
line += c;
}
}
if (!line.empty() && static_cast<int>(words.size()) < MAX_ENTRIES) {
words.push_back(line);
}
f.close();
return words;
}
void LookupHistory::removeWord(const std::string& cachePath, const std::string& word) {
if (word.empty()) return;
auto existing = load(cachePath);
FsFile f;
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
return;
}
for (const auto& w : existing) {
if (w != word) {
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
}
}
f.close();
}
void LookupHistory::addWord(const std::string& cachePath, const std::string& word) {
if (word.empty()) return;
// Check if already present
auto existing = load(cachePath);
if (std::any_of(existing.begin(), existing.end(), [&word](const std::string& w) { return w == word; })) return;
// Cap at max entries
if (static_cast<int>(existing.size()) >= MAX_ENTRIES) return;
FsFile f;
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
return;
}
// Rewrite existing entries plus new one
for (const auto& w : existing) {
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
}
f.write(reinterpret_cast<const uint8_t*>(word.c_str()), word.size());
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
f.close();
}

15
src/util/LookupHistory.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <vector>
class LookupHistory {
public:
static std::vector<std::string> load(const std::string& cachePath);
static void addWord(const std::string& cachePath, const std::string& word);
static void removeWord(const std::string& cachePath, const std::string& word);
static bool hasHistory(const std::string& cachePath);
private:
static std::string filePath(const std::string& cachePath);
static constexpr int MAX_ENTRIES = 500;
};