29 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
cottongin
c1dfe92ea3 Merge remote-tracking branch 'upstream/master' into mod/master 2026-02-14 19:53:57 -05:00
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
66 changed files with 6466 additions and 265 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,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,115 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
// ---------------------------------------------------------------------------
// PageTableRow
// ---------------------------------------------------------------------------
void PageTableRow::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
const int baseX = xPos + xOffset;
const int baseY = yPos + yOffset;
// Draw horizontal borders (top and bottom of this row)
renderer.drawLine(baseX, baseY, baseX + totalWidth, baseY);
renderer.drawLine(baseX, baseY + rowHeight, baseX + totalWidth, baseY + rowHeight);
// Draw vertical borders and render cell contents
// Left edge
renderer.drawLine(baseX, baseY, baseX, baseY + rowHeight);
for (const auto& cell : cells) {
// Right vertical border for this cell
const int cellRightX = baseX + cell.xOffset + cell.columnWidth;
renderer.drawLine(cellRightX, baseY, cellRightX, baseY + rowHeight);
// Render each text line within the cell
const int cellTextX = baseX + cell.xOffset + TABLE_CELL_PADDING_X;
int cellLineY = baseY + 1 + TABLE_CELL_PADDING_TOP; // 1px border + top padding
for (const auto& line : cell.lines) {
line->render(renderer, fontId, cellTextX, cellLineY);
cellLineY += lineHeight;
}
}
}
bool PageTableRow::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
serialization::writePod(file, rowHeight);
serialization::writePod(file, totalWidth);
serialization::writePod(file, lineHeight);
const uint16_t cellCount = static_cast<uint16_t>(cells.size());
serialization::writePod(file, cellCount);
for (const auto& cell : cells) {
serialization::writePod(file, cell.xOffset);
serialization::writePod(file, cell.columnWidth);
const uint16_t lineCount = static_cast<uint16_t>(cell.lines.size());
serialization::writePod(file, lineCount);
for (const auto& line : cell.lines) {
if (!line->serialize(file)) {
return false;
}
}
}
return true;
}
std::unique_ptr<PageTableRow> PageTableRow::deserialize(FsFile& file) {
int16_t xPos, yPos, rowHeight, totalWidth, lineHeight;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
serialization::readPod(file, rowHeight);
serialization::readPod(file, totalWidth);
serialization::readPod(file, lineHeight);
uint16_t cellCount;
serialization::readPod(file, cellCount);
// Sanity check
if (cellCount > 100) {
LOG_ERR("PTR", "Deserialization failed: cell count %u exceeds maximum", cellCount);
return nullptr;
}
std::vector<PageTableCellData> cells;
cells.resize(cellCount);
for (uint16_t c = 0; c < cellCount; ++c) {
serialization::readPod(file, cells[c].xOffset);
serialization::readPod(file, cells[c].columnWidth);
uint16_t lineCount;
serialization::readPod(file, lineCount);
if (lineCount > 1000) {
LOG_ERR("PTR", "Deserialization failed: line count %u in cell %u exceeds maximum", lineCount, c);
return nullptr;
}
cells[c].lines.reserve(lineCount);
for (uint16_t l = 0; l < lineCount; ++l) {
auto tb = TextBlock::deserialize(file);
if (!tb) {
return nullptr;
}
cells[c].lines.push_back(std::move(tb));
}
}
return std::unique_ptr<PageTableRow>(
new PageTableRow(std::move(cells), rowHeight, totalWidth, lineHeight, xPos, yPos));
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@@ -36,8 +154,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 +176,13 @@ 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 {
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr;

View File

@@ -8,6 +8,7 @@
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageTableRow = 2,
};
// represents something that has been added to a page
@@ -17,6 +18,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 +30,43 @@ 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);
};
class Page {
public:
// the list of block index and line numbers on this page

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

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

@@ -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,6 +27,9 @@ 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

View File

@@ -413,6 +413,9 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
style.defined.paddingLeft = 1;
}
} else if (propName == "width") {
style.width = interpretLength(propValue);
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

@@ -5,6 +5,8 @@
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include "../Page.h"
#include "../htmlEntities.h"
@@ -32,8 +34,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 +115,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,21 +188,184 @@ 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;
}
// Table structure tags (only when inside a table)
if (self->inTable) {
if (strcmp(name, "tr") == 0) {
self->tableData->rows.push_back(TableRow());
self->depth += 1;
return;
}
// <col> — capture width hint for column sizing
if (strcmp(name, "col") == 0) {
CssLength widthHint;
bool hasHint = false;
// Parse HTML width attribute
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "width") == 0) {
hasHint = parseHtmlWidthAttr(atts[i + 1], widthHint);
break;
}
}
}
// CSS width (inline style) overrides HTML attribute
if (self->cssParser) {
std::string styleAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
break;
}
}
}
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
if (inlineStyle.hasWidth()) {
widthHint = inlineStyle.width;
hasHint = true;
}
}
}
if (hasHint) {
self->tableData->colWidthHints.push_back(widthHint);
} else {
// Push a zero-value placeholder to maintain index alignment
self->tableData->colWidthHints.push_back(CssLength());
}
self->depth += 1;
return;
}
if (strcmp(name, "td") == 0 || strcmp(name, "th") == 0) {
const bool isHeader = strcmp(name, "th") == 0;
// Parse colspan and width attributes
int colspan = 1;
CssLength cellWidthHint;
bool hasCellWidthHint = false;
std::string cellStyleAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "colspan") == 0) {
colspan = atoi(atts[i + 1]);
if (colspan < 1) colspan = 1;
} else if (strcmp(atts[i], "width") == 0) {
hasCellWidthHint = parseHtmlWidthAttr(atts[i + 1], cellWidthHint);
} else if (strcmp(atts[i], "style") == 0) {
cellStyleAttr = atts[i + 1];
}
}
}
// CSS width (inline style or stylesheet) overrides HTML attribute
if (self->cssParser) {
std::string classAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "class") == 0) {
classAttr = atts[i + 1];
break;
}
}
}
CssStyle cellCssStyle = self->cssParser->resolveStyle(name, classAttr);
if (!cellStyleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(cellStyleAttr);
cellCssStyle.applyOver(inlineStyle);
}
if (cellCssStyle.hasWidth()) {
cellWidthHint = cellCssStyle.width;
hasCellWidthHint = true;
}
}
// Ensure there's a row to add cells to
if (self->tableData->rows.empty()) {
self->tableData->rows.push_back(TableRow());
}
// Create a new ParsedText for this cell (characterData will flow into it)
auto cellBlockStyle = BlockStyle();
cellBlockStyle.alignment = CssTextAlign::Left;
cellBlockStyle.textAlignDefined = true;
// Explicitly disable paragraph indent for table cells
cellBlockStyle.textIndent = 0;
cellBlockStyle.textIndentDefined = true;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, cellBlockStyle));
self->nextWordContinues = false;
// Track the cell
auto& currentRow = self->tableData->rows.back();
currentRow.cells.push_back(TableCell());
currentRow.cells.back().isHeader = isHeader;
currentRow.cells.back().colspan = colspan;
if (hasCellWidthHint) {
currentRow.cells.back().widthHint = cellWidthHint;
currentRow.cells.back().hasWidthHint = true;
}
// Apply bold for header cells
if (isHeader) {
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
}
self->depth += 1;
return;
}
// Transparent table container tags
if (matches(name, TABLE_TRANSPARENT_TAGS, NUM_TABLE_TRANSPARENT_TAGS)) {
self->depth += 1;
return;
}
// Skip colgroup, col, caption
if (matches(name, TABLE_SKIP_TAGS, NUM_TABLE_SKIP_TAGS)) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
// Other tags inside table cells (p, div, span, b, i, etc.) fall through
// to the normal handling below. startNewTextBlock is a no-op when inTable.
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
std::string alt = "[Image]";
@@ -408,7 +619,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 +658,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 +680,57 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
}
// --- Table cell/row/table close handling ---
if (self->inTable) {
if (isTableCellTag) {
// Save the current cell content into the table data
if (self->tableData && !self->tableData->rows.empty()) {
auto& currentRow = self->tableData->rows.back();
if (!currentRow.cells.empty()) {
currentRow.cells.back().content = std::move(self->currentTextBlock);
}
}
// Create a safe empty ParsedText so character data between cells doesn't crash
auto safeBlockStyle = BlockStyle();
safeBlockStyle.alignment = CssTextAlign::Left;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, safeBlockStyle));
self->nextWordContinues = false;
}
if (isTableTag) {
// Process the entire buffered table
self->depth -= 1;
// Clean up style state for this depth
if (self->skipUntilDepth == self->depth) self->skipUntilDepth = INT_MAX;
if (self->boldUntilDepth == self->depth) self->boldUntilDepth = INT_MAX;
if (self->italicUntilDepth == self->depth) self->italicUntilDepth = INT_MAX;
if (self->underlineUntilDepth == self->depth) self->underlineUntilDepth = INT_MAX;
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
self->inlineStyleStack.pop_back();
self->updateEffectiveInlineStyle();
}
self->processTable();
self->inTable = false;
self->tableData.reset();
// Restore a fresh text block for content after the table
auto paragraphAlignmentBlockStyle = BlockStyle();
paragraphAlignmentBlockStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
paragraphAlignmentBlockStyle.alignment = align;
self->currentTextBlock.reset(
new ParsedText(self->extraParagraphSpacing, self->hyphenationEnabled, paragraphAlignmentBlockStyle));
return; // depth already decremented, skip the normal endElement cleanup
}
}
self->depth -= 1;
// Leaving skip
@@ -653,3 +918,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,11 +7,13 @@
#include <memory>
#include "../ParsedText.h"
#include "../TableData.h"
#include "../blocks/TextBlock.h"
#include "../css/CssParser.h"
#include "../css/CssStyle.h"
class Page;
class PageTableRow;
class GfxRenderer;
#define MAX_WORD_SIZE 200
@@ -57,10 +59,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);

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)
}
@@ -840,6 +906,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

@@ -77,6 +77,7 @@ class GfxRenderer {
// 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,9 +111,11 @@ 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

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

@@ -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

@@ -58,6 +58,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,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

@@ -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,41 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastEpub.generateCoverBmp(cropped)) {
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
lastEpub.getAuthor(), 480, 800);
}
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)();
}
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>
@@ -65,45 +66,35 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
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 (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = false;
// Try format-specific thumbnail generation first
if (StringUtils::checkFileExtension(book.path, ".epub")) {
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...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = epub.generateThumbBmp(coverHeight);
if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = "";
}
coverRendered = false;
updateRequired = true;
success = epub.generateThumbBmp(coverHeight);
} 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...");
}
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;
success = xtc.generateThumbBmp(coverHeight);
}
}
// Fallback: generate a placeholder thumbnail with title/author
if (!success && !Storage.exists(coverPath.c_str())) {
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
coverRendered = false;
updateRequired = true;
}
}
progress++;

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,8 @@
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -102,6 +107,58 @@ 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 (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
epub->generateCoverBmp(false);
// Fallback: generate placeholder if real cover extraction failed
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress();
}
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
epub->generateCoverBmp(true);
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
epub->getAuthor(), thumbWidth, thumbHeight);
}
updateProgress();
}
}
}
}
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
@@ -196,10 +253,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 +354,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 +423,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 +654,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;
@@ -480,6 +771,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 +861,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 +882,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 +915,8 @@ void EpubReaderActivity::renderScreen() {
section->currentPage = newPage;
pendingPercentJump = false;
}
loadingSection = false;
}
renderer.clearScreen();
@@ -673,6 +973,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
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);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);

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

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