Compare commits
53
Commits
feat-touch
...
cecdbefa0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecdbefa0e | ||
|
|
4c5fd653c0 | ||
|
|
05c1e9aa46 | ||
|
|
f3da5e4f06 | ||
|
|
a43061305a | ||
|
|
7ec07b4d9d | ||
|
|
c2c1badc7e | ||
|
|
5776911af0 | ||
|
|
3c5c5e8aa7 | ||
|
|
6f5c5a0900 | ||
|
|
f2b00f6b93 | ||
|
|
44ff313740 | ||
|
|
6f0707e83c | ||
|
|
7b6df60a54 | ||
|
|
1c13913713 | ||
|
|
613371b716 | ||
|
|
0d7a84e3c5 | ||
|
|
4c93950771 | ||
|
|
1d01eebf55 | ||
|
|
9bf6e5f43c | ||
|
|
ca1b833126 | ||
|
|
3e1dd31e53 | ||
|
|
685d4e88f9 | ||
|
|
79b4d63ee2 | ||
|
|
ad3138983b | ||
|
|
793685e76b | ||
|
|
551d29744a | ||
|
|
74a0969cc6 | ||
|
|
06aa1ac2f7 | ||
|
|
57447a56a6 | ||
|
|
5c86bfe1fd | ||
|
|
4cdefb137c | ||
|
|
e7f3406704 | ||
|
|
6f08a88b12 | ||
|
|
c463da994e | ||
|
|
e4d3981469 | ||
|
|
c9d27826f9 | ||
|
|
13bd00bf9d | ||
|
|
fef39da23c | ||
|
|
fd43ca2fe1 | ||
|
|
28255061fb | ||
|
|
43ff1b6644 | ||
|
|
2754a5ff01 | ||
|
|
6edeed73c5 | ||
|
|
8ac302018c | ||
|
|
a11517a62d | ||
|
|
988652513a | ||
|
|
28d0d6f5d2 | ||
|
|
568831f232 | ||
|
|
d6f5be6b7a | ||
|
|
d30fde2f4d | ||
|
|
6305777b22 | ||
|
|
9ab0b0bfb7 |
@@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/
|
||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
/managed_components
|
||||
/.dummy
|
||||
dependencies.lock
|
||||
sdkconfig.default
|
||||
sdkconfig.defaults
|
||||
CMakeLists.txt
|
||||
|
||||
@@ -8,6 +8,8 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
||||
|
||||

|
||||
|
||||
> If you're planning to buy an Xteink device, consider purchasing an **X3/X4 Developer Edition** through https://crosspointreader.com. CrossPoint receives a small share of each sale, helping fund development costs.
|
||||
|
||||
## What can CrossPoint do?
|
||||
|
||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||
@@ -67,10 +69,6 @@ USB port or browser before assuming the device is locked. Only reach for the unl
|
||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
||||
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
||||
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
||||
>
|
||||
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
|
||||
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
|
||||
> Papyrix (or any other unsupported firmware) on a locked device.**
|
||||
|
||||
## Install firmware
|
||||
|
||||
@@ -249,7 +247,7 @@ One of the best things about open source is that anyone can take the code in a d
|
||||
|
||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||
|
||||
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
||||
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
|
||||
|
||||
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
||||
|
||||
|
||||
+21
-12
@@ -5,6 +5,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [CrossPoint User Guide](#crosspoint-user-guide)
|
||||
- [1. Hardware Overview](#1-hardware-overview)
|
||||
- [Button Layout](#button-layout)
|
||||
- [Taking a Screenshot](#taking-a-screenshot)
|
||||
- [2. Power \& Startup](#2-power--startup)
|
||||
- [Power On / Off](#power-on--off)
|
||||
- [First Launch](#first-launch)
|
||||
@@ -14,7 +15,11 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
||||
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
||||
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
|
||||
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
|
||||
- [Uploading Books](#uploading-books)
|
||||
- [Removing a Book](#removing-a-book)
|
||||
- [3.6 Settings](#36-settings)
|
||||
- [3.6.1 Display](#361-display)
|
||||
- [3.6.2 Reader](#362-reader)
|
||||
@@ -23,21 +28,25 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [Cover settings](#cover-settings)
|
||||
- [Custom images](#custom-images)
|
||||
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
- [Chapter Navigation](#chapter-navigation)
|
||||
- [Auto Page Turn](#auto-page-turn)
|
||||
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
||||
- [Footnote Navigation](#footnote-navigation)
|
||||
- [System Navigation](#system-navigation)
|
||||
- [Supported Languages](#supported-languages)
|
||||
- [Page Turning](#page-turning)
|
||||
- [Chapter Navigation](#chapter-navigation)
|
||||
- [Auto Page Turn](#auto-page-turn)
|
||||
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
||||
- [Footnote Navigation](#footnote-navigation)
|
||||
- [System Navigation](#system-navigation)
|
||||
- [Supported Languages](#supported-languages)
|
||||
- [5. Reader Menu](#5-reader-menu)
|
||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||
- [5.2 Bookmarks](#52-bookmarks)
|
||||
- [6. Current Limitations & Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||
- [5.2 Bookmarks](#52-bookmarks)
|
||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
|
||||
## 1. Hardware Overview
|
||||
|
||||
|
||||
+22
-10
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
|
||||
|
||||
## `section.bin`
|
||||
|
||||
### Version 25
|
||||
### Version 29
|
||||
|
||||
Each file in `sections/*.bin` stores one laid-out spine section. The header is
|
||||
also the cache-busting key: if any layout-affecting setting differs from the
|
||||
current reader settings, the section is discarded and rebuilt.
|
||||
|
||||
Version 25 includes:
|
||||
Version 29 includes:
|
||||
|
||||
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
||||
image rendering mode, and Focus Reading
|
||||
@@ -105,6 +105,12 @@ Version 25 includes:
|
||||
- paragraph and list-item LUTs used by KOReader sync page refinement
|
||||
- optional per-word Focus Reading split metadata
|
||||
- per-page footnote entries
|
||||
- serialized word style bits for underline, strikethrough, superscript, and
|
||||
subscript
|
||||
- flat TextBlock word storage (v29): per-word arrays plus one shared
|
||||
NUL-terminated text blob, replacing v28's length-prefixed word strings. The
|
||||
on-disk order mirrors the in-RAM arena so the firmware reads a whole block
|
||||
payload with a single allocation and a single SD read
|
||||
|
||||
ImHex pattern:
|
||||
|
||||
@@ -113,7 +119,7 @@ import std.mem;
|
||||
import std.string;
|
||||
import std.core;
|
||||
|
||||
#define EXPECTED_VERSION 25
|
||||
#define EXPECTED_VERSION 29
|
||||
#define MAX_STRING_LENGTH 65535
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
@@ -174,14 +180,20 @@ struct BlockStyle {
|
||||
|
||||
struct TextBlock {
|
||||
u16 wordCount;
|
||||
String words[wordCount];
|
||||
s16 wordXPos[wordCount];
|
||||
WordStyle wordStyle[wordCount];
|
||||
|
||||
u8 hasFocus;
|
||||
if (hasFocus != 0) {
|
||||
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
|
||||
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
|
||||
u16 textBytes [[comment("Total size of text[], including one NUL per word")]];
|
||||
|
||||
if (wordCount > 0) {
|
||||
u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]];
|
||||
s16 wordXPos[wordCount];
|
||||
if (hasFocus != 0) {
|
||||
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
|
||||
}
|
||||
WordStyle wordStyle[wordCount];
|
||||
if (hasFocus != 0) {
|
||||
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
|
||||
}
|
||||
char text[textBytes] [[comment("All words back to back, each NUL-terminated")]];
|
||||
}
|
||||
|
||||
BlockStyle blockStyle;
|
||||
|
||||
+1
-1
Submodule freeink-sdk updated: 8be3236609...2442e1d672
@@ -1,7 +1,7 @@
|
||||
#include "EpdFontFamily.h"
|
||||
|
||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
||||
// Extract font style bits; render-time overlay bits do not affect font selection.
|
||||
const bool hasBold = (style & BOLD) != 0;
|
||||
const bool hasItalic = (style & ITALIC) != 0;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class EpdFontFamily {
|
||||
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
||||
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
|
||||
};
|
||||
static constexpr uint8_t TEXT_DECORATION_MASK = static_cast<uint8_t>(UNDERLINE | STRIKETHROUGH);
|
||||
|
||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||
const EpdFont* boldItalic = nullptr)
|
||||
@@ -27,6 +28,9 @@ class EpdFontFamily {
|
||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
||||
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
|
||||
static constexpr bool hasTextDecoration(const Style style) {
|
||||
return (static_cast<uint8_t>(style) & TEXT_DECORATION_MASK) != 0;
|
||||
}
|
||||
|
||||
private:
|
||||
const EpdFont* regular;
|
||||
|
||||
@@ -33,12 +33,24 @@ void FontDecompressor::freePageBuffer() {
|
||||
}
|
||||
|
||||
void FontDecompressor::freeHotGroup() {
|
||||
hotGroup.clear();
|
||||
hotGroup.shrink_to_fit();
|
||||
free(hotGroup);
|
||||
hotGroup = nullptr;
|
||||
hotGroupCapacity = 0;
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
hotGlyphBuf.clear();
|
||||
hotGlyphBuf.shrink_to_fit();
|
||||
free(hotGlyphBuf);
|
||||
hotGlyphBuf = nullptr;
|
||||
hotGlyphBufCapacity = 0;
|
||||
}
|
||||
|
||||
bool FontDecompressor::ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed) {
|
||||
if (capacity >= needed) return true;
|
||||
// Grow-only, free-then-malloc: every caller fully rewrites the buffer after a grow, so the
|
||||
// old contents are dead -- freeing first gives the allocator its best shot on a tight heap.
|
||||
free(buf);
|
||||
buf = static_cast<uint8_t*>(malloc(needed)); // owned by FontDecompressor, freed in freeHotGroup()
|
||||
capacity = buf ? needed : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
|
||||
@@ -170,24 +182,20 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
||||
}
|
||||
|
||||
// Check if hot group already has this group decompressed — if not, decompress it
|
||||
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
||||
if (!(hotGroup != nullptr && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
||||
stats.cacheMisses++;
|
||||
const EpdFontGroup& group = fontData->groups[groupIndex];
|
||||
|
||||
hotGroup.resize(group.uncompressedSize);
|
||||
if (hotGroup.empty()) {
|
||||
// ensureCapacity may free the buffer, so the cached-group identity dies with it either way.
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
if (!ensureCapacity(hotGroup, hotGroupCapacity, group.uncompressedSize)) {
|
||||
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
|
||||
hotGroup.clear();
|
||||
hotGroup.shrink_to_fit();
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
if (!decompressGroup(fontData, groupIndex, hotGroup, group.uncompressedSize)) {
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
@@ -200,18 +208,16 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
||||
}
|
||||
|
||||
// Compact just the requested glyph from byte-aligned data into scratch buffer
|
||||
if (glyph->dataLength > hotGlyphBuf.size()) {
|
||||
hotGlyphBuf.resize(glyph->dataLength);
|
||||
}
|
||||
if (hotGlyphBuf.empty()) {
|
||||
if (!ensureCapacity(hotGlyphBuf, hotGlyphBufCapacity, glyph->dataLength)) {
|
||||
LOG_ERR("FDC", "Failed to allocate %u bytes for glyph scratch", (unsigned)glyph->dataLength);
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
|
||||
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
|
||||
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf, glyph->width, glyph->height);
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return hotGlyphBuf.data();
|
||||
return hotGlyphBuf;
|
||||
}
|
||||
|
||||
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
#include <InflateReader.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "EpdFontData.h"
|
||||
|
||||
class FontDecompressor {
|
||||
@@ -67,13 +65,22 @@ class FontDecompressor {
|
||||
|
||||
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
|
||||
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
|
||||
// Nothrow high-water malloc buffers, NOT std::vector: getBitmap() runs on the render path,
|
||||
// and under -fno-exceptions a vector resize that hits OOM abort()s the firmware instead of
|
||||
// failing (field crash: hotGroup.resize() -> std::bad_alloc -> abort with ~11 KB free).
|
||||
// ensureCapacity() returns false on OOM so the caller can skip the glyph gracefully.
|
||||
const EpdFontData* hotGroupFont = nullptr;
|
||||
uint16_t hotGroupIndex = UINT16_MAX;
|
||||
std::vector<uint8_t> hotGroup;
|
||||
uint8_t* hotGroup = nullptr; // owned; freed in freeHotGroup()/dtor
|
||||
uint32_t hotGroupCapacity = 0;
|
||||
|
||||
// Scratch buffer for compacting a single glyph from the hot group.
|
||||
// Valid until the next getBitmap() call.
|
||||
std::vector<uint8_t> hotGlyphBuf;
|
||||
// Valid until the next getBitmap() call. Same ownership/OOM contract as hotGroup.
|
||||
uint8_t* hotGlyphBuf = nullptr;
|
||||
uint32_t hotGlyphBufCapacity = 0;
|
||||
|
||||
// Grow (never shrink) an owned buffer to at least `needed` bytes; false on OOM, buffer freed.
|
||||
static bool ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed);
|
||||
|
||||
void freePageBuffer();
|
||||
void freeHotGroup();
|
||||
|
||||
+44
-17
@@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||
const char* asCStr(const char* s) { return s; }
|
||||
|
||||
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||
// next page's need), eroding the largest contiguous block all session. With
|
||||
// reuse, capacities converge on the book's max page after a few turns and page
|
||||
// turns stop touching the allocator. Only three small instantiations exist
|
||||
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||
template <typename T, typename CapT>
|
||||
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||
if (buf && capacity >= needed) return true;
|
||||
delete[] buf;
|
||||
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SdCardFont::~SdCardFont() { freeAll(); }
|
||||
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
||||
s.miniBitmap = nullptr;
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniIntervalCapacity = 0;
|
||||
s.miniGlyphCapacity = 0;
|
||||
s.miniBitmapCapacity = 0;
|
||||
freeStyleMiniKern(s);
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
s.miniKernLeftCapacity = 0;
|
||||
s.miniKernRightCapacity = 0;
|
||||
s.miniKernMatrixCapacity = 0;
|
||||
}
|
||||
|
||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
@@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||
}
|
||||
|
||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
||||
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||
// non-coalescing holes in the heap every page turn).
|
||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
||||
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||
matrixBytes);
|
||||
freeStyleMiniKern(s);
|
||||
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
return missed;
|
||||
}
|
||||
|
||||
// Build mini intervals from sorted codepoints
|
||||
freeStyleMiniData(s);
|
||||
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniKernLeftEntryCount = 0;
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
|
||||
uint32_t intervalCapacity = validCount;
|
||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
||||
if (!s.miniIntervals) {
|
||||
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
return static_cast<int>(cpCount);
|
||||
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate mini glyph array
|
||||
s.miniGlyphCount = validCount;
|
||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
||||
if (!s.miniGlyphs) {
|
||||
// Mini glyph array (reused across pages when it fits)
|
||||
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
freeStyleMiniData(s);
|
||||
return static_cast<int>(cpCount);
|
||||
}
|
||||
s.miniGlyphCount = validCount;
|
||||
|
||||
// Build sorted read order for sequential I/O
|
||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||
@@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||
}
|
||||
|
||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
||||
if (!s.miniBitmap) {
|
||||
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||
delete[] readOrder;
|
||||
delete[] mappings;
|
||||
|
||||
@@ -163,13 +163,22 @@ class SdCardFont {
|
||||
// Stub EpdFontData returned when not prewarmed
|
||||
EpdFontData stubData{};
|
||||
|
||||
// Mini EpdFontData built during prewarm
|
||||
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||
// After a few pages the capacities converge on the book's max and page turns
|
||||
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||
// zeroes capacities) for style eviction / font unload.
|
||||
EpdFontData miniData{};
|
||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||
EpdGlyph* miniGlyphs = nullptr;
|
||||
uint8_t* miniBitmap = nullptr;
|
||||
uint32_t miniIntervalCount = 0;
|
||||
uint32_t miniGlyphCount = 0;
|
||||
uint32_t miniIntervalCapacity = 0;
|
||||
uint32_t miniGlyphCapacity = 0;
|
||||
uint32_t miniBitmapCapacity = 0;
|
||||
|
||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||
@@ -184,6 +193,10 @@ class SdCardFont {
|
||||
uint8_t miniKernLeftClassCount = 0;
|
||||
uint8_t miniKernRightClassCount = 0;
|
||||
int8_t* miniKernMatrix = nullptr;
|
||||
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||
uint16_t miniKernLeftCapacity = 0;
|
||||
uint16_t miniKernRightCapacity = 0;
|
||||
uint32_t miniKernMatrixCapacity = 0;
|
||||
|
||||
// The EpdFont whose data pointer we manage
|
||||
EpdFont epdFont{&stubData};
|
||||
|
||||
@@ -33,6 +33,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import socket
|
||||
import urllib.request
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -49,17 +50,44 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
|
||||
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path."""
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
||||
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
|
||||
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path.
|
||||
|
||||
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
|
||||
on a different mirror each request; a mirror may advertise an IPv6 address a
|
||||
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
|
||||
Retry on failure, forcing IPv4 resolution after the first attempt.
|
||||
"""
|
||||
if dest.exists():
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f" Downloading {dest.name}...")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except Exception as e:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
force_ipv4 = attempt > 1
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
break
|
||||
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
|
||||
last_err = e
|
||||
dest.unlink(missing_ok=True)
|
||||
if attempt < retries:
|
||||
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
|
||||
finally:
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _orig_getaddrinfo
|
||||
else:
|
||||
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
|
||||
size_kb = dest.stat().st_size / 1024
|
||||
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
||||
return dest
|
||||
|
||||
@@ -132,7 +132,16 @@ families:
|
||||
bold: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
|
||||
|
||||
|
||||
- name: Vollkorn
|
||||
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
# ── Sans-serif ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+21
-1
@@ -39,7 +39,17 @@ std::unique_ptr<PageLine> PageLine::deserialize(HalFile& file) {
|
||||
serialization::readPod(file, yPos);
|
||||
|
||||
auto tb = TextBlock::deserialize(file);
|
||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||
if (!tb) {
|
||||
LOG_ERR("PGE", "Deserialization failed: null TextBlock");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto* line = new (std::nothrow) PageLine(std::move(tb), xPos, yPos);
|
||||
if (!line) {
|
||||
LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine");
|
||||
return nullptr;
|
||||
}
|
||||
return std::unique_ptr<PageLine>(line);
|
||||
}
|
||||
|
||||
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
@@ -148,6 +158,10 @@ std::unique_ptr<Page> Page::deserialize(HalFile& file) {
|
||||
|
||||
uint16_t count;
|
||||
serialization::readPod(file, count);
|
||||
// Reserve up front: growth-by-doubling needs old + new capacity live at once and
|
||||
// reallocates repeatedly — a field crash (bad_alloc -> abort under -fno-exceptions)
|
||||
// hit exactly this append path on a heavily fragmented heap.
|
||||
page->elements.reserve(count);
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint8_t tag;
|
||||
@@ -155,9 +169,15 @@ std::unique_ptr<Page> Page::deserialize(HalFile& file) {
|
||||
|
||||
if (tag == TAG_PageLine) {
|
||||
auto pl = PageLine::deserialize(file);
|
||||
if (!pl) {
|
||||
return nullptr;
|
||||
}
|
||||
page->elements.push_back(std::move(pl));
|
||||
} else if (tag == TAG_PageImage) {
|
||||
auto pi = PageImage::deserialize(file);
|
||||
if (!pi) {
|
||||
return nullptr;
|
||||
}
|
||||
page->elements.push_back(std::move(pi));
|
||||
} else if (tag == TAG_PageHorizontalRule) {
|
||||
auto rule = PageHorizontalRule::deserialize(file);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <BidiUtils.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
|
||||
if (!lineHasFocusSplit) {
|
||||
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
|
||||
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
|
||||
// TextBlock flattens the vectors into its arena; they stay owned here and die at return.
|
||||
auto block = std::make_shared<TextBlock>(lineWords, lineXPos, lineWordStyles, std::vector<uint8_t>{},
|
||||
std::vector<uint16_t>{}, blockStyle);
|
||||
if (!block->valid()) {
|
||||
LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed");
|
||||
return;
|
||||
}
|
||||
processLine(std::move(block));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1179,6 +1186,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
}
|
||||
|
||||
processLine(std::make_shared<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
|
||||
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
|
||||
auto block = std::make_shared<TextBlock>(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle);
|
||||
if (!block->valid()) {
|
||||
LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed");
|
||||
return;
|
||||
}
|
||||
processLine(std::move(block));
|
||||
}
|
||||
|
||||
+521
-112
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "Epub/css/CssParser.h"
|
||||
@@ -10,34 +11,64 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 27;
|
||||
// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated
|
||||
// text blob) instead of length-prefixed strings and per-field arrays.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 29;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
// as unknown and clears -- so an incomplete file is never mistaken for a valid one.
|
||||
constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
|
||||
// Written when a build is suspended partway (reader exited or device slept mid-build).
|
||||
// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse
|
||||
// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile
|
||||
// accepts it so a resume shows those pages instantly; the reader extends it by
|
||||
// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION,
|
||||
// so finalized files are untouched by this feature; older firmware treats the sentinel
|
||||
// as an unknown version and rebuilds, which is a safe downgrade.
|
||||
// MUST change in lockstep with SECTION_FILE_VERSION: the sentinel IS the partial's
|
||||
// format version, so a stale-format partial otherwise passes the header check and
|
||||
// only fails (noisily, via the block-decode error path) when a page is loaded.
|
||||
// Derived so the pairing can't be forgotten: 0xFE for v28, 0xFD for v29, ...
|
||||
constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - 28);
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t);
|
||||
|
||||
struct PageLutEntry {
|
||||
uint32_t fileOffset;
|
||||
uint16_t paragraphIndex;
|
||||
uint16_t listItemIndex;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
|
||||
// constructed/destroyed where the parser's full definition is visible.
|
||||
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
: epub(epub),
|
||||
spineIndex(spineIndex),
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
|
||||
// Suspend any in-progress build so every section.reset() / navigation / sleep path
|
||||
// persists the pages already laid out as a partial .bin instead of discarding them
|
||||
// (no-op once a build has completed or never started).
|
||||
Section::~Section() { suspendBuild(); }
|
||||
|
||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
if (!file) {
|
||||
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
||||
LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t position = file.position();
|
||||
if (!page->serialize(file)) {
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
|
||||
return 0;
|
||||
}
|
||||
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
|
||||
|
||||
pageCount++;
|
||||
builtPageCount_++;
|
||||
// pageCount is the pages available to read: a rebuild over a partial only raises it
|
||||
// once it has laid out more pages than the partial already covers.
|
||||
if (builtPageCount_ > pageCount) {
|
||||
pageCount = builtPageCount_;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
@@ -56,7 +87,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
// Written as the incomplete sentinel; finalizeBuild() patches it to
|
||||
// SECTION_FILE_VERSION as the last step, committing the file.
|
||||
serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
serialization::writePod(file, lineCompression);
|
||||
serialization::writePod(file, extraParagraphSpacing);
|
||||
@@ -83,16 +116,18 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
}
|
||||
|
||||
// Match parameters
|
||||
bool filePartial = false;
|
||||
{
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||
|
||||
int fileFontId;
|
||||
uint16_t fileViewportWidth, fileViewportHeight;
|
||||
@@ -127,14 +162,42 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
}
|
||||
|
||||
serialization::readPod(file, pageCount);
|
||||
|
||||
if (filePartial) {
|
||||
// A partial's pageCount is the watermark of a suspended build. Read the watermark
|
||||
// trailer (appended after the li LUT) so estimatedTotalPages can extrapolate.
|
||||
uint32_t liLutOffset = 0;
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t));
|
||||
serialization::readPod(file, liLutOffset);
|
||||
const uint32_t trailerOffset = liLutOffset + static_cast<uint32_t>(pageCount) * sizeof(uint16_t);
|
||||
const bool trailerValid =
|
||||
pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size();
|
||||
if (!trailerValid) {
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: malformed partial section");
|
||||
clearCache();
|
||||
pageCount = 0;
|
||||
return false;
|
||||
}
|
||||
file.seek(trailerOffset);
|
||||
serialization::readPod(file, partialBytesConsumed_);
|
||||
serialization::readPod(file, partialTotalBytes_);
|
||||
partial_ = true;
|
||||
partialPageCount_ = pageCount;
|
||||
}
|
||||
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||
bool Section::clearCache() const {
|
||||
const std::string tmpBin = binTmpPath();
|
||||
if (Storage.exists(tmpBin.c_str())) {
|
||||
Storage.remove(tmpBin.c_str());
|
||||
}
|
||||
if (!Storage.exists(filePath.c_str())) {
|
||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
@@ -154,8 +217,43 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering, const bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn) {
|
||||
// One-shot build: start, then lay out the whole section in a single pass.
|
||||
if (!startBuild(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
|
||||
hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled, popupFn)) {
|
||||
return false;
|
||||
}
|
||||
if (!buildSomeMore(0)) { // 0 = build to completion
|
||||
return false;
|
||||
}
|
||||
return buildComplete_;
|
||||
}
|
||||
|
||||
bool Section::startBuild(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight,
|
||||
const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering,
|
||||
const bool focusReadingEnabled, const std::function<void()>& popupFn) {
|
||||
if (build_) {
|
||||
LOG_ERR("SCT", "startBuild called while a build is already active");
|
||||
return false;
|
||||
}
|
||||
buildComplete_ = false;
|
||||
builtPageCount_ = 0;
|
||||
// Pages from a loaded partial stay readable (from filePath) while this build writes
|
||||
// to the tmp .bin, so availability never drops below the partial's watermark.
|
||||
pageCount = partial_ ? partialPageCount_ : 0;
|
||||
|
||||
// Remove a stale tmp .bin from a crash-interrupted build; this build recreates it.
|
||||
{
|
||||
const std::string staleTmp = binTmpPath();
|
||||
if (Storage.exists(staleTmp.c_str())) {
|
||||
Storage.remove(staleTmp.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
const auto htmlDir = epub->getCachePath() + "/html";
|
||||
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
|
||||
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
{
|
||||
@@ -163,62 +261,101 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
Storage.mkdir(sectionsDir.c_str());
|
||||
}
|
||||
|
||||
// Retry logic for SD card timing issues
|
||||
bool success = false;
|
||||
uint32_t fileSize = 0;
|
||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||
if (attempt > 0) {
|
||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||
delay(50); // Brief delay before retry
|
||||
// Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the
|
||||
// book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation
|
||||
// that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip
|
||||
// inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so
|
||||
// even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a
|
||||
// reopen skip the multi-second inflate. If htmlPath exists it is known-complete.
|
||||
const bool reusedHtml = Storage.exists(htmlPath.c_str());
|
||||
bool htmlCached = reusedHtml;
|
||||
if (reusedHtml) {
|
||||
LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str());
|
||||
} else {
|
||||
Storage.mkdir(htmlDir.c_str());
|
||||
|
||||
// Retry logic for SD card timing issues
|
||||
bool streamed = false;
|
||||
uint32_t fileSize = 0;
|
||||
for (int attempt = 0; attempt < 3 && !streamed; attempt++) {
|
||||
if (attempt > 0) {
|
||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||
delay(50); // Brief delay before retry
|
||||
}
|
||||
|
||||
// Remove any incomplete file from previous attempt before retrying
|
||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
}
|
||||
|
||||
HalFile tmpHtml;
|
||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
continue;
|
||||
}
|
||||
// Larger chunks mean far fewer SD writes inflating the HTML; a 1KB chunk turned a 584KB
|
||||
// single-spine novel into ~570 tiny writes (multi-second). 8KB keeps the transient buffers
|
||||
// small while cutting the write count 8x.
|
||||
streamed = epub->readItemContentsToStream(localPath, tmpHtml, 8192);
|
||||
fileSize = tmpHtml.size();
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tmpHtml.close();
|
||||
|
||||
// If streaming failed, remove the incomplete file immediately
|
||||
if (!streamed && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any incomplete file from previous attempt before retrying
|
||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
if (!streamed) {
|
||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||
return false;
|
||||
}
|
||||
|
||||
HalFile tmpHtml;
|
||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
continue;
|
||||
}
|
||||
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||
fileSize = tmpHtml.size();
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tmpHtml.close();
|
||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||
|
||||
// If streaming failed, remove the incomplete file immediately
|
||||
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||
// Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are
|
||||
// valid regardless of whether the layout build finishes, so reopening (even a window-only spine
|
||||
// that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp.
|
||||
if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) {
|
||||
htmlCached = true;
|
||||
} else {
|
||||
LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp");
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||
|
||||
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
||||
std::vector<PageLutEntry> lut = {};
|
||||
|
||||
auto ctx = makeUniqueNoThrow<BuildContext>();
|
||||
if (!ctx) {
|
||||
LOG_ERR("SCT", "OOM: BuildContext");
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
// htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild
|
||||
// then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up.
|
||||
ctx->reusedHtml = htmlCached;
|
||||
ctx->htmlPath = htmlPath;
|
||||
ctx->tmpHtmlPath = tmpHtmlPath;
|
||||
ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath;
|
||||
|
||||
// Derive the content base directory and image cache path prefix for the parser
|
||||
size_t lastSlash = localPath.find_last_of('/');
|
||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
const size_t lastSlash = localPath.find_last_of('/');
|
||||
ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
|
||||
CssParser* cssParser = nullptr;
|
||||
if (embeddedStyle) {
|
||||
cssParser = epub->getCssParser();
|
||||
if (cssParser) {
|
||||
if (!cssParser->loadFromCache()) {
|
||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||
}
|
||||
ctx->cssParser = epub->getCssParser();
|
||||
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
|
||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,113 +372,376 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
}
|
||||
}
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||
// The parser stores the path/contentBase/imageBasePath by reference, so they must
|
||||
// live in the BuildContext (which outlives the parser). The page-complete callback
|
||||
// captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the
|
||||
// context for the parser's whole lifetime.
|
||||
BuildContext* ctxPtr = ctx.get();
|
||||
ctx->parser = makeUniqueNoThrow<ChapterHtmlSlimParser>(
|
||||
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
||||
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, ctxPtr](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||
ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||
},
|
||||
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
if (!success) {
|
||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn,
|
||||
ctxPtr->cssParser);
|
||||
if (!ctx->parser) {
|
||||
LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser");
|
||||
if (ctx->cssParser) ctx->cssParser->clear();
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
build_ = std::move(ctx);
|
||||
|
||||
if (!build_->parser->beginParse()) {
|
||||
LOG_ERR("SCT", "Failed to begin parse");
|
||||
abandonBuild();
|
||||
return false;
|
||||
}
|
||||
build_->totalBytes = build_->parser->parseTotalBytes();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Section::buildSomeMore(const int maxPages) {
|
||||
if (!build_ || !build_->parser) {
|
||||
LOG_ERR("SCT", "buildSomeMore with no active build");
|
||||
return false;
|
||||
}
|
||||
// Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial,
|
||||
// pageCount stays pinned at the partial's watermark until the build passes it, which
|
||||
// would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark.
|
||||
const int startCount = builtPageCount_;
|
||||
for (;;) {
|
||||
const auto status = build_->parser->parseStep();
|
||||
if (status == ChapterHtmlSlimParser::ParseStatus::Error) {
|
||||
LOG_ERR("SCT", "Parse error during incremental build");
|
||||
abandonBuild();
|
||||
return false;
|
||||
}
|
||||
if (status == ChapterHtmlSlimParser::ParseStatus::Done) {
|
||||
return finalizeBuild();
|
||||
}
|
||||
// ParseStatus::More: yield once we've laid out the requested number of pages.
|
||||
if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) {
|
||||
build_->bytesConsumed = build_->parser->parseBytesConsumed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Section::hasHtmlCache() const {
|
||||
const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html";
|
||||
return Storage.exists(htmlPath.c_str());
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::findAnchorDuringBuild(const std::string& anchor) const {
|
||||
if (!build_ || !build_->parser) return std::nullopt;
|
||||
for (const auto& [key, page] : build_->parser->getAnchors()) {
|
||||
if (key == anchor) return page;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::findAnchor(const std::string& anchor) const {
|
||||
if (const auto page = findAnchorDuringBuild(anchor)) {
|
||||
return page;
|
||||
}
|
||||
// Fall back to the on-disk anchor map: a finalized section, or a partial whose map
|
||||
// covers everything up to its watermark (nullopt past it -- build further and retry).
|
||||
return getPageForAnchor(anchor);
|
||||
}
|
||||
|
||||
uint16_t Section::estimatedTotalPages() const {
|
||||
// Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA
|
||||
// damping is needed. Also the best guess while a rebuild is running but hasn't laid out
|
||||
// enough pages yet to extrapolate from its own progress.
|
||||
const auto partialEstimate = [this]() -> uint16_t {
|
||||
if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) {
|
||||
return pageCount;
|
||||
}
|
||||
const uint64_t est = static_cast<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
|
||||
if (est <= pageCount) return pageCount;
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||
};
|
||||
|
||||
if (!build_) {
|
||||
return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact
|
||||
}
|
||||
const uint32_t consumed = build_->bytesConsumed;
|
||||
const uint32_t total = build_->totalBytes;
|
||||
if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate();
|
||||
|
||||
// Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This
|
||||
// re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses
|
||||
// dense vs sparse regions of the chapter.
|
||||
const uint64_t raw = static_cast<uint64_t>(builtPageCount_) * total / consumed;
|
||||
|
||||
// Damp that jitter with an exponential moving average. Step it once per build advance (keyed on
|
||||
// bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how
|
||||
// often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so
|
||||
// the average settles onto the true count (and finalizeBuild then returns the exact pageCount).
|
||||
constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle
|
||||
if (build_->smoothedEstimate <= 0) {
|
||||
build_->smoothedEstimate = static_cast<float>(raw); // seed on the first estimate
|
||||
} else if (consumed != build_->smoothedAtConsumed) {
|
||||
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
|
||||
}
|
||||
build_->smoothedAtConsumed = consumed;
|
||||
|
||||
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
|
||||
if (est <= pageCount) return pageCount; // never fewer than the pages already available
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||
}
|
||||
|
||||
// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built
|
||||
// page count and table offsets, stamp `version` as the commit point, then swap the tmp
|
||||
// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer
|
||||
// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate
|
||||
// the total page count. The parser must still be alive (anchors are read from it).
|
||||
// On failure the tmp is removed and any pre-existing file at filePath is left intact.
|
||||
bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) {
|
||||
const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||
|
||||
const auto failCommit = [this]() {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
return false;
|
||||
};
|
||||
|
||||
const uint32_t lutOffset = file.position();
|
||||
bool hasFailedLutRecords = false;
|
||||
// Write LUT
|
||||
for (const auto& entry : lut) {
|
||||
for (const auto& entry : build_->lut) {
|
||||
if (entry.fileOffset == 0) {
|
||||
hasFailedLutRecords = true;
|
||||
break;
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
return failCommit();
|
||||
}
|
||||
serialization::writePod(file, entry.fileOffset);
|
||||
}
|
||||
|
||||
if (hasFailedLutRecords) {
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a
|
||||
// partial, skip anchors that landed on the incomplete trailing page the suspend drops.
|
||||
const uint32_t anchorMapOffset = file.position();
|
||||
const auto& anchors = visitor.getAnchors();
|
||||
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
||||
const auto& anchors = build_->parser->getAnchors();
|
||||
uint16_t anchorCount = 0;
|
||||
for (const auto& [anchor, page] : anchors) {
|
||||
if (!asPartial || page < builtPageCount_) anchorCount++;
|
||||
}
|
||||
serialization::writePod(file, anchorCount);
|
||||
for (const auto& [anchor, page] : anchors) {
|
||||
if (asPartial && page >= builtPageCount_) continue;
|
||||
serialization::writeString(file, anchor);
|
||||
serialization::writePod(file, page);
|
||||
}
|
||||
|
||||
const uint32_t paragraphLutOffset = file.position();
|
||||
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
||||
for (const auto& entry : lut) {
|
||||
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
|
||||
for (const auto& entry : build_->lut) {
|
||||
serialization::writePod(file, entry.paragraphIndex);
|
||||
}
|
||||
|
||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
||||
for (const auto& entry : lut) {
|
||||
for (const auto& entry : build_->lut) {
|
||||
serialization::writePod(file, entry.listItemIndex);
|
||||
}
|
||||
|
||||
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
||||
serialization::writePod(file, pageCount);
|
||||
if (asPartial) {
|
||||
// Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t).
|
||||
serialization::writePod(file, bytesConsumed);
|
||||
serialization::writePod(file, totalBytes);
|
||||
}
|
||||
|
||||
// Patch header with the built page count and section offsets...
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_));
|
||||
serialization::writePod(file, builtPageCount_);
|
||||
serialization::writePod(file, lutOffset);
|
||||
serialization::writePod(file, anchorMapOffset);
|
||||
serialization::writePod(file, paragraphLutOffset);
|
||||
serialization::writePod(file, liLutFileOffset);
|
||||
// ...then commit by overwriting the sentinel version with the real one. Writing the
|
||||
// version last makes it the commit point: a crash before here leaves version 0.
|
||||
file.seek(0);
|
||||
serialization::writePod(file, version);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
|
||||
// Swap into place. A crash between remove and rename loses the old file but keeps a
|
||||
// fully-committed tmp; the next build just removes it and rebuilds.
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) {
|
||||
LOG_ERR("SCT", "Failed to move built section into place");
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
bool Section::finalizeBuild() {
|
||||
// Flush the trailing page (emits the last page via the completePageFn into the LUT).
|
||||
build_->parser->finishParse();
|
||||
|
||||
if (!build_->reusedHtml) {
|
||||
// Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future
|
||||
// rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded.
|
||||
if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) {
|
||||
LOG_DBG("SCT", "Failed to promote HTML cache, removing temp");
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0);
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
build_.reset();
|
||||
if (!committed) {
|
||||
// commitBuildFile removed filePath before the failed swap, so nothing valid remains.
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = 0;
|
||||
builtPageCount_ = 0;
|
||||
return false;
|
||||
}
|
||||
buildComplete_ = true;
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = builtPageCount_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Section::suspendBuild() {
|
||||
if (!build_) return;
|
||||
|
||||
// Only worth persisting if this build produced pages a pre-existing partial doesn't
|
||||
// already cover; otherwise keep the older (bigger) partial and just drop the tmp.
|
||||
const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_);
|
||||
|
||||
bool committed = false;
|
||||
if (worthKeeping) {
|
||||
// Capture the parse watermark and commit before tearing the parser down (the anchor
|
||||
// map is read from it). The incomplete trailing page is intentionally not flushed:
|
||||
// only fully laid-out pages are persisted, and the rebuild re-derives the rest.
|
||||
const uint32_t consumed = static_cast<uint32_t>(build_->parser->parseBytesConsumed());
|
||||
committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes);
|
||||
if (committed) {
|
||||
partial_ = true;
|
||||
partialPageCount_ = builtPageCount_;
|
||||
partialBytesConsumed_ = consumed;
|
||||
partialTotalBytes_ = build_->totalBytes;
|
||||
LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_);
|
||||
}
|
||||
}
|
||||
|
||||
if (build_->parser) build_->parser->abortParse();
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
if (!committed && file) {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
}
|
||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
build_.reset();
|
||||
buildComplete_ = false;
|
||||
pageCount = partial_ ? partialPageCount_ : 0;
|
||||
builtPageCount_ = 0;
|
||||
}
|
||||
|
||||
void Section::abandonBuild() {
|
||||
if (!build_) return;
|
||||
if (build_->parser) build_->parser->abortParse();
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
if (file) {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
}
|
||||
// A parse error would recur against the same HTML, so drop any partial too -- resuming
|
||||
// from it would just re-enter the failing build every open.
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
build_.reset();
|
||||
buildComplete_ = false;
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = 0;
|
||||
builtPageCount_ = 0;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageDuringBuild(const int page) {
|
||||
if (!build_ || page < 0 || page >= static_cast<int>(build_->lut.size()) || !file) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint32_t pos = build_->lut[page].fileOffset;
|
||||
if (pos == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
// The .bin is open O_RDWR for the build. Read the already-written page, then restore
|
||||
// the write cursor so the next onPageComplete keeps appending where it left off.
|
||||
const uint32_t writePos = file.position();
|
||||
file.seek(pos);
|
||||
auto p = Page::deserialize(file);
|
||||
file.seek(writePos);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Read a page from the committed file at filePath (finalized section or partial from a
|
||||
// previous session). Uses a local handle so it is safe while a build holds the member
|
||||
// `file` open on the tmp .bin.
|
||||
std::unique_ptr<Page> Section::loadPageAt(const int page) const {
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||
uint32_t lutOffset;
|
||||
serialization::readPod(file, lutOffset);
|
||||
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
||||
serialization::readPod(f, lutOffset);
|
||||
f.seek(lutOffset + sizeof(uint32_t) * page);
|
||||
uint32_t pagePos;
|
||||
serialization::readPod(file, pagePos);
|
||||
file.seek(pagePos);
|
||||
serialization::readPod(f, pagePos);
|
||||
f.seek(pagePos);
|
||||
|
||||
auto page = Page::deserialize(file);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
return page;
|
||||
return Page::deserialize(f);
|
||||
// No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPage(const int page) {
|
||||
if (page < 0) {
|
||||
return nullptr;
|
||||
}
|
||||
if (build_ && page < static_cast<int>(build_->lut.size())) {
|
||||
return loadPageDuringBuild(page);
|
||||
}
|
||||
// Not (yet) in the active build: serve from the file on disk -- a finalized section,
|
||||
// or a partial from a previous session whose pages the rebuild hasn't reached again.
|
||||
const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount);
|
||||
if (page >= onDisk) {
|
||||
return nullptr;
|
||||
}
|
||||
return loadPageAt(page);
|
||||
}
|
||||
|
||||
std::string Section::getTextFromSectionFile() {
|
||||
std::string fullText;
|
||||
auto p = this->loadPageFromSectionFile();
|
||||
auto p = loadPage(currentPage);
|
||||
if (p) {
|
||||
for (const auto& el : p->elements) {
|
||||
if (el->getTag() == TAG_PageLine) {
|
||||
const auto& line = static_cast<const PageLine&>(*el);
|
||||
if (line.getBlock()) {
|
||||
const auto& words = line.getBlock()->getWords();
|
||||
for (const auto& w : words) {
|
||||
const auto& block = *line.getBlock();
|
||||
for (uint16_t i = 0; i < block.wordCount(); i++) {
|
||||
if (!fullText.empty()) fullText += " ";
|
||||
fullText += w;
|
||||
fullText += block.wordText(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,6 +761,15 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Only a finalized section's count is the chapter total; a partial's count is just the
|
||||
// suspended build's watermark, which would skew progress mapping. Callers fall back to
|
||||
// their own estimates.
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
|
||||
+105
-7
@@ -3,11 +3,14 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub.h"
|
||||
|
||||
class Page;
|
||||
class GfxRenderer;
|
||||
class ChapterHtmlSlimParser;
|
||||
class CssParser;
|
||||
|
||||
class Section {
|
||||
std::shared_ptr<Epub> epub;
|
||||
@@ -21,16 +24,67 @@ class Section {
|
||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
// Page-offset table entry, kept in RAM while an incremental build is running so
|
||||
// already-built pages can be located in the partially-written .bin.
|
||||
struct PageLutEntry {
|
||||
uint32_t fileOffset;
|
||||
uint16_t paragraphIndex;
|
||||
uint16_t listItemIndex;
|
||||
};
|
||||
// Held only while an incremental build is in progress (see startBuild). Carries the
|
||||
// live parser plus the strings it references (the parser stores them by reference)
|
||||
// and the in-RAM page-offset table.
|
||||
struct BuildContext {
|
||||
std::unique_ptr<ChapterHtmlSlimParser> parser;
|
||||
std::vector<PageLutEntry> lut;
|
||||
std::string parsePath;
|
||||
std::string contentBase;
|
||||
std::string imageBasePath;
|
||||
std::string htmlPath;
|
||||
std::string tmpHtmlPath;
|
||||
bool reusedHtml = false;
|
||||
CssParser* cssParser = nullptr;
|
||||
// HTML byte progress, for estimating the section's total page count while it's still building.
|
||||
uint32_t bytesConsumed = 0;
|
||||
uint32_t totalBytes = 0;
|
||||
// Exponentially-smoothed page-count estimate (0 = not yet seeded) and the bytesConsumed at its
|
||||
// last update. The raw byte-ratio estimate jitters as the build crosses dense/sparse regions;
|
||||
// the EMA is stepped once per build advance (not per redraw) to damp that wobble.
|
||||
float smoothedEstimate = 0;
|
||||
uint32_t smoothedAtConsumed = 0;
|
||||
};
|
||||
std::unique_ptr<BuildContext> build_;
|
||||
bool buildComplete_ = false;
|
||||
// Pages laid out by the active build (== build_->lut.size()). Distinct from pageCount,
|
||||
// which is the pages *available to read* and also counts a loaded partial file's pages.
|
||||
uint16_t builtPageCount_ = 0;
|
||||
// A partial section file (suspended build from a previous session) is loaded at filePath.
|
||||
// Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them.
|
||||
bool partial_ = false;
|
||||
uint16_t partialPageCount_ = 0;
|
||||
// Parse watermark from the partial's trailer, for estimating the total page count.
|
||||
uint32_t partialBytesConsumed_ = 0;
|
||||
uint32_t partialTotalBytes_ = 0;
|
||||
bool finalizeBuild();
|
||||
// Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the
|
||||
// header, stamp the version byte, and swap the tmp .bin over filePath.
|
||||
bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes);
|
||||
// Builds write here and are swapped over filePath only on commit, so a prior
|
||||
// partial/finalized file stays readable while a rebuild is in progress.
|
||||
std::string binTmpPath() const { return filePath + ".part"; }
|
||||
std::unique_ptr<Page> loadPageAt(int page) const;
|
||||
// Read a page already laid out by the in-progress build (page < build LUT size), from
|
||||
// the partially-written tmp .bin without disturbing the build's write cursor.
|
||||
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
||||
|
||||
public:
|
||||
uint16_t pageCount = 0;
|
||||
int currentPage = 0;
|
||||
|
||||
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
: epub(epub),
|
||||
spineIndex(spineIndex),
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
~Section() = default;
|
||||
// Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the
|
||||
// forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp.
|
||||
explicit Section(const std::shared_ptr<Epub>& epub, int spineIndex, GfxRenderer& renderer);
|
||||
~Section();
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled);
|
||||
@@ -39,12 +93,56 @@ class Section {
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
|
||||
// Incremental build: lay out the section a few pages at a time so a large chapter
|
||||
// can show its first page immediately and keep the UI responsive while the rest
|
||||
// builds. createSectionFile() above is the one-shot wrapper over these.
|
||||
// if (!startBuild(...)) fail;
|
||||
// each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop.
|
||||
bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled, const std::function<void()>& popupFn = nullptr);
|
||||
// Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns
|
||||
// false on error (the build is abandoned). Sets isBuildComplete() when finished.
|
||||
bool buildSomeMore(int maxPages);
|
||||
bool isBuilding() const { return static_cast<bool>(build_); }
|
||||
bool isBuildComplete() const { return buildComplete_; }
|
||||
// Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based
|
||||
// estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine
|
||||
// is still building, so "page X of Y" / progress don't read off the small build watermark.
|
||||
uint16_t estimatedTotalPages() const;
|
||||
void abandonBuild();
|
||||
// Persist an in-progress build as a partial section file (version sentinel + LUTs +
|
||||
// watermark trailer) instead of discarding it, so the next open of this spine can show
|
||||
// its pages instantly and only rebuild in the background. Called by the destructor, so
|
||||
// any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a
|
||||
// pre-existing partial when it covers more pages than this build reached.
|
||||
void suspendBuild();
|
||||
// True when a partial file was loaded: pageCount is a watermark, not the chapter total.
|
||||
bool isPartial() const { return partial_; }
|
||||
|
||||
// Unified page read: from the active build if it has reached the page, otherwise from
|
||||
// the on-disk file (finalized section, or a partial the rebuild hasn't caught up to).
|
||||
std::unique_ptr<Page> loadPage(int page);
|
||||
|
||||
std::string getTextFromSectionFile();
|
||||
|
||||
// Resolve an anchor from the in-progress build first, then the on-disk anchor map
|
||||
// (covers finalized sections and partials from a previous session).
|
||||
std::optional<uint16_t> findAnchor(const std::string& anchor) const;
|
||||
|
||||
// True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a
|
||||
// giant spine) zip inflation. Lets the reader skip the indexing popup on a fast reopen/rebuild.
|
||||
bool hasHtmlCache() const;
|
||||
|
||||
// Look up the page number for an anchor id from the section cache file.
|
||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||
|
||||
// Look up an anchor among the pages built so far by the in-progress build, so an anchor jump
|
||||
// (TOC / chapter select, usually the chapter top = page 0) can resolve without laying out the
|
||||
// whole chapter. Returns nullopt if the anchor hasn't been reached yet (build more) or no build.
|
||||
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
||||
|
||||
// Get the page count from the section cache file without fully loading it.
|
||||
std::optional<uint16_t> getCachedPageCount() const;
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ struct BlockStyle {
|
||||
bool isRtl = false; // true if resolved direction is RTL
|
||||
bool directionDefined = false; // true if direction was explicitly set in CSS/HTML
|
||||
|
||||
// Set when this block was created by a <br> element. Used by startNewTextBlock to inject
|
||||
// a full line-height gap when the <br> block stays empty (section-break use case).
|
||||
// NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks.
|
||||
bool fromBrElement = false;
|
||||
|
||||
// Combined insets (margin + padding)
|
||||
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
||||
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
||||
@@ -92,6 +97,9 @@ struct BlockStyle {
|
||||
result.directionDefined = true;
|
||||
}
|
||||
|
||||
// fromBrElement is consumed by startNewTextBlock when an empty <br> block
|
||||
// is merged with the following paragraph; never propagate it further.
|
||||
result.fromBrElement = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,30 +3,159 @@
|
||||
#include <BidiUtils.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||
size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasFocus, const uint16_t textBytes) {
|
||||
// Layout documented in TextBlock.h: 16-bit arrays first, then 8-bit arrays, then text.
|
||||
size_t size = static_cast<size_t>(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t));
|
||||
if (hasFocus) {
|
||||
size += static_cast<size_t>(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t));
|
||||
}
|
||||
return size + textBytes;
|
||||
}
|
||||
|
||||
void TextBlock::bindArenaPointers() {
|
||||
uint8_t* base = arena.get();
|
||||
const size_t wc = numWords;
|
||||
textOffArr = reinterpret_cast<const uint16_t*>(base);
|
||||
xposArr = reinterpret_cast<const int16_t*>(base + wc * 2);
|
||||
size_t off = wc * 4;
|
||||
if (focusPresent) {
|
||||
focusSuffixXArr = reinterpret_cast<const uint16_t*>(base + off);
|
||||
off += wc * 2;
|
||||
}
|
||||
stylesArr = base + off;
|
||||
off += wc;
|
||||
if (focusPresent) {
|
||||
focusBoundaryArr = base + off;
|
||||
off += wc;
|
||||
}
|
||||
textArr = reinterpret_cast<const char*>(base + off);
|
||||
}
|
||||
|
||||
TextBlock::TextBlock(const std::vector<std::string>& words, const std::vector<int16_t>& wordXpos,
|
||||
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
|
||||
const std::vector<uint16_t>& focusSuffixX, const BlockStyle& blockStyle)
|
||||
: blockStyle(blockStyle) {
|
||||
// Focus annotations are optional: empty vectors mean no word in this block has a split.
|
||||
// When present, they must be sized in lockstep with words[].
|
||||
const bool hasFocus = !wordFocusBoundary.empty();
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
||||
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
||||
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(),
|
||||
(uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size());
|
||||
const bool hasFocus = !focusBoundary.empty();
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > 10000 ||
|
||||
(hasFocus && (words.size() != focusBoundary.size() || words.size() != focusSuffixX.size()))) {
|
||||
LOG_ERR("TXB", "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)",
|
||||
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
||||
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(focusBoundary.size()),
|
||||
static_cast<uint32_t>(focusSuffixX.size()));
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
numWords = static_cast<uint16_t>(words.size());
|
||||
focusPresent = hasFocus;
|
||||
if (numWords == 0) {
|
||||
return; // valid empty block, no arena
|
||||
}
|
||||
|
||||
// Pass 1: total text size, one NUL per word. A line is at most a physical
|
||||
// row of the page, so uint16_t offsets are ample; reject anything larger.
|
||||
size_t totalText = 0;
|
||||
for (const auto& w : words) totalText += w.size() + 1;
|
||||
if (totalText > UINT16_MAX) {
|
||||
LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast<uint32_t>(totalText));
|
||||
numWords = 0;
|
||||
focusPresent = false;
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
textBytes = static_cast<uint16_t>(totalText);
|
||||
|
||||
const size_t size = arenaSize(numWords, focusPresent, textBytes);
|
||||
arena = makeUniqueNoThrow<uint8_t[]>(size);
|
||||
if (!arena) {
|
||||
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(size));
|
||||
numWords = 0;
|
||||
textBytes = 0;
|
||||
focusPresent = false;
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
bindArenaPointers();
|
||||
|
||||
// Pass 2: fill. Mutable aliases of the const views bound above.
|
||||
auto* textOff = const_cast<uint16_t*>(textOffArr);
|
||||
auto* xpos = const_cast<int16_t*>(xposArr);
|
||||
auto* styles = const_cast<uint8_t*>(stylesArr);
|
||||
auto* text = const_cast<char*>(textArr);
|
||||
uint16_t off = 0;
|
||||
for (uint16_t i = 0; i < numWords; i++) {
|
||||
textOff[i] = off;
|
||||
xpos[i] = wordXpos[i];
|
||||
styles[i] = static_cast<uint8_t>(wordStyles[i]);
|
||||
memcpy(text + off, words[i].data(), words[i].size());
|
||||
off += static_cast<uint16_t>(words[i].size());
|
||||
text[off++] = '\0';
|
||||
}
|
||||
if (focusPresent) {
|
||||
auto* suffixX = const_cast<uint16_t*>(focusSuffixXArr);
|
||||
auto* boundary = const_cast<uint8_t*>(focusBoundaryArr);
|
||||
for (uint16_t i = 0; i < numWords; i++) {
|
||||
suffixX[i] = focusSuffixX[i];
|
||||
boundary[i] = focusBoundary[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||
if (!isValid) {
|
||||
LOG_ERR("TXB", "Render skipped: invalid block");
|
||||
return;
|
||||
}
|
||||
|
||||
const bool scanning = renderer.isFontCacheScanning();
|
||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
const int wordX = wordXpos[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||
const auto baseDir = static_cast<BidiUtils::BidiBaseDir>(
|
||||
BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0));
|
||||
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
|
||||
|
||||
struct DecorationLineTracker {
|
||||
EpdFontFamily::Style style;
|
||||
int yOffset;
|
||||
int startX = -1;
|
||||
int endX = -1;
|
||||
int yPos = 0;
|
||||
|
||||
bool active() const { return startX != -1; }
|
||||
void reset() {
|
||||
startX = -1;
|
||||
endX = -1;
|
||||
yPos = 0;
|
||||
}
|
||||
};
|
||||
|
||||
DecorationLineTracker decorationLines[] = {
|
||||
{EpdFontFamily::UNDERLINE, ascender + 2},
|
||||
{EpdFontFamily::STRIKETHROUGH, ascender * 4 / 5},
|
||||
};
|
||||
|
||||
const auto flushDecoration = [&](DecorationLineTracker& line) {
|
||||
if (line.active()) {
|
||||
renderer.drawLine(line.startX, line.yPos, line.endX, line.yPos, 2, true);
|
||||
line.reset();
|
||||
}
|
||||
};
|
||||
const auto flushDecorations = [&]() {
|
||||
for (auto& line : decorationLines) {
|
||||
flushDecoration(line);
|
||||
}
|
||||
};
|
||||
|
||||
for (uint16_t i = 0; i < numWords; i++) {
|
||||
const char* word = wordText(i);
|
||||
const int wordX = xposArr[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyle(i);
|
||||
const auto baseDir =
|
||||
static_cast<BidiUtils::BidiBaseDir>(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0));
|
||||
const uint8_t boundary = focusBoundary(i);
|
||||
|
||||
// SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside
|
||||
// drawText, so these offsets are chosen relative to the full-size ascender:
|
||||
@@ -49,54 +178,81 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
|
||||
"boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
|
||||
const auto boldStyle = static_cast<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
|
||||
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
|
||||
memcpy(boldBuf, words[i].c_str(), boldLen);
|
||||
const size_t boldLen =
|
||||
std::min<size_t>({static_cast<size_t>(boundary), static_cast<size_t>(wordTextLen(i)), sizeof(boldBuf) - 1});
|
||||
memcpy(boldBuf, word, boldLen);
|
||||
boldBuf[boldLen] = '\0';
|
||||
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir);
|
||||
const int suffixX = wordX + wordFocusSuffixX[i];
|
||||
renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir);
|
||||
const int suffixX = wordX + focusSuffixXArr[i];
|
||||
renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir);
|
||||
} else {
|
||||
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
|
||||
renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir);
|
||||
}
|
||||
|
||||
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||
const std::string& w = words[i];
|
||||
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
||||
const int underlineY = wordY + ascender + 2;
|
||||
if (scanning) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
|
||||
int lineStartX = wordX;
|
||||
int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir);
|
||||
|
||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||
underlineWidth = (underlineWidth + 1) / 2;
|
||||
lineWidth = (lineWidth + 1) / 2;
|
||||
}
|
||||
|
||||
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
|
||||
// Do not decorate the synthetic em-space used for paragraph indentation.
|
||||
if (wordTextLen(i) >= 3 && static_cast<uint8_t>(word[0]) == 0xE2 && static_cast<uint8_t>(word[1]) == 0x80 &&
|
||||
static_cast<uint8_t>(word[2]) == 0x83) {
|
||||
const char* visibleText = word + 3;
|
||||
lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
||||
lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir);
|
||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||
lineWidth = (lineWidth + 1) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& line : decorationLines) {
|
||||
if ((currentStyle & line.style) == 0) {
|
||||
flushDecoration(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const int lineY = wordY + line.yOffset;
|
||||
if (line.active() && line.yPos != lineY) {
|
||||
flushDecoration(line);
|
||||
}
|
||||
if (!line.active()) {
|
||||
line.startX = lineStartX;
|
||||
line.yPos = lineY;
|
||||
}
|
||||
line.endX = lineStartX + lineWidth;
|
||||
}
|
||||
} else {
|
||||
flushDecorations();
|
||||
}
|
||||
}
|
||||
flushDecorations();
|
||||
}
|
||||
|
||||
bool TextBlock::serialize(HalFile& file) const {
|
||||
// Focus annotations are optional; vectors are either empty (no splits in this block)
|
||||
// or sized in lockstep with words[].
|
||||
const bool hasFocus = !wordFocusBoundary.empty();
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
||||
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
||||
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
||||
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
|
||||
static_cast<uint32_t>(wordFocusSuffixX.size()));
|
||||
if (!isValid) {
|
||||
LOG_ERR("TXB", "Serialization failed: invalid block");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Word data
|
||||
serialization::writePod(file, static_cast<uint16_t>(words.size()));
|
||||
for (const auto& w : words) serialization::writeString(file, w);
|
||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
||||
// Focus block: 1-byte presence flag, followed by per-word vectors only when present.
|
||||
// Saves 3 bytes/word when focus reading is disabled or no word on this line was split.
|
||||
serialization::writePod(file, static_cast<uint8_t>(hasFocus ? 1 : 0));
|
||||
if (hasFocus) {
|
||||
for (auto b : wordFocusBoundary) serialization::writePod(file, b);
|
||||
for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx);
|
||||
// Word data: scalars, then the arena verbatim -- its in-memory layout is
|
||||
// exactly the on-disk layout (see TextBlock.h), so one write covers all
|
||||
// per-word arrays and the text blob.
|
||||
serialization::writePod(file, numWords);
|
||||
serialization::writePod(file, static_cast<uint8_t>(focusPresent ? 1 : 0));
|
||||
serialization::writePod(file, textBytes);
|
||||
if (numWords > 0) {
|
||||
const size_t size = arenaSize(numWords, focusPresent, textBytes);
|
||||
if (file.write(arena.get(), size) != size) {
|
||||
LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast<uint32_t>(size));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Style (alignment + margins/padding/indent)
|
||||
@@ -120,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const {
|
||||
|
||||
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
|
||||
uint16_t wc;
|
||||
std::vector<std::string> words;
|
||||
std::vector<int16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<uint8_t> wordFocusBoundary;
|
||||
std::vector<uint16_t> wordFocusSuffixX;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
// Word count
|
||||
uint8_t hasFocus;
|
||||
uint16_t textBytes;
|
||||
serialization::readPod(file, wc);
|
||||
serialization::readPod(file, hasFocus);
|
||||
serialization::readPod(file, textBytes);
|
||||
|
||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||
// Sanity checks: cap the arena allocation and reject impossible geometry
|
||||
// (every word carries at least its NUL terminator).
|
||||
if (wc > 10000) {
|
||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||
return nullptr;
|
||||
}
|
||||
if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) {
|
||||
LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Word data
|
||||
words.resize(wc);
|
||||
wordXpos.resize(wc);
|
||||
wordStyles.resize(wc);
|
||||
for (auto& w : words) serialization::readString(file, w);
|
||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
||||
// Focus block: presence flag, then vectors only if present. Empty vectors when absent
|
||||
// signal "no splits in this block" to render() (zero per-word RAM cost).
|
||||
uint8_t hasFocus;
|
||||
serialization::readPod(file, hasFocus);
|
||||
if (hasFocus) {
|
||||
wordFocusBoundary.resize(wc);
|
||||
wordFocusSuffixX.resize(wc);
|
||||
for (auto& b : wordFocusBoundary) serialization::readPod(file, b);
|
||||
for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx);
|
||||
std::unique_ptr<TextBlock> block(new (std::nothrow) TextBlock());
|
||||
if (!block) {
|
||||
LOG_ERR("TXB", "OOM: TextBlock");
|
||||
return nullptr;
|
||||
}
|
||||
block->numWords = wc;
|
||||
block->textBytes = textBytes;
|
||||
block->focusPresent = hasFocus != 0;
|
||||
|
||||
if (wc > 0) {
|
||||
const size_t size = arenaSize(wc, block->focusPresent, textBytes);
|
||||
block->arena = makeUniqueNoThrow<uint8_t[]>(size);
|
||||
if (!block->arena) {
|
||||
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(size));
|
||||
return nullptr;
|
||||
}
|
||||
if (file.read(block->arena.get(), size) != size) {
|
||||
LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast<uint32_t>(size));
|
||||
return nullptr;
|
||||
}
|
||||
block->bindArenaPointers();
|
||||
|
||||
// Validate offsets before anything dereferences wordText(): offset 0 first,
|
||||
// strictly increasing, in bounds, and every word NUL-terminated (word i ends
|
||||
// at the byte before offset i+1; the last word at the last text byte).
|
||||
const uint16_t* textOff = block->textOffArr;
|
||||
const char* text = block->textArr;
|
||||
if (textOff[0] != 0 || text[textBytes - 1] != '\0') {
|
||||
LOG_ERR("TXB", "Deserialization failed: corrupt text layout");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint16_t i = 1; i < wc; i++) {
|
||||
if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') {
|
||||
LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Style (alignment + margins/padding/indent)
|
||||
BlockStyle& blockStyle = block->blockStyle;
|
||||
serialization::readPod(file, blockStyle.alignment);
|
||||
serialization::readPod(file, blockStyle.textAlignDefined);
|
||||
serialization::readPod(file, blockStyle.marginTop);
|
||||
@@ -170,7 +349,5 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
|
||||
serialization::readPod(file, blockStyle.isRtl);
|
||||
serialization::readPod(file, blockStyle.directionDefined);
|
||||
|
||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
|
||||
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
|
||||
blockStyle));
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -9,42 +9,83 @@
|
||||
#include "Block.h"
|
||||
#include "BlockStyle.h"
|
||||
|
||||
// Represents a line of text on a page
|
||||
// Represents a line of text on a page.
|
||||
//
|
||||
// All per-word data lives in ONE flat heap allocation (the arena) instead of
|
||||
// six parallel vectors: a resident page holds ~25-30 of these blocks, and the
|
||||
// vector-of-string layout cost ~250 throwing allocations per page load, which
|
||||
// was the primary driver of heap fragmentation on the ESP32-C3.
|
||||
//
|
||||
// Arena layout, in order (2-byte alignment holds by construction: all 16-bit
|
||||
// arrays come first and the arena base is allocator-aligned; RISC-V faults on
|
||||
// unaligned multi-byte access):
|
||||
// uint16_t textOff[wordCount] byte offset of word i's text in text[]
|
||||
// int16_t xpos[wordCount]
|
||||
// uint16_t focusSuffixX[wordCount] present only when focusPresent
|
||||
// uint8_t styles[wordCount]
|
||||
// uint8_t focusBoundary[wordCount] present only when focusPresent
|
||||
// char text[textBytes] all words back to back, NUL-terminated
|
||||
//
|
||||
// Each word is stored NUL-terminated so render() can hand `text + textOff[i]`
|
||||
// straight to C APIs (drawText) with no std::string materialization.
|
||||
//
|
||||
// Focus split semantics (unchanged from the vector layout): boundary N > 0
|
||||
// means the first N bytes of word i render bold, the remainder in the base
|
||||
// style. N is bounded to 9 codepoints (<= 36 UTF-8 bytes) by the clamp in
|
||||
// ParsedText::addWord. focusSuffixX is the pre-computed pixel offset from the
|
||||
// word start to the regular suffix. Both arrays are omitted from the arena
|
||||
// entirely when no word on the line has a split (zero per-word RAM cost when
|
||||
// focus reading is disabled).
|
||||
class TextBlock final : public Block {
|
||||
private:
|
||||
std::vector<std::string> words;
|
||||
std::vector<int16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
// Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold,
|
||||
// the remainder in the base style. 0 means no split (whole word uses wordStyles[i]).
|
||||
// N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by
|
||||
// FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t.
|
||||
// Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost
|
||||
// when focus reading is disabled, or on lines that happen to contain no splittable words).
|
||||
std::vector<uint8_t> wordFocusBoundary;
|
||||
// Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0.
|
||||
// Eliminates getTextAdvanceX from the render path. 0 when boundary == 0.
|
||||
// Empty in lockstep with wordFocusBoundary.
|
||||
std::vector<uint16_t> wordFocusSuffixX;
|
||||
BlockStyle blockStyle;
|
||||
uint16_t numWords = 0;
|
||||
uint16_t textBytes = 0; // total size of the text region, including NULs
|
||||
bool focusPresent = false;
|
||||
bool isValid = true;
|
||||
// The ONLY allocation: makeUniqueNoThrow, so OOM yields an invalid block
|
||||
// instead of abort() (bare new is not nothrow with -fno-exceptions).
|
||||
std::unique_ptr<uint8_t[]> arena;
|
||||
// Typed views into the arena, bound once after the arena is filled. All
|
||||
// 16-bit bases sit at even offsets, so direct dereference is alignment-safe.
|
||||
const uint16_t* textOffArr = nullptr;
|
||||
const int16_t* xposArr = nullptr;
|
||||
const uint16_t* focusSuffixXArr = nullptr; // null when !focusPresent
|
||||
const uint8_t* stylesArr = nullptr;
|
||||
const uint8_t* focusBoundaryArr = nullptr; // null when !focusPresent
|
||||
const char* textArr = nullptr;
|
||||
|
||||
TextBlock() = default; // deserialize() fills the fields directly
|
||||
static size_t arenaSize(uint16_t wordCount, bool hasFocus, uint16_t textBytes);
|
||||
void bindArenaPointers();
|
||||
|
||||
public:
|
||||
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
|
||||
std::vector<EpdFontFamily::Style> word_styles, std::vector<uint8_t> focus_boundary,
|
||||
std::vector<uint16_t> focus_suffix_x, const BlockStyle& blockStyle = BlockStyle())
|
||||
: words(std::move(words)),
|
||||
wordXpos(std::move(word_xpos)),
|
||||
wordStyles(std::move(word_styles)),
|
||||
wordFocusBoundary(std::move(focus_boundary)),
|
||||
wordFocusSuffixX(std::move(focus_suffix_x)),
|
||||
blockStyle(blockStyle) {}
|
||||
// Flatten-on-construct: copies the layout-time vectors into the arena; the
|
||||
// vectors die with the caller. On arena OOM the block is empty and valid()
|
||||
// is false -- callers must check and fail the line instead of using it.
|
||||
explicit TextBlock(const std::vector<std::string>& words, const std::vector<int16_t>& wordXpos,
|
||||
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
|
||||
const std::vector<uint16_t>& focusSuffixX, const BlockStyle& blockStyle = BlockStyle());
|
||||
~TextBlock() override = default;
|
||||
TextBlock(const TextBlock&) = delete;
|
||||
TextBlock& operator=(const TextBlock&) = delete;
|
||||
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||
const std::vector<std::string>& getWords() const { return words; }
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
size_t wordCount() const { return words.size(); }
|
||||
// given a renderer works out where to break the words into lines
|
||||
bool isEmpty() override { return numWords == 0; }
|
||||
bool valid() const { return isValid; }
|
||||
uint16_t wordCount() const { return numWords; }
|
||||
// NUL-terminated by construction; safe to pass to C APIs directly.
|
||||
const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; }
|
||||
uint16_t wordTextLen(const uint16_t i) const {
|
||||
const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes;
|
||||
return end - textOffArr[i] - 1; // exclude the NUL
|
||||
}
|
||||
int16_t wordXpos(const uint16_t i) const { return xposArr[i]; }
|
||||
EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast<EpdFontFamily::Style>(stylesArr[i]); }
|
||||
uint8_t focusBoundary(const uint16_t i) const { return focusPresent ? focusBoundaryArr[i] : 0; }
|
||||
uint16_t focusSuffixX(const uint16_t i) const { return focusPresent ? focusSuffixXArr[i] : 0; }
|
||||
|
||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||
BlockType getType() override { return TEXT_BLOCK; }
|
||||
bool serialize(HalFile& file) const;
|
||||
|
||||
@@ -67,13 +67,6 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe
|
||||
[](char a, char b) { return asciiToLower(a) == b; });
|
||||
}
|
||||
|
||||
// Case-insensitive ASCII substring search. Only needed by text-decoration,
|
||||
// which accepts multi-value strings like "underline solid red".
|
||||
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
|
||||
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
|
||||
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
|
||||
}
|
||||
|
||||
// Walk s and invoke fn(token) for each non-empty run between delimiters.
|
||||
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
||||
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
||||
@@ -252,11 +245,20 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
||||
}
|
||||
|
||||
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
||||
// text-decoration can have multiple space-separated values
|
||||
if (icontainsAscii(val, "underline")) {
|
||||
return CssTextDecoration::Underline;
|
||||
}
|
||||
return CssTextDecoration::None;
|
||||
// text-decoration can have multiple space-separated values. Compare whole tokens
|
||||
// so malformed values like "notunderline" do not accidentally enable a line.
|
||||
CssTextDecoration result = CssTextDecoration::None;
|
||||
bool explicitNone = false;
|
||||
forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) {
|
||||
if (iequalsAscii(token, "none")) {
|
||||
explicitNone = true;
|
||||
} else if (iequalsAscii(token, "underline")) {
|
||||
result = result | CssTextDecoration::Underline;
|
||||
} else if (iequalsAscii(token, "line-through")) {
|
||||
result = result | CssTextDecoration::LineThrough;
|
||||
}
|
||||
});
|
||||
return explicitNone ? CssTextDecoration::None : result;
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(std::string_view val) {
|
||||
@@ -868,7 +870,7 @@ bool CssParser::loadFromCache() {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
class CssParser {
|
||||
public:
|
||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 6;
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 7;
|
||||
|
||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
||||
~CssParser() = default;
|
||||
|
||||
@@ -52,8 +52,19 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
|
||||
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
||||
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
||||
|
||||
// Text decoration options
|
||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
||||
// Text decoration options. Values are bit flags so CSS can combine multiple line decorations.
|
||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 };
|
||||
|
||||
constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) {
|
||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) {
|
||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
constexpr uint8_t CSS_TEXT_DECORATION_MASK =
|
||||
static_cast<uint8_t>(CssTextDecoration::Underline) | static_cast<uint8_t>(CssTextDecoration::LineThrough);
|
||||
|
||||
// Display options - only None and Block are relevant for e-ink rendering
|
||||
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
||||
|
||||
@@ -34,6 +34,7 @@ constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
||||
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
||||
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
||||
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
|
||||
constexpr const char* IMAGE_TAGS[] = {"img"};
|
||||
constexpr const char* SKIP_TAGS[] = {"head"};
|
||||
|
||||
@@ -89,13 +90,50 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
|
||||
}
|
||||
}
|
||||
|
||||
// Update effective bold/italic/underline based on block style and inline style stack
|
||||
EpdFontFamily::Style ChapterHtmlSlimParser::fontStyleForTextDecoration(const CssTextDecoration decoration) {
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR;
|
||||
if ((decoration & CssTextDecoration::Underline) != CssTextDecoration::None) {
|
||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
if ((decoration & CssTextDecoration::LineThrough) != CssTextDecoration::None) {
|
||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::STRIKETHROUGH);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css) {
|
||||
if (css.hasTextDecoration()) {
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = css.textDecoration;
|
||||
}
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::pushDecorationStyleEntry(const CssTextDecoration defaultDecoration,
|
||||
const CssStyle& cssStyle) {
|
||||
StyleStackEntry entry;
|
||||
entry.depth = depth;
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = cssStyle.hasTextDecoration() ? cssStyle.textDecoration : defaultDecoration;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
inlineStyleStack.push_back(entry);
|
||||
updateEffectiveInlineStyle();
|
||||
}
|
||||
|
||||
// Update effective bold/italic/decorations based on block style and inline style stack
|
||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
// Start with block-level styles
|
||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
||||
effectiveUnderline =
|
||||
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
effectiveTextDecoration =
|
||||
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
|
||||
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
||||
effectiveDirection = currentCssStyle.direction;
|
||||
effectiveSup = false;
|
||||
@@ -109,8 +147,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
if (entry.hasItalic) {
|
||||
effectiveItalic = entry.italic;
|
||||
}
|
||||
if (entry.hasUnderline) {
|
||||
effectiveUnderline = entry.underline;
|
||||
// CSS line decorations propagate through descendants; child entries add
|
||||
// their own lines but cannot cancel an ancestor's already active line.
|
||||
if (entry.hasTextDecoration) {
|
||||
effectiveTextDecoration = effectiveTextDecoration | entry.textDecoration;
|
||||
}
|
||||
if (entry.hasDirection) {
|
||||
effectiveDirectionDefined = true;
|
||||
@@ -164,7 +204,6 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
// Determine font style from depth-based tracking and CSS effective style
|
||||
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
||||
|
||||
// Combine style flags using bitwise OR
|
||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||
@@ -174,9 +213,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
if (isItalic) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
||||
}
|
||||
if (isUnderline) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
|
||||
if (effectiveSup) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
||||
} else if (effectiveSub) {
|
||||
@@ -202,7 +239,16 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
// open. Merge those into the new style so the first child in a container inherits
|
||||
// the container's vertical spacing.
|
||||
const auto style = currentTextBlock->getBlockStyle();
|
||||
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
|
||||
BlockStyle incoming = blockStyle;
|
||||
if (style.fromBrElement) {
|
||||
// The empty block was created by a <br> section separator. Inject a full line of
|
||||
// blank space before the following paragraph so the scene/section break is visible.
|
||||
// This only fires when the <br> block stayed empty (i.e. no inline text was added).
|
||||
const int16_t lineHeight = static_cast<int16_t>(renderer.getLineHeight(fontId) * lineCompression + 0.5f);
|
||||
incoming.marginTop = static_cast<int16_t>(incoming.marginTop + lineHeight);
|
||||
}
|
||||
|
||||
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(incoming, BlockStyle::CombineAxis::Vertical));
|
||||
|
||||
flushPendingAnchor();
|
||||
return;
|
||||
@@ -420,14 +466,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
headerStyle.bold = false;
|
||||
headerStyle.hasItalic = true;
|
||||
headerStyle.italic = true;
|
||||
headerStyle.hasUnderline = true;
|
||||
headerStyle.underline = false;
|
||||
self->inlineStyleStack.push_back(headerStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
const CssTextDecoration savedTextDecoration = self->effectiveTextDecoration;
|
||||
self->effectiveTextDecoration = CssTextDecoration::None;
|
||||
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
self->effectiveTextDecoration = savedTextDecoration;
|
||||
self->nextWordContinues = false;
|
||||
self->inlineStyleStack.pop_back();
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -760,12 +807,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentFootnote.number[0] = '\0';
|
||||
self->currentFootnoteLinkTextLen = 0;
|
||||
|
||||
// Apply underline style to visually indicate the link
|
||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||
// Apply underline style to visually indicate the link.
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth;
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = true;
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = CssTextDecoration::Underline;
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -818,7 +864,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
self->startNewTextBlock(self->blockStyleStack.back().withoutBottom());
|
||||
// Tag the new block so startNewTextBlock can inject a full line-height gap if
|
||||
// the block remains empty (i.e. <br> is a section separator between paragraphs).
|
||||
// If the block gets text added before the next block opens it becomes non-empty,
|
||||
// goes through makePages() normally, and the flag has no effect (inline <br> case).
|
||||
BlockStyle brStyle =
|
||||
self->currentTextBlock ? self->currentTextBlock->getBlockStyle() : self->blockStyleStack.back();
|
||||
brStyle.fromBrElement = true;
|
||||
self->startNewTextBlock(brStyle);
|
||||
} else {
|
||||
self->currentCssStyle = cssStyle;
|
||||
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle,
|
||||
@@ -837,23 +890,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||
// Push inline style entry for underline tag
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = true;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
self->pushDecorationStyleEntry(CssTextDecoration::Underline, cssStyle);
|
||||
} else if (matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS))) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
self->pushDecorationStyleEntry(CssTextDecoration::LineThrough, cssStyle);
|
||||
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
@@ -870,10 +914,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -893,10 +934,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -935,10 +973,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
if (cssStyle.hasVerticalAlign()) {
|
||||
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
||||
@@ -1150,9 +1185,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
||||
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
||||
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
||||
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
||||
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic;
|
||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||
const bool tableStructuralTag = isTableStructuralTag(name);
|
||||
|
||||
@@ -1171,7 +1205,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
||||
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
|
||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) ||
|
||||
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
|
||||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
@@ -1230,11 +1265,6 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->italicUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving underline tag
|
||||
if (self->underlineUntilDepth == self->depth) {
|
||||
self->underlineUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Pop from inline style stack if we pushed an entry at this depth
|
||||
// This handles all inline elements: b, i, u, span, etc.
|
||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||
@@ -1261,7 +1291,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
}
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
|
||||
|
||||
bool ChapterHtmlSlimParser::beginParse() {
|
||||
// Initialize block style stack with a root entry representing "no ancestor block elements".
|
||||
// The user's paragraph alignment is set as the default so child elements without explicit
|
||||
// text-align inherit it correctly through getCombinedBlockStyle.
|
||||
@@ -1279,67 +1311,78 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
paragraphAlignmentBlockStyle.alignment = align;
|
||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
||||
|
||||
XML_Parser parser = XML_ParserCreate(nullptr);
|
||||
int done;
|
||||
|
||||
if (!parser) {
|
||||
xmlParser_ = XML_ParserCreate(nullptr);
|
||||
if (!xmlParser_) {
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
||||
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
||||
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
|
||||
XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand);
|
||||
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("EHP", filepath, file)) {
|
||||
destroyXmlParser(parser);
|
||||
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file size to decide whether to show indexing popup.
|
||||
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
||||
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
|
||||
popupFn();
|
||||
}
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
XML_SetUserData(xmlParser_, this);
|
||||
XML_SetElementHandler(xmlParser_, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(xmlParser_, characterData);
|
||||
|
||||
// Compute the time taken to parse and build pages
|
||||
const uint32_t chapterStartTime = millis();
|
||||
do {
|
||||
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
|
||||
if (!buf) {
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
parseStartTime_ = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
|
||||
ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() {
|
||||
void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE);
|
||||
if (!buf) {
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
|
||||
if (len == 0 && file.available() > 0) {
|
||||
LOG_ERR("EHP", "File read error");
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
|
||||
|
||||
done = file.available() == 0;
|
||||
if (len == 0 && parseFile_.available() > 0) {
|
||||
LOG_ERR("EHP", "File read error");
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
} while (!done);
|
||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
|
||||
const int done = parseFile_.available() == 0;
|
||||
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
if (XML_ParseBuffer(xmlParser_, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_),
|
||||
XML_ErrorString(XML_GetErrorCode(xmlParser_)));
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
|
||||
return done ? ParseStatus::Done : ParseStatus::More;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::abortParse() {
|
||||
if (xmlParser_) {
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
}
|
||||
// Only close the file if it was successfully opened in beginParse()
|
||||
if (parseFile_.isOpen()) {
|
||||
parseFile_.close();
|
||||
}
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::finishParse() {
|
||||
if (xmlParser_) {
|
||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_);
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
}
|
||||
parseFile_.close();
|
||||
|
||||
// Process last page if there is still text
|
||||
if (currentTextBlock) {
|
||||
@@ -1357,6 +1400,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
if (!beginParse()) {
|
||||
return false;
|
||||
}
|
||||
for (;;) {
|
||||
const ParseStatus status = parseStep();
|
||||
if (status == ParseStatus::Error) {
|
||||
abortParse();
|
||||
return false;
|
||||
}
|
||||
if (status == ParseStatus::Done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return finishParse();
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <climits>
|
||||
@@ -31,7 +32,6 @@ class ChapterHtmlSlimParser {
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
int italicUntilDepth = INT_MAX;
|
||||
int underlineUntilDepth = INT_MAX;
|
||||
// buffer for building up words from characters, will auto break if longer than this
|
||||
// leave one char at end for null pointer
|
||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||
@@ -60,7 +60,8 @@ class ChapterHtmlSlimParser {
|
||||
int depth = 0;
|
||||
bool hasBold = false, bold = false;
|
||||
bool hasItalic = false, italic = false;
|
||||
bool hasUnderline = false, underline = false;
|
||||
bool hasTextDecoration = false;
|
||||
CssTextDecoration textDecoration = CssTextDecoration::None;
|
||||
bool hasDirection = false;
|
||||
CssTextDirection direction = CssTextDirection::Ltr;
|
||||
bool hasSup = false, sup = false;
|
||||
@@ -71,7 +72,7 @@ class ChapterHtmlSlimParser {
|
||||
CssStyle currentCssStyle;
|
||||
bool effectiveBold = false;
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
|
||||
bool effectiveDirectionDefined = false;
|
||||
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
||||
bool effectiveSup = false;
|
||||
@@ -96,12 +97,25 @@ class ChapterHtmlSlimParser {
|
||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||
int wordsExtractedInBlock = 0;
|
||||
|
||||
// Resumable parse state. The one-shot parseAndBuildPages() drives these
|
||||
// internally; the incremental section builder drives them across render ticks
|
||||
// so a large single chapter can yield between pages instead of blocking the UI
|
||||
// until the whole thing is laid out. parseFile_ and the expat parser stay alive
|
||||
// for the lifetime of the parse so it can be paused and resumed at buffer
|
||||
// boundaries.
|
||||
XML_Parser xmlParser_ = nullptr;
|
||||
HalFile parseFile_;
|
||||
uint32_t parseStartTime_ = 0;
|
||||
|
||||
void updateEffectiveInlineStyle();
|
||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||
void flushPendingAnchor();
|
||||
void flushPartWordBuffer();
|
||||
void makePages();
|
||||
static EpdFontFamily::Style fontStyleForTextDecoration(CssTextDecoration decoration);
|
||||
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
|
||||
static void applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css);
|
||||
void pushDecorationStyleEntry(CssTextDecoration defaultDecoration, const CssStyle& cssStyle);
|
||||
void emitHorizontalRule(const BlockStyle& blockStyle);
|
||||
// XML callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
@@ -141,8 +155,28 @@ class ChapterHtmlSlimParser {
|
||||
imageBasePath(imageBasePath),
|
||||
tocAnchors(std::move(tocAnchors)) {}
|
||||
|
||||
~ChapterHtmlSlimParser() = default;
|
||||
~ChapterHtmlSlimParser();
|
||||
|
||||
// One-shot parse: builds every page before returning (begin + step* + finish).
|
||||
bool parseAndBuildPages();
|
||||
|
||||
// Resumable parse, for the incremental section builder. Drive as:
|
||||
// if (!beginParse()) fail;
|
||||
// loop: switch (parseStep()) { More: keep going / yield; Done: finishParse(); Error: abortParse(); }
|
||||
// Pages are emitted via completePageFn as they complete during parseStep(), so
|
||||
// the caller can stop once enough pages are built and resume on a later tick.
|
||||
enum class ParseStatus { More, Done, Error };
|
||||
bool beginParse();
|
||||
ParseStatus parseStep();
|
||||
bool finishParse(); // flush the trailing page and tear down; returns true
|
||||
void abortParse(); // tear down without flushing (error / abandon)
|
||||
|
||||
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
||||
|
||||
// Byte progress of the in-flight parse, used to estimate a still-building section's total page
|
||||
// count (a giant single-spine book never fully lays out, so its real count is unknown). Valid
|
||||
// between beginParse() and finishParse()/abortParse().
|
||||
size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; }
|
||||
size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; }
|
||||
};
|
||||
|
||||
+48
-40
@@ -79,6 +79,53 @@ std::string normalisePath(const std::string& path) {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool naturalLess(const std::string& str1, const std::string& str2) {
|
||||
// Naive natural sort: numeric-aware, case-insensitive
|
||||
const char* s1 = str1.c_str();
|
||||
const char* s2 = str2.c_str();
|
||||
|
||||
// ctype functions require unsigned char values: passing a negative char (UTF-8
|
||||
// bytes above 0x7f with signed char) is undefined behavior
|
||||
const auto isDigit = [](const char c) { return isdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
|
||||
// Iterate while both strings have characters
|
||||
while (*s1 && *s2) {
|
||||
// Check if both are at the start of a number
|
||||
if (isDigit(*s1) && isDigit(*s2)) {
|
||||
// Skip leading zeros and track them
|
||||
while (*s1 == '0') s1++;
|
||||
while (*s2 == '0') s2++;
|
||||
|
||||
// Count digits to compare lengths first
|
||||
int len1 = 0, len2 = 0;
|
||||
while (isDigit(s1[len1])) len1++;
|
||||
while (isDigit(s2[len2])) len2++;
|
||||
|
||||
// Different length so return smaller integer value
|
||||
if (len1 != len2) return len1 < len2;
|
||||
|
||||
// Same length so compare digit by digit
|
||||
for (int i = 0; i < len1; i++) {
|
||||
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
||||
}
|
||||
|
||||
// Numbers equal so advance pointers
|
||||
s1 += len1;
|
||||
s2 += len2;
|
||||
} else {
|
||||
// Regular case-insensitive character comparison
|
||||
const int c1 = tolower(static_cast<unsigned char>(*s1));
|
||||
const int c2 = tolower(static_cast<unsigned char>(*s2));
|
||||
if (c1 != c2) return c1 < c2;
|
||||
s1++;
|
||||
s2++;
|
||||
}
|
||||
}
|
||||
|
||||
// One string is prefix of other
|
||||
return *s1 == '\0' && *s2 != '\0';
|
||||
}
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
// Directories first
|
||||
@@ -86,46 +133,7 @@ void sortFileList(std::vector<std::string>& strs) {
|
||||
bool isDir2 = str2.back() == '/';
|
||||
if (isDir1 != isDir2) return isDir1;
|
||||
|
||||
// Start naive natural sort
|
||||
const char* s1 = str1.c_str();
|
||||
const char* s2 = str2.c_str();
|
||||
|
||||
// Iterate while both strings have characters
|
||||
while (*s1 && *s2) {
|
||||
// Check if both are at the start of a number
|
||||
if (isdigit(*s1) && isdigit(*s2)) {
|
||||
// Skip leading zeros and track them
|
||||
while (*s1 == '0') s1++;
|
||||
while (*s2 == '0') s2++;
|
||||
|
||||
// Count digits to compare lengths first
|
||||
int len1 = 0, len2 = 0;
|
||||
while (isdigit(s1[len1])) len1++;
|
||||
while (isdigit(s2[len2])) len2++;
|
||||
|
||||
// Different length so return smaller integer value
|
||||
if (len1 != len2) return len1 < len2;
|
||||
|
||||
// Same length so compare digit by digit
|
||||
for (int i = 0; i < len1; i++) {
|
||||
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
||||
}
|
||||
|
||||
// Numbers equal so advance pointers
|
||||
s1 += len1;
|
||||
s2 += len2;
|
||||
} else {
|
||||
// Regular case-insensitive character comparison
|
||||
char c1 = tolower(*s1);
|
||||
char c2 = tolower(*s2);
|
||||
if (c1 != c2) return c1 < c2;
|
||||
s1++;
|
||||
s2++;
|
||||
}
|
||||
}
|
||||
|
||||
// One string is prefix of other
|
||||
return *s1 == '\0' && *s2 != '\0';
|
||||
return naturalLess(str1, str2);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path);
|
||||
|
||||
std::string normalisePath(const std::string& path);
|
||||
|
||||
// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders
|
||||
// before str2. Same ordering sortFileList applies within the file/directory groups.
|
||||
bool naturalLess(const std::string& str1, const std::string& str2);
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs);
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,14 @@ void FontCacheManager::resetStats() {
|
||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||
|
||||
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
||||
scanText_ += text;
|
||||
if (!text) return;
|
||||
const size_t remaining = (scanTextLen_ < SCAN_TEXT_CAPACITY - 1) ? (SCAN_TEXT_CAPACITY - 1 - scanTextLen_) : 0;
|
||||
if (remaining > 0) {
|
||||
const size_t textLen = strnlen(text, remaining);
|
||||
memcpy(scanText_ + scanTextLen_, text, textLen);
|
||||
scanTextLen_ += textLen;
|
||||
scanText_[scanTextLen_] = '\0';
|
||||
}
|
||||
if (scanFontId_ < 0) scanFontId_ = fontId;
|
||||
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
||||
@@ -80,15 +87,15 @@ FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manage
|
||||
manager_->scanMode_ = ScanMode::Scanning;
|
||||
manager_->clearCache();
|
||||
manager_->resetStats();
|
||||
manager_->scanText_.clear();
|
||||
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
|
||||
manager_->scanTextLen_ = 0;
|
||||
manager_->scanText_[0] = '\0';
|
||||
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
||||
manager_->scanFontId_ = -1;
|
||||
}
|
||||
|
||||
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||
manager_->scanMode_ = ScanMode::None;
|
||||
if (manager_->scanText_.empty()) return;
|
||||
if (manager_->scanTextLen_ == 0) return;
|
||||
|
||||
// Build style bitmask from all styles that appeared during the scan
|
||||
uint8_t styleMask = 0;
|
||||
@@ -97,11 +104,10 @@ void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||
}
|
||||
if (styleMask == 0) styleMask = 1; // default to regular
|
||||
|
||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
|
||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_, styleMask);
|
||||
|
||||
// Free scan string memory
|
||||
manager_->scanText_.clear();
|
||||
manager_->scanText_.shrink_to_fit();
|
||||
manager_->scanTextLen_ = 0;
|
||||
manager_->scanText_[0] = '\0';
|
||||
}
|
||||
|
||||
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class FontDecompressor;
|
||||
class SdCardFont;
|
||||
@@ -51,7 +51,9 @@ class FontCacheManager {
|
||||
|
||||
enum class ScanMode : uint8_t { None, Scanning };
|
||||
ScanMode scanMode_ = ScanMode::None;
|
||||
std::string scanText_;
|
||||
static constexpr size_t SCAN_TEXT_CAPACITY = 2048;
|
||||
char scanText_[SCAN_TEXT_CAPACITY] = {};
|
||||
size_t scanTextLen_ = 0;
|
||||
uint32_t scanStyleCounts_[4] = {};
|
||||
int scanFontId_ = -1;
|
||||
};
|
||||
|
||||
@@ -91,6 +91,20 @@ void GfxRenderer::begin() {
|
||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||
}
|
||||
|
||||
void GfxRenderer::releaseFrameBufferForBuild() {
|
||||
display.releaseFrameBuffers();
|
||||
frameBuffer = nullptr;
|
||||
}
|
||||
|
||||
bool GfxRenderer::restoreFrameBufferAfterBuild() {
|
||||
if (!display.reallocFrameBuffers()) {
|
||||
LOG_ERR("GFX", "Framebuffer realloc failed after build");
|
||||
return false;
|
||||
}
|
||||
frameBuffer = display.getFrameBuffer();
|
||||
return frameBuffer != nullptr;
|
||||
}
|
||||
|
||||
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
@@ -1054,8 +1068,24 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
||||
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
||||
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
|
||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int size) const {
|
||||
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
|
||||
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
|
||||
// icon's position to 8px (one byte) along the rotated axis, which prevents it
|
||||
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
|
||||
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
|
||||
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
|
||||
// only called by the UI themes, which all render in forced Portrait.
|
||||
const int rowBytes = (size + 7) / 8;
|
||||
for (int row = 0; row < size; row++) {
|
||||
for (int col = 0; col < size; col++) {
|
||||
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
|
||||
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
|
||||
if (ink) {
|
||||
drawPixel(x + (size - 1 - row), y + col, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||
|
||||
@@ -183,7 +183,7 @@ class GfxRenderer {
|
||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
||||
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawIcon(const uint8_t bitmap[], int x, int y, int size) const;
|
||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||
float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
@@ -250,6 +250,13 @@ class GfxRenderer {
|
||||
// Font helpers
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
|
||||
// Lend the framebuffer to memory-hungry phases such as section pagination.
|
||||
// Nothing may draw/display while it is released. restore returns the buffer
|
||||
// white, so callers must redraw the full screen afterward.
|
||||
void releaseFrameBufferForBuild();
|
||||
bool restoreFrameBufferAfterBuild();
|
||||
bool hasFrameBuffer() const { return frameBuffer != nullptr; }
|
||||
|
||||
// Low level functions
|
||||
uint8_t* getFrameBuffer() const;
|
||||
size_t getBufferSize() const;
|
||||
|
||||
@@ -236,7 +236,6 @@ STR_CHAPTER_PREFIX: "Раздзел:"
|
||||
STR_PAGES_SEPARATOR: "стар. |"
|
||||
STR_BOOK_PREFIX: "Кніга:"
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
||||
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
|
||||
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
||||
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
||||
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
||||
@@ -296,7 +295,8 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
|
||||
STR_LINK: "[спасылка]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||
STR_SLEEP_NEVER: "Ніколі"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
|
||||
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
||||
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||
|
||||
@@ -272,7 +272,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
||||
STR_PAGES_SEPARATOR: " pàgines | "
|
||||
STR_BOOK_PREFIX: "Llibre: "
|
||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||
@@ -305,7 +304,8 @@ STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
||||
STR_LINK: "[enllaç]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
||||
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
||||
|
||||
@@ -58,7 +58,7 @@ STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
|
||||
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
||||
STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čtečka"
|
||||
STR_CAT_CONTROLS: "Ovládací prvky"
|
||||
STR_CAT_CONTROLS: "Ovládání"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||
@@ -243,7 +243,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "stránek |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
|
||||
STR_PERCENT_STEP_HINT: "Vlevo/Vpravo: 1 % Nahoru/Dolů: 10 %"
|
||||
STR_SYNCING_TIME: "Čas synchronizace..."
|
||||
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
||||
@@ -273,5 +272,6 @@ STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Přední tlačítka:"
|
||||
STR_STEP_HINT_SIDE: "Boční tlačítka:"
|
||||
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
||||
|
||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Kapitel: "
|
||||
STR_PAGES_SEPARATOR: " sider | "
|
||||
STR_BOOK_PREFIX: "Bog: "
|
||||
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
|
||||
STR_PERCENT_STEP_HINT: "Venstre/Højre: 1% Op/Ned: 10%"
|
||||
STR_SYNCING_TIME: "Synkroniserer tid..."
|
||||
STR_CALC_HASH: "Beregner dokument-hash..."
|
||||
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Aldrig"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Frontknapper:"
|
||||
STR_STEP_HINT_SIDE: "Sideknapper:"
|
||||
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||
|
||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Hoofdstuk: "
|
||||
STR_PAGES_SEPARATOR: " pagina's | "
|
||||
STR_BOOK_PREFIX: "Boek: "
|
||||
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
|
||||
STR_PERCENT_STEP_HINT: "Links/Rechts: 1% Omhoog/Omlaag: 10%"
|
||||
STR_SYNCING_TIME: "Tijd synchroniseren..."
|
||||
STR_CALC_HASH: "Document-hash berekenen..."
|
||||
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nooit"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
||||
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||
|
||||
@@ -18,6 +18,7 @@ STR_NO_CHAPTERS: "No chapters"
|
||||
STR_END_OF_BOOK: "End of book"
|
||||
STR_EMPTY_CHAPTER: "Empty chapter"
|
||||
STR_INDEXING: "Indexing"
|
||||
STR_INDEX_FAILED: "Failed to index - invalid book"
|
||||
STR_MEMORY_ERROR: "Memory error"
|
||||
STR_PAGE_LOAD_ERROR: "Page load error"
|
||||
STR_EMPTY_FILE: "Empty file"
|
||||
@@ -70,6 +71,8 @@ STR_IMAGES: "Images"
|
||||
STR_IMAGES_DISPLAY: "Display"
|
||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||
STR_IMAGES_SUPPRESS: "Suppress"
|
||||
STR_EOB_HOME: "Home"
|
||||
STR_EOB_CONTINUE_WITH: "Continue with"
|
||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||
STR_ORIENTATION: "Reading Orientation"
|
||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||
@@ -286,6 +289,25 @@ STR_HW_BACK_LABEL: "Back (1st button)"
|
||||
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
|
||||
STR_HW_LEFT_LABEL: "Left (3rd button)"
|
||||
STR_HW_RIGHT_LABEL: "Right (4th button)"
|
||||
STR_BLUETOOTH: "Bluetooth"
|
||||
STR_TOGGLE_BLUETOOTH: "Toggle Bluetooth"
|
||||
STR_BT_SCAN_PAIR: "Scan & Pair"
|
||||
STR_BT_NO_DEVICES: "No devices found"
|
||||
STR_BT_FREE_HINT1: "Set Free2/3 to Reader Mode and"
|
||||
STR_BT_FREE_HINT2: "Volume Function to pair"
|
||||
STR_BT_DISCONNECT: "Disconnect"
|
||||
STR_BT_CONNECTED_TO: "Connected: %s"
|
||||
STR_BT_NOT_CONNECTED: "Not connected"
|
||||
STR_BT_PAIRED_DEVICES: "Paired Devices"
|
||||
STR_BT_NO_PAIRED: "No paired devices"
|
||||
STR_BT_MAP_BUTTONS: "Map Remote Buttons"
|
||||
STR_BT_PRESS_REMOTE: "Press a button on your remote"
|
||||
STR_BT_CONNECTING_POPUP: "BT Connecting..."
|
||||
STR_BT_PAUSED_LOW_MEM_POPUP: "BT paused (low memory)"
|
||||
STR_STATE_PAUSED: "PAUSED"
|
||||
STR_BT_PAGE_FORWARD: "Page Forward"
|
||||
STR_BT_PAGE_BACK: "Page Back"
|
||||
STR_BT_FORGET_PROMPT: "Hold Confirm to forget"
|
||||
STR_GO_TO_PERCENT: "Go to %"
|
||||
STR_GO_HOME_BUTTON: "Go Home"
|
||||
STR_SYNC_PROGRESS: "Sync Progress"
|
||||
@@ -297,7 +319,6 @@ STR_CHAPTER_PREFIX: "Chapter: "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
STR_BOOK_PREFIX: "Book: "
|
||||
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
|
||||
STR_PERCENT_STEP_HINT: "Left/Right: 1% Up/Down: 10%"
|
||||
STR_SYNCING_TIME: "Syncing time..."
|
||||
STR_CALC_HASH: "Calculating document hash..."
|
||||
STR_HASH_FAILED: "Failed to calculate document hash"
|
||||
@@ -332,7 +353,8 @@ STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Never"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Front buttons:"
|
||||
STR_STEP_HINT_SIDE: "Side buttons:"
|
||||
STR_ADD_SERVER: "Add Server"
|
||||
STR_SERVER_NAME: "Server Name"
|
||||
STR_NO_SERVERS: "No OPDS servers configured"
|
||||
|
||||
@@ -241,7 +241,6 @@ STR_CHAPTER_PREFIX: "Luku: "
|
||||
STR_PAGES_SEPARATOR: " sivua | "
|
||||
STR_BOOK_PREFIX: "Kirja: "
|
||||
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
|
||||
STR_PERCENT_STEP_HINT: "Vasen/Oikea: 1% Ylös/Alas: 10%"
|
||||
STR_SYNCING_TIME: "Synkronoidaan aikaa..."
|
||||
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
||||
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
||||
@@ -271,5 +270,6 @@ STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
||||
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Ei koskaan"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Etupainikkeet:"
|
||||
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
|
||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||
|
||||
@@ -267,7 +267,6 @@ STR_CHAPTER_PREFIX: "Chapitre : "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
STR_BOOK_PREFIX: "Livre : "
|
||||
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
||||
STR_PERCENT_STEP_HINT: "Gauche/Droite : 1% Haut/Bas : 10%"
|
||||
STR_SYNCING_TIME: "Synchro de l’heure…"
|
||||
STR_CALC_HASH: "Calcul hash doc…"
|
||||
STR_HASH_FAILED: "Échec calcul hash"
|
||||
@@ -299,7 +298,8 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
|
||||
STR_LINK: "[lien]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Jamais"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
|
||||
STR_STEP_HINT_FRONT: "Boutons avant :"
|
||||
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
||||
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||
|
||||
@@ -288,7 +288,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
||||
STR_PAGES_SEPARATOR: " Seiten | "
|
||||
STR_BOOK_PREFIX: "Buch: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
|
||||
STR_PERCENT_STEP_HINT: "links/rechts: 1% hoch/runter: 10%"
|
||||
STR_SYNCING_TIME: "Zeit synchronisieren…"
|
||||
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
||||
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
||||
@@ -322,7 +321,8 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
|
||||
STR_LINK: "[Link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
||||
STR_SLEEP_NEVER: "Nie"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
|
||||
STR_STEP_HINT_FRONT: "Vordere Tasten:"
|
||||
STR_STEP_HINT_SIDE: "Seitentasten:"
|
||||
STR_ADD_SERVER: "Server hinzufügen"
|
||||
STR_SERVER_NAME: "Servername"
|
||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||
|
||||
@@ -268,7 +268,6 @@ STR_CHAPTER_PREFIX: "פרק: "
|
||||
STR_PAGES_SEPARATOR: " דפים | "
|
||||
STR_BOOK_PREFIX: "ספר: "
|
||||
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
||||
STR_PERCENT_STEP_HINT: "שמאל/ימין: 1% למעלה/למטה: 10%"
|
||||
STR_SYNCING_TIME: "מסנכרן שעון..."
|
||||
STR_CALC_HASH: "מחשב האש..."
|
||||
STR_HASH_FAILED: "חישוב האש נכשל"
|
||||
@@ -390,4 +389,5 @@ STR_BOOKMARK_OPTION: "סימנייה"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
||||
STR_SLEEP_NEVER: "אף פעם"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
|
||||
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
|
||||
STR_STEP_HINT_SIDE: "לחצני צד:"
|
||||
|
||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Fejezet: "
|
||||
STR_PAGES_SEPARATOR: " oldal | "
|
||||
STR_BOOK_PREFIX: "Könyv: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
|
||||
STR_PERCENT_STEP_HINT: "Bal/Jobb: 1% Fel/Le: 10%"
|
||||
STR_SYNCING_TIME: "Idő szinkronizálása..."
|
||||
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
||||
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
||||
STR_SLEEP_NEVER: "Soha"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
|
||||
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
||||
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
||||
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||
|
||||
@@ -274,7 +274,6 @@ STR_CHAPTER_PREFIX: "Capitolo: "
|
||||
STR_PAGES_SEPARATOR: " pagine | "
|
||||
STR_BOOK_PREFIX: "Libro: "
|
||||
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
|
||||
STR_PERCENT_STEP_HINT: "Sinistra/Destra: 1% Su/Giù: 10%"
|
||||
STR_SYNCING_TIME: "Sincronizzazione orario..."
|
||||
STR_CALC_HASH: "Calcolo hash documento..."
|
||||
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
||||
@@ -307,7 +306,8 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
|
||||
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot"
|
||||
STR_ADD_SERVER: "Aggiungi server"
|
||||
STR_SERVER_NAME: "Nome server"
|
||||
@@ -383,3 +383,6 @@ STR_DISABLED: "Disattivato"
|
||||
STR_BOOKMARK_OPTION: "Segnalibro"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
|
||||
STR_EOB_CONTINUE_WITH: "Continua con"
|
||||
STR_EOB_HOME: "Home"
|
||||
STR_INDEX_FAILED: "Indicizzazione fallita - libro non valido"
|
||||
@@ -237,7 +237,6 @@ STR_CHAPTER_PREFIX: "Тарау: "
|
||||
STR_PAGES_SEPARATOR: " бет | "
|
||||
STR_BOOK_PREFIX: "Кітап: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
||||
STR_PERCENT_STEP_HINT: "Сол/Оң: 1% Жоғары/Төмен: 10%"
|
||||
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
||||
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
||||
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
||||
@@ -294,7 +293,8 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
|
||||
STR_LINK: "[сілтеме]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||
STR_SLEEP_NEVER: "Ешқашан"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
|
||||
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
||||
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
||||
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||
|
||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Sk: "
|
||||
STR_PAGES_SEPARATOR: " psl. | "
|
||||
STR_BOOK_PREFIX: "Kn: "
|
||||
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
|
||||
STR_PERCENT_STEP_HINT: "K/D: 1% | V/A: 10%"
|
||||
STR_SYNCING_TIME: "Sinchr. laikas..."
|
||||
STR_CALC_HASH: "Skaičiuojama maiša..."
|
||||
STR_HASH_FAILED: "Maišos klaida"
|
||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
|
||||
STR_LINK: "[nuoroda]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||
STR_SLEEP_NEVER: "Niekada"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
|
||||
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
||||
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
||||
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||
|
||||
@@ -275,7 +275,6 @@ STR_CHAPTER_PREFIX: "Rozdział: "
|
||||
STR_PAGES_SEPARATOR: " stron | "
|
||||
STR_BOOK_PREFIX: "Książka: "
|
||||
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
|
||||
STR_PERCENT_STEP_HINT: "Lewo/Prawo: 1% Góra/Dół: 10%"
|
||||
STR_SYNCING_TIME: "Synchronizacja czasu..."
|
||||
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
||||
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
||||
@@ -308,7 +307,8 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nigdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Przyciski przednie:"
|
||||
STR_STEP_HINT_SIDE: "Przyciski boczne:"
|
||||
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
||||
STR_ADD_SERVER: "Dodaj serwer"
|
||||
STR_SERVER_NAME: "Nazwa serwera"
|
||||
|
||||
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
|
||||
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
||||
STR_INDEXING: "Indexando"
|
||||
STR_MEMORY_ERROR: "Erro de memória"
|
||||
STR_PAGE_LOAD_ERROR: "Erro página"
|
||||
STR_PAGE_LOAD_ERROR: "Erro ao carregar pág."
|
||||
STR_EMPTY_FILE: "Arquivo vazio"
|
||||
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
||||
STR_LOADING: "Carregando..."
|
||||
@@ -32,8 +32,8 @@ STR_CONNECTING: "Conectando..."
|
||||
STR_CONNECTED: "Conectado!"
|
||||
STR_CONNECTION_FAILED: "Falha na conexão"
|
||||
STR_FORGET_NETWORK: "Esquecer rede?"
|
||||
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
|
||||
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
|
||||
STR_SAVE_PASSWORD: "Salvar senha na próxima vez?"
|
||||
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
|
||||
STR_JOIN_NETWORK: "Entrar em uma rede"
|
||||
STR_CREATE_HOTSPOT: "Criar hotspot"
|
||||
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
|
||||
@@ -41,7 +41,7 @@ STR_HOTSPOT_DESC: "Crie uma rede Wi-Fi para outras pessoas entrarem"
|
||||
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
|
||||
STR_HOTSPOT_MODE: "Modo hotspot"
|
||||
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
||||
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
|
||||
STR_OPEN_URL_HINT: "Abra esta URL no seu navegador"
|
||||
STR_OR_HTTP_PREFIX: "ou http://"
|
||||
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
||||
@@ -50,30 +50,36 @@ STR_MAC_ADDRESS: "Endereço MAC:"
|
||||
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
||||
STR_TO_PREFIX: "para"
|
||||
STR_CALIBRE_RECEIVING: "Recebendo:"
|
||||
STR_CALIBRE_RECEIVED: "Recebido:"
|
||||
STR_CALIBRE_RECEIVING: "Recebendo: "
|
||||
STR_CALIBRE_RECEIVED: "Recebido: "
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar ao dispositivo\""
|
||||
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
||||
STR_CAT_DISPLAY: "Tela"
|
||||
STR_CAT_READER: "Leitor"
|
||||
STR_CAT_CONTROLS: "Controles"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
||||
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
||||
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
||||
STR_TEXT_AA: "Suavização de texto"
|
||||
STR_IMAGES: "Imagens"
|
||||
STR_IMAGES_DISPLAY: "Exibição"
|
||||
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
|
||||
STR_IMAGES_SUPPRESS: "Ocultar"
|
||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
||||
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
|
||||
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
|
||||
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
|
||||
STR_FONT_FAMILY: "Fonte do leitor"
|
||||
STR_FONT_SIZE: "Tam. fonte UI"
|
||||
STR_LINE_SPACING: "Espaçamento entre linhas"
|
||||
@@ -81,6 +87,7 @@ STR_SCREEN_MARGIN: "Margens da tela"
|
||||
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
||||
STR_HYPHENATION: "Hifenização"
|
||||
STR_TIME_TO_SLEEP: "Tempo para repousar"
|
||||
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
|
||||
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
||||
STR_REFRESH_FREQ: "Frequência atualização"
|
||||
@@ -128,6 +135,7 @@ STR_ALWAYS: "Sempre"
|
||||
STR_IGNORE: "Ignorar"
|
||||
STR_SLEEP: "Repouso"
|
||||
STR_PAGE_TURN: "Virar página"
|
||||
STR_FORCE_REFRESH: "Atualizar tela"
|
||||
STR_PORTRAIT: "Retrato"
|
||||
STR_LANDSCAPE_CW: "Paisagem H"
|
||||
STR_INVERTED: "Invertido"
|
||||
@@ -135,6 +143,9 @@ STR_ORIENTATION_INVERTED: "Retrato 180°"
|
||||
STR_LANDSCAPE_CCW: "Paisagem AH"
|
||||
STR_PREV_NEXT: "Ant/Próx"
|
||||
STR_NEXT_PREV: "Próx/Ant"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Marcador"
|
||||
STR_DISABLED: "Desabilitado"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Pequeno"
|
||||
@@ -162,17 +173,21 @@ STR_UPDATING: "Atualizando..."
|
||||
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
||||
STR_UPDATE_FAILED: "Falha na atualização"
|
||||
STR_UPDATE_COMPLETE: "Atualização concluída"
|
||||
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
|
||||
STR_NO_ENTRIES: "Nenhum entries encontrado"
|
||||
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
|
||||
STR_RESTARTING_HINT: "Reiniciando... Se não reiniciar, pressione e segure o botão de energia por alguns segundos."
|
||||
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
|
||||
STR_DOWNLOADING: "Baixando..."
|
||||
STR_DOWNLOAD_FAILED: "Falha no download"
|
||||
STR_ERROR_MSG: "Erro:"
|
||||
STR_UNNAMED: "Sem nome"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir"
|
||||
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
||||
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
||||
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
||||
STR_NETWORK_PREFIX: "Rede:"
|
||||
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
|
||||
STR_NEXT_PAGE: "Próxima Página »"
|
||||
STR_PREV_PAGE: "« Página Anterior"
|
||||
STR_NETWORK_PREFIX: "Rede: "
|
||||
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
||||
@@ -181,10 +196,9 @@ STR_BACK: "« Voltar"
|
||||
STR_EXIT: "« Sair"
|
||||
STR_HOME: "« Início"
|
||||
STR_SELECT: "Escolher"
|
||||
STR_SELECTED: "Selecionado"
|
||||
STR_TOGGLE: "Alternar"
|
||||
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||
STR_BOOKMARK_REMOVED: "Marcador removido."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
|
||||
STR_CONFIRM: "Confirmar"
|
||||
STR_CANCEL: "Cancelar"
|
||||
STR_CONNECT: "Conectar"
|
||||
@@ -193,6 +207,8 @@ STR_DOWNLOAD: "Baixar"
|
||||
STR_RETRY: "Tentar novamente"
|
||||
STR_YES: "Sim"
|
||||
STR_NO: "Não"
|
||||
STR_SHOW: "Mostrar"
|
||||
STR_HIDE: "Ocultar"
|
||||
STR_STATE_ON: "LIG."
|
||||
STR_STATE_OFF: "DESL."
|
||||
STR_NOT_SET: "Não definido"
|
||||
@@ -203,15 +219,51 @@ STR_DIR_DOWN: "Baixo"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
||||
STR_FILTER_CONTRAST: "Contraste"
|
||||
STR_UI_THEME: "Tema da interface"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Customizar Barra de Status"
|
||||
STR_CHAPTER_PAGE_COUNT: "Páginas do Capítulo"
|
||||
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de Progresso"
|
||||
STR_PROGRESS_BAR: "Barra de Progresso"
|
||||
STR_PROGRESS_BAR_THICKNESS: "Espessura da Barra de Progresso"
|
||||
STR_PROGRESS_BAR_THIN: "Fino"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Médio"
|
||||
STR_PROGRESS_BAR_THICK: "Grosso"
|
||||
STR_BOOK: "Livro"
|
||||
STR_CHAPTER: "Capítulo"
|
||||
STR_EXAMPLE_CHAPTER: "Capítulo 21"
|
||||
STR_EXAMPLE_BOOK: "Título do Livro"
|
||||
STR_PREVIEW: "Prévia"
|
||||
STR_TITLE: "Título"
|
||||
STR_BATTERY: "Bateria"
|
||||
STR_XTC_STATUS_BAR: "Barra de Status XTC"
|
||||
STR_BOTTOM: "Baixo"
|
||||
STR_TOP: "Topo"
|
||||
STR_CLOCK: "Relógio"
|
||||
STR_CLOCK_UTC_OFFSET: "Offset UTC do Relógio"
|
||||
STR_CLOCK_FORMAT: "Formato do Relógio"
|
||||
STR_CLOCK_FORMAT_24H: "24 horas"
|
||||
STR_CLOCK_FORMAT_12H: "12 horas"
|
||||
STR_CURRENT_TIME: "Hora atual:"
|
||||
STR_NEXT_FIELD: "Próximo"
|
||||
STR_CLOCK_SYNC: "Sincronizar Relógio"
|
||||
STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora"
|
||||
STR_CLOCK_SYNCING: "Sincronizando via NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Relógio sincronizado"
|
||||
STR_CLOCK_SYNC_FAIL: "Falha na sincronização"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi não conectado"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Conecte ao Wi-Fi primeiro, depois tente novamente."
|
||||
STR_CLOCK_SYNCED: "Relógio sincronizado"
|
||||
STR_UI_THEME: "Tema"
|
||||
STR_THEME_CLASSIC: "Clássico"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
|
||||
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais"
|
||||
STR_BOOKMARKS: "Marcadores"
|
||||
STR_BOOKMARK_ADDED: "Marcador adicionado."
|
||||
STR_BOOKMARK_REMOVED: "Marcador removido."
|
||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||
STR_SEARCH: "Busca"
|
||||
STR_COVER_CUSTOM: "Capa + personalizado"
|
||||
STR_QUICK_RESUME: "Retomada rápida"
|
||||
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||
@@ -225,11 +277,11 @@ STR_CALIBRE_SETUP: "Configuração"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
STR_CLEAR_BUTTON: "Limpar"
|
||||
STR_DEFAULT_VALUE: "Padrão"
|
||||
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
|
||||
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
|
||||
STR_UNASSIGNED: "Não atribuído"
|
||||
STR_ALREADY_ASSIGNED: "Já atribuído"
|
||||
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
|
||||
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
|
||||
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir layout padrão"
|
||||
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar remapeamento"
|
||||
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
||||
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
||||
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
||||
@@ -239,14 +291,15 @@ STR_GO_HOME_BUTTON: "Ir para o início"
|
||||
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
||||
STR_DELETE_CACHE: "Excluir cache do livro"
|
||||
STR_DELETE: "Excluir"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?"
|
||||
STR_DISPLAY_QR: "Mostrar página como QR"
|
||||
STR_CHAPTER_PREFIX: "Capítulo:"
|
||||
STR_PAGES_SEPARATOR: "páginas |"
|
||||
STR_BOOK_PREFIX: "Livro:"
|
||||
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
|
||||
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
|
||||
STR_SYNCING_TIME: "Sincronizando horário..."
|
||||
STR_CALC_HASH: "Calculando hash documento..."
|
||||
STR_HASH_FAILED: "Falha ao calcular o hash documento"
|
||||
STR_CALC_HASH: "Calculando hash do documento..."
|
||||
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
|
||||
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
||||
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
||||
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
||||
@@ -264,14 +317,69 @@ STR_UPLOAD_PROMPT: "Enviar posição atual?"
|
||||
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
||||
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
||||
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
|
||||
STR_SECTION_PREFIX: "Seção"
|
||||
STR_SECTION_PREFIX: "Seção "
|
||||
STR_UPLOAD: "Enviar"
|
||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||
STR_FOCUS_READING: "Leitura focada"
|
||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido de notas de rodapé"
|
||||
STR_SET_SLEEP_COVER: "Definir capa"
|
||||
STR_FOOTNOTES: "Notas de rodapé"
|
||||
STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Capturar tela"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nunca"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botões frontais:"
|
||||
STR_STEP_HINT_SIDE: "Botões laterais:"
|
||||
STR_ADD_SERVER: "Adicionar Servidor"
|
||||
STR_SERVER_NAME: "Nome do Servidor"
|
||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||
STR_DELETE_SERVER: "Excluir Servidor"
|
||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
||||
STR_MANAGE_FONTS: "Gerenciar fontes"
|
||||
STR_FONT_BROWSER: "Navegador de fontes"
|
||||
STR_LOADING_FONT_LIST: "Carregando lista de fontes..."
|
||||
STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível"
|
||||
STR_FONT_INSTALLED: "Fonte instalada!"
|
||||
STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte"
|
||||
STR_INSTALLED: "Instalada"
|
||||
STR_DOWNLOAD_ALL: "Baixar tudo"
|
||||
STR_UPDATE_ALL: "Atualizar tudo"
|
||||
STR_UPDATE_AVAILABLE: "Atualizar"
|
||||
STR_CRASH_TITLE: "Falha do sistema"
|
||||
STR_CRASH_DESCRIPTION: "Um relatório detalhado foi salvo em crash_report.txt. Por favor, inclua este arquivo no seu relatório de erro."
|
||||
STR_CRASH_REASON: "Motivo da falha:"
|
||||
STR_CRASH_NO_REASON: "(Nenhum motivo registrado)"
|
||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Pressione ESQ ou DIR para mover o cursor"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Pressione ESQ para retornar à posição do cursor"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Segure DIR e pressione [***] para ocultar a senha"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Segure DIR e pressione [abc] para exibir a senha"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pressione [***] para ocultar a senha"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pressione [abc] para exibir a senha"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Segure CIMA para editar a entrada"
|
||||
STR_KB_TIPS: "Dicas:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Pressione BAIXO para retornar ao teclado"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Pressione ABC para sair do modo URL"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Segure DEL para limpar todo o texto"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Segure SELECT para caractere secundário"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Segure SELECT para MAIÚSCULO ou caractere secundário"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Segure SELECT para minúsculo ou caractere secundário"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Pressione URL para atalhos"
|
||||
STR_SD_FIRMWARE_UPDATE: "Atualização de firmware via cartão SD"
|
||||
STR_SELECT_FIRMWARE_FILE: "Selecione o arquivo de firmware (.bin)"
|
||||
STR_NO_BIN_FILES: "Nenhum arquivo .bin encontrado"
|
||||
STR_VALIDATING_FIRMWARE: "Validando firmware..."
|
||||
STR_INVALID_FIRMWARE: "Arquivo de firmware inválido"
|
||||
STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição"
|
||||
STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais"
|
||||
STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?"
|
||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo"
|
||||
STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
|
||||
STR_RECOVERY_MODE: "Modo de Recuperação"
|
||||
STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o"
|
||||
|
||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Capitol: "
|
||||
STR_PAGES_SEPARATOR: " pagini | "
|
||||
STR_BOOK_PREFIX: "Carte: "
|
||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
||||
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
|
||||
STR_SYNCING_TIME: "Timp de sincronizare..."
|
||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Niciodată"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
||||
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
||||
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||
|
||||
@@ -298,7 +298,6 @@ STR_CHAPTER_PREFIX: "Глава: "
|
||||
STR_PAGES_SEPARATOR: " стр. | "
|
||||
STR_BOOK_PREFIX: "Книга: "
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
||||
STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%"
|
||||
STR_SYNCING_TIME: "Синхронизация времени..."
|
||||
STR_CALC_HASH: "Расчёт хэша документа..."
|
||||
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
||||
@@ -332,7 +331,8 @@ STR_LINK: "[ссылка]"
|
||||
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||
STR_SLEEP_NEVER: "Никогда"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
|
||||
STR_STEP_HINT_FRONT: "Передние кнопки:"
|
||||
STR_STEP_HINT_SIDE: "Боковые кнопки:"
|
||||
STR_ADD_SERVER: "Добавить сервер"
|
||||
STR_SERVER_NAME: "Имя сервера"
|
||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||
|
||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "strán |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
|
||||
STR_PERCENT_STEP_HINT: "Vľavo/Vpravo: 1 % Hore/Dole: 10 %"
|
||||
STR_SYNCING_TIME: "Čas synchronizácie..."
|
||||
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||
@@ -328,7 +327,8 @@ STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
|
||||
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
|
||||
STR_ADD_SERVER: "Pridať server"
|
||||
STR_SERVER_NAME: "Názov servera"
|
||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||
|
||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Poglavje: "
|
||||
STR_PAGES_SEPARATOR: " strani | "
|
||||
STR_BOOK_PREFIX: "Knjiga: "
|
||||
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
|
||||
STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%"
|
||||
STR_SYNCING_TIME: "Sinhronizacija časa..."
|
||||
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
||||
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
|
||||
STR_LINK: "[povezava]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikoli"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
||||
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
||||
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||
|
||||
@@ -297,7 +297,6 @@ STR_CHAPTER_PREFIX: "Cap.: "
|
||||
STR_PAGES_SEPARATOR: " pág. | "
|
||||
STR_BOOK_PREFIX: "Libro: "
|
||||
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
|
||||
STR_PERCENT_STEP_HINT: "Izq./Dcha.: 1% | Subir/Bajar: 10%"
|
||||
STR_SYNCING_TIME: "Tiempo de sincronización..."
|
||||
STR_CALC_HASH: "Calculando hash del documento..."
|
||||
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
||||
@@ -331,7 +330,8 @@ STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
||||
STR_LINK: "[enlace]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||
STR_SLEEP_NEVER: "Nunca"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
|
||||
STR_STEP_HINT_FRONT: "Botones frontales:"
|
||||
STR_STEP_HINT_SIDE: "Botones laterales:"
|
||||
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||
STR_ADD_SERVER: "Agregar servidor"
|
||||
STR_SERVER_NAME: "Nombre de servidor"
|
||||
|
||||
@@ -295,7 +295,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
||||
STR_PAGES_SEPARATOR: " sidor | "
|
||||
STR_BOOK_PREFIX: "Bok:"
|
||||
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
|
||||
STR_PERCENT_STEP_HINT: "Vänster/Höger: 1% Upp/Ner 10%"
|
||||
STR_SYNCING_TIME: "Synkroniserar tid…"
|
||||
STR_CALC_HASH: "Beräknar dokumenthash"
|
||||
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
||||
@@ -328,7 +327,8 @@ STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
|
||||
STR_LINK: "[länk]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Aldrig"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Framknappar:"
|
||||
STR_STEP_HINT_SIDE: "Sidoknappar:"
|
||||
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||
STR_ADD_SERVER: "Lägg till server"
|
||||
STR_SERVER_NAME: "Servernamn"
|
||||
|
||||
@@ -241,7 +241,6 @@ STR_CHAPTER_PREFIX: "Bölüm: "
|
||||
STR_PAGES_SEPARATOR: " sayfa | "
|
||||
STR_BOOK_PREFIX: "Kitap: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
|
||||
STR_PERCENT_STEP_HINT: "Sol/Sağ: %1 Yukarı/Aşağı: %10"
|
||||
STR_SYNCING_TIME: "Zaman senkronize ediliyor..."
|
||||
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
||||
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
||||
@@ -289,7 +288,8 @@ STR_IMAGES_SUPPRESS: "Bastır"
|
||||
STR_LINK: "[bağlantı]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
||||
STR_SLEEP_NEVER: "Asla"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
|
||||
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
||||
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
||||
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
||||
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||
STR_PREVIEW: "Önizleme"
|
||||
|
||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Розділ: "
|
||||
STR_PAGES_SEPARATOR: " сторінок | "
|
||||
STR_BOOK_PREFIX: "Книга: "
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
||||
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
|
||||
STR_SYNCING_TIME: "Синхронізація часу..."
|
||||
STR_CALC_HASH: "Обчислення хешу документа..."
|
||||
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
||||
@@ -329,7 +328,8 @@ STR_LINK: "[посилання]"
|
||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||
STR_SLEEP_NEVER: "Ніколи"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
||||
STR_STEP_HINT_FRONT: "Передні кнопки:"
|
||||
STR_STEP_HINT_SIDE: "Бічні кнопки:"
|
||||
STR_ADD_SERVER: "Додати сервер"
|
||||
STR_SERVER_NAME: "Назва сервера"
|
||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||
|
||||
@@ -89,7 +89,8 @@ STR_HYPHENATION: "Partició de mots"
|
||||
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
|
||||
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
|
||||
@@ -276,7 +277,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
||||
STR_PAGES_SEPARATOR: " pàgines | "
|
||||
STR_BOOK_PREFIX: "Llibre: "
|
||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||
|
||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Chương: "
|
||||
STR_PAGES_SEPARATOR: " trang | "
|
||||
STR_BOOK_PREFIX: "Sách: "
|
||||
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
|
||||
STR_PERCENT_STEP_HINT: "Trái/Phải: 1% Lên/Xuống: 10%"
|
||||
STR_SYNCING_TIME: "Đang đồng bộ giờ..."
|
||||
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
|
||||
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
|
||||
@@ -328,7 +327,8 @@ STR_LINK: "[liên kết]"
|
||||
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
||||
STR_SLEEP_NEVER: "Không bao giờ"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
|
||||
STR_STEP_HINT_FRONT: "Nút trước:"
|
||||
STR_STEP_HINT_SIDE: "Nút bên:"
|
||||
STR_ADD_SERVER: "Thêm máy chủ"
|
||||
STR_SERVER_NAME: "Tên máy chủ"
|
||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||
|
||||
@@ -278,13 +278,18 @@ class XPathParagraphResolver final : public Print {
|
||||
path.push_back({name, siblingIndex});
|
||||
parentStates.emplace_back();
|
||||
|
||||
// Count both <p> and <li> as paragraph-like positions, matching how the section
|
||||
// layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures
|
||||
// KOReader progress in list items maps to the correct XPath.
|
||||
if (name == "p") {
|
||||
paragraphCount++;
|
||||
if (paragraphCount == targetParagraph) {
|
||||
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
||||
stopped = true;
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
}
|
||||
} else if (name == "li") {
|
||||
paragraphCount++;
|
||||
}
|
||||
if (paragraphCount == targetParagraph) {
|
||||
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
||||
stopped = true;
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
}
|
||||
|
||||
depth++;
|
||||
|
||||
@@ -1,118 +1,43 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "KOReaderJsonIO.h"
|
||||
|
||||
// Initialize the static instance
|
||||
KOReaderCredentialStore KOReaderCredentialStore::instance;
|
||||
|
||||
namespace {
|
||||
// File format version (for binary migration)
|
||||
constexpr uint8_t KOREADER_FILE_VERSION = 1;
|
||||
|
||||
// File paths
|
||||
constexpr char KOREADER_FILE_BIN[] = "/.crosspoint/koreader.bin";
|
||||
constexpr char KOREADER_FILE_JSON[] = "/.crosspoint/koreader.json";
|
||||
constexpr char KOREADER_FILE_BAK[] = "/.crosspoint/koreader.bin.bak";
|
||||
|
||||
// Default sync server URL
|
||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||
|
||||
// Legacy obfuscation key - "KOReader" in ASCII (only used for binary migration)
|
||||
constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72};
|
||||
constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY);
|
||||
|
||||
void legacyDeobfuscate(std::string& data) {
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH];
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool KOReaderCredentialStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return KOReaderJsonIO::save(*this, KOREADER_FILE_JSON);
|
||||
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||
doc["username"] = getUsername();
|
||||
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||
doc["serverUrl"] = getServerUrl();
|
||||
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||
}
|
||||
|
||||
bool KOReaderCredentialStore::loadFromFile() {
|
||||
// Try JSON first
|
||||
if (Storage.exists(KOREADER_FILE_JSON)) {
|
||||
String json = Storage.readFile(KOREADER_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
bool resave = false;
|
||||
bool result = KOReaderJsonIO::load(*this, json.c_str(), &resave);
|
||||
if (result && resave) {
|
||||
saveToFile();
|
||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
std::string user = doc["username"] | "";
|
||||
|
||||
// Fall back to binary migration
|
||||
if (Storage.exists(KOREADER_FILE_BIN)) {
|
||||
if (loadFromBinaryFile()) {
|
||||
if (saveToFile()) {
|
||||
Storage.rename(KOREADER_FILE_BIN, KOREADER_FILE_BAK);
|
||||
LOG_DBG("KRS", "Migrated koreader.bin to koreader.json");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("KRS", "Failed to save KOReader credentials during migration");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool needsResave = false;
|
||||
std::string pass = extractPassword(doc, needsResave);
|
||||
|
||||
LOG_DBG("KRS", "No credentials file found");
|
||||
return false;
|
||||
}
|
||||
setCredentials(user, pass);
|
||||
setServerUrl(doc["serverUrl"] | "");
|
||||
|
||||
bool KOReaderCredentialStore::loadFromBinaryFile() {
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("KRS", KOREADER_FILE_BIN, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != KOREADER_FILE_VERSION) {
|
||||
LOG_DBG("KRS", "Unknown file version: %u", version);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.available()) {
|
||||
serialization::readString(file, username);
|
||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||
} else {
|
||||
username.clear();
|
||||
LOG_DBG("KRS", "Invalid matchMethod %u in JSON, resetting to FILENAME", method);
|
||||
setMatchMethod(DocumentMatchMethod::FILENAME);
|
||||
}
|
||||
|
||||
if (file.available()) {
|
||||
serialization::readString(file, password);
|
||||
legacyDeobfuscate(password);
|
||||
} else {
|
||||
password.clear();
|
||||
if (needsResave) {
|
||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
if (file.available()) {
|
||||
serialization::readString(file, serverUrl);
|
||||
} else {
|
||||
serverUrl.clear();
|
||||
}
|
||||
|
||||
if (file.available()) {
|
||||
uint8_t method;
|
||||
serialization::readPod(file, method);
|
||||
matchMethod = static_cast<DocumentMatchMethod>(method);
|
||||
} else {
|
||||
matchMethod = DocumentMatchMethod::FILENAME;
|
||||
}
|
||||
|
||||
LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
#include <PersistableStore.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -14,9 +17,9 @@ enum class DocumentMatchMethod : uint8_t {
|
||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||
* but prevents casual reading and ties credentials to the specific device).
|
||||
*/
|
||||
class KOReaderCredentialStore {
|
||||
|
||||
class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore> {
|
||||
private:
|
||||
static KOReaderCredentialStore instance;
|
||||
std::string username;
|
||||
std::string password;
|
||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||
@@ -24,20 +27,14 @@ class KOReaderCredentialStore {
|
||||
|
||||
// Private constructor for singleton
|
||||
KOReaderCredentialStore() = default;
|
||||
~KOReaderCredentialStore() = default;
|
||||
|
||||
bool loadFromBinaryFile();
|
||||
friend class PersistableStore<KOReaderCredentialStore>;
|
||||
|
||||
public:
|
||||
// Delete copy constructor and assignment
|
||||
KOReaderCredentialStore(const KOReaderCredentialStore&) = delete;
|
||||
KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete;
|
||||
|
||||
// Get singleton instance
|
||||
static KOReaderCredentialStore& getInstance() { return instance; }
|
||||
|
||||
// Save/load from SD card
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
static const char* getFilePath() { return "/.crosspoint/koreader.json"; }
|
||||
void toJson(JsonDocument& doc) const;
|
||||
bool fromJson(JsonVariantConst doc);
|
||||
|
||||
// Credential management
|
||||
void setCredentials(const std::string& user, const std::string& pass);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#include "KOReaderJsonIO.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
#include "KOReaderCredentialStore.h"
|
||||
|
||||
namespace KOReaderJsonIO {
|
||||
|
||||
bool save(const KOReaderCredentialStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
doc["username"] = store.getUsername();
|
||||
doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword());
|
||||
doc["serverUrl"] = store.getServerUrl();
|
||||
doc["matchMethod"] = static_cast<uint8_t>(store.getMatchMethod());
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave) {
|
||||
if (needsResave) *needsResave = false;
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("KRS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string user = doc["username"] | std::string("");
|
||||
|
||||
bool ok = false;
|
||||
std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
||||
if (!ok || pass.empty()) {
|
||||
pass = doc["password"] | std::string("");
|
||||
if (!pass.empty() && needsResave) *needsResave = true;
|
||||
}
|
||||
|
||||
store.setCredentials(user, pass);
|
||||
store.setServerUrl(doc["serverUrl"] | std::string(""));
|
||||
|
||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||
store.setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace KOReaderJsonIO
|
||||
@@ -1,8 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
class KOReaderCredentialStore;
|
||||
|
||||
namespace KOReaderJsonIO {
|
||||
bool save(const KOReaderCredentialStore& store, const char* path);
|
||||
bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave);
|
||||
} // namespace KOReaderJsonIO
|
||||
@@ -709,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
||||
float intra =
|
||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||
// Progress-based XPath correctly handles both <p> and <li> positions.
|
||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||
// Fall back to paragraph-index lookup when progress-based resolution fails.
|
||||
if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
||||
}
|
||||
// Fall back to progress-based XPath, then synthetic progress mapping.
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||
}
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ OpdsParser::OpdsParser() {
|
||||
if (!parser) {
|
||||
errorOccured = true;
|
||||
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
||||
return;
|
||||
}
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
}
|
||||
|
||||
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
|
||||
@@ -20,10 +24,6 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
||||
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
if (errorOccured) return length;
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
|
||||
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
||||
size_t remaining = length;
|
||||
constexpr size_t chunkSize = 1024;
|
||||
@@ -54,6 +54,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
}
|
||||
|
||||
void OpdsParser::flush() {
|
||||
if (errorOccured || !parser) return;
|
||||
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
||||
errorOccured = true;
|
||||
destroyXmlParser(parser);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "PersistableStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
bool PersistableStoreBase::writeDocToFile(const char* path, const JsonDocument& doc) {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
if (!Storage.writeFile(path, json)) {
|
||||
LOG_ERR("PERSIST", "Failed to write %s", path);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PersistableStoreBase::readDocFromFile(const char* path, JsonDocument& doc) {
|
||||
if (!Storage.exists(path)) {
|
||||
return false; // Expected on first boot — not an error.
|
||||
}
|
||||
String json = Storage.readFile(path);
|
||||
if (json.isEmpty()) {
|
||||
LOG_ERR("PERSIST", "Failed to read %s (empty)", path);
|
||||
return false;
|
||||
}
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("PERSIST", "JSON parse error in %s: %s", path, error.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PersistableStoreBase::extractPassword(JsonVariantConst doc, bool& needsResave) {
|
||||
bool ok = false;
|
||||
std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
||||
if (!ok) {
|
||||
// Deobfuscation failed — fall back to legacy plaintext password.
|
||||
pass = doc["password"] | "";
|
||||
if (!pass.empty()) needsResave = true;
|
||||
}
|
||||
// A successfully decoded empty string is a legitimate value; preserve as-is.
|
||||
return pass;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief Non-template core of PersistableStore.
|
||||
*
|
||||
* All ArduinoJson parse/serialize machinery is instantiated once here (in
|
||||
* PersistableStore.cpp) instead of in every store's translation unit. GCC
|
||||
* emits the JSON serializer/parser templates as local .isra clones per TU
|
||||
* (~0.5KB each), so keeping serializeJson/deserializeJson out of the stores
|
||||
* is what makes the abstraction flash-neutral.
|
||||
*/
|
||||
class PersistableStoreBase {
|
||||
protected:
|
||||
PersistableStoreBase() = default;
|
||||
~PersistableStoreBase() = default;
|
||||
|
||||
// Serializes doc and writes it to path (ensures /.crosspoint exists). Logs on failure.
|
||||
static bool writeDocToFile(const char* path, const JsonDocument& doc);
|
||||
|
||||
// Reads path and parses it into doc. Returns false silently when the file
|
||||
// does not exist (expected on first boot); logs on read/parse failure.
|
||||
static bool readDocFromFile(const char* path, JsonDocument& doc);
|
||||
|
||||
/**
|
||||
* Helper function for extracting an obfuscated password from a JSON value.
|
||||
* Accepts JsonVariantConst so callers can pass either a whole JsonDocument
|
||||
* or a JsonObject element (e.g. inside an array iteration).
|
||||
* If the decoded password requires a resave (e.g. from plaintext fallback), `needsResave` is set to true.
|
||||
*/
|
||||
static std::string extractPassword(JsonVariantConst doc, bool& needsResave);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Base class for persistable singletons using CRTP.
|
||||
*
|
||||
* Derived classes must provide:
|
||||
* - A private default constructor
|
||||
* - friend class PersistableStore<Derived>;
|
||||
* - static const char* getFilePath();
|
||||
* - void toJson(JsonDocument& doc) const;
|
||||
* - bool fromJson(JsonVariantConst doc);
|
||||
*
|
||||
* Note for implementers: read string values as `const char*` (e.g.
|
||||
* `obj["name"] | ""`), never as `| std::string("")` — ArduinoJson's
|
||||
* std::string converter drags a per-TU copy of the whole JSON serializer
|
||||
* into flash via its serializeJson fallback.
|
||||
*/
|
||||
template <typename T>
|
||||
class PersistableStore : public PersistableStoreBase {
|
||||
protected:
|
||||
PersistableStore() = default;
|
||||
~PersistableStore() = default;
|
||||
|
||||
public:
|
||||
// Delete copy constructor and assignment
|
||||
PersistableStore(const PersistableStore&) = delete;
|
||||
PersistableStore& operator=(const PersistableStore&) = delete;
|
||||
|
||||
static T& getInstance() {
|
||||
static T instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
bool saveToFile() const {
|
||||
JsonDocument doc;
|
||||
static_cast<const T*>(this)->toJson(doc);
|
||||
return writeDocToFile(T::getFilePath(), doc);
|
||||
}
|
||||
|
||||
bool loadFromFile() {
|
||||
JsonDocument doc;
|
||||
if (!readDocFromFile(T::getFilePath(), doc)) {
|
||||
return false;
|
||||
}
|
||||
return static_cast<T*>(this)->fromJson(doc.as<JsonVariantConst>());
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -42,7 +42,7 @@ std::string Txt::getTitle() const {
|
||||
|
||||
// Remove .txt extension
|
||||
if (FsHelpers::hasTxtExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 4);
|
||||
filename.resize(filename.length() - 4);
|
||||
}
|
||||
|
||||
return filename;
|
||||
|
||||
@@ -77,6 +77,10 @@ void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
||||
|
||||
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||
|
||||
void HalDisplay::releaseFrameBuffers() { einkDisplay.releaseBuffers(); }
|
||||
|
||||
bool HalDisplay::reallocFrameBuffers() { return einkDisplay.reallocBuffers(); }
|
||||
|
||||
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ class HalDisplay {
|
||||
// Access to frame buffer
|
||||
uint8_t* getFrameBuffer() const;
|
||||
|
||||
// Lend the framebuffer's RAM to a memory-hungry phase. No display calls may
|
||||
// run between release and a successful realloc; buffers come back white, so
|
||||
// callers must redraw the full screen.
|
||||
void releaseFrameBuffers();
|
||||
bool reallocFrameBuffers();
|
||||
|
||||
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
||||
// to the gray region in physical panel coordinates (no-arg = full frame).
|
||||
// Call after the BW base frame is displayed and before the grayscale planes
|
||||
|
||||
+66
-1
@@ -13,7 +13,10 @@ framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
check_tool = cppcheck
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||
; project header as missing (~470 information-level lines) and fails the job.
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
check_skip_packages = yes
|
||||
|
||||
board_upload.flash_size = 16MB
|
||||
@@ -40,6 +43,16 @@ build_flags =
|
||||
-Wno-bidi-chars
|
||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||
-fno-exceptions
|
||||
# FreeInk panel profiles: compile both X3 (792x528/UC8253) and X4 (800x480/SSD1677);
|
||||
# the firmware picks the active one at runtime via HalDisplay setDisplayX3().
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
# BLE HID page-turner host (BleKeyboardHost). NimBLE role/bond config is baked into
|
||||
# the prebuilt arduino-esp32 framework's sdkconfig.h, so we don't redefine it here
|
||||
# (doing so only warns and has no effect). The host only compiles when
|
||||
# FREEINK_CAP_BLE_HID_HOST is set on the env (below); with the capability off,
|
||||
# BleKeyboardHost links stubs and pulls in zero NimBLE code.
|
||||
-DFREEINK_BLE_HID_SHOW_UNNAMED_DEVICES=0
|
||||
|
||||
build_unflags =
|
||||
-std=gnu++11
|
||||
@@ -50,6 +63,48 @@ board_build.flash_mode = dio
|
||||
board_build.flash_size = 16MB
|
||||
board_build.partitions = partitions.csv
|
||||
|
||||
; Shrink the NimBLE footprint for a 1-connection HID host moving 3-6 byte reports.
|
||||
; Field-measured: begin() costs ~52 KB with these trims vs ~68 KB with the prebuilt
|
||||
; framework defaults — and that 15 KB is the difference between the stack landing
|
||||
; above the reader's render shed floor (stable coexistence) and below it (a
|
||||
; guaranteed shed/restart flap). Rebuilds the Arduino core libs on first build
|
||||
; (slower once, cached after; needs the CMake pin in platformio.local.ini on macOS).
|
||||
custom_sdkconfig =
|
||||
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n
|
||||
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n
|
||||
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||
CONFIG_BT_NIMBLE_MAX_CCCDS=2
|
||||
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=6
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6 ; was 24 x 320 B
|
||||
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=6 ; was 24 x 255 B
|
||||
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=12 ; was 30 x 70 B; only scan bursts need many
|
||||
; IDF 5.5 sizes the HCI transport pools under TRANSPORT_* names; pin both spellings.
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4 ; was 64; a HID host never does prepared writes
|
||||
CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only
|
||||
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only
|
||||
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30
|
||||
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||
; the core source list; all other bundled libraries default to enabled.
|
||||
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||
|
||||
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||
; require embedded server certs the lib builder can't generate
|
||||
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||
custom_component_remove =
|
||||
espressif/esp_insights
|
||||
espressif/esp_rainmaker
|
||||
espressif/esp_diagnostics
|
||||
espressif/esp_diag_data_store
|
||||
espressif/esp_schedule
|
||||
espressif/esp_rcp_update
|
||||
espressif/esp_secure_cert_mgr
|
||||
espressif/cbor
|
||||
|
||||
extra_scripts =
|
||||
pre:scripts/build_html.py
|
||||
pre:scripts/gen_i18n.py
|
||||
@@ -63,10 +118,15 @@ lib_deps =
|
||||
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||
; FreeInk HAL support libs the above depend on (BoardConfig pin maps, etc.).
|
||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||
BleKeyboardHost=symlink://freeink-sdk/libs/network/BleKeyboardHost
|
||||
h2zero/NimBLE-Arduino @ ^2.3.8
|
||||
bblanchon/ArduinoJson @ 7.4.2
|
||||
ricmoo/QRCode @ 0.0.1
|
||||
bitbank2/PNGdec @ 1.1.6
|
||||
@@ -80,6 +140,9 @@ build_flags =
|
||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||
-DFREEINK_BLE_HID_SCAN_DEBUG=1 ; verbose BLE scan lifecycle/advertisement logs for bring-up
|
||||
-DFREEINK_BLE_HID_REPORT_DEBUG=1 ; raw HID report hex dumps + report-map hints (bring-up)
|
||||
|
||||
|
||||
[env:gh_release]
|
||||
@@ -89,6 +152,7 @@ build_flags =
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
||||
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||
|
||||
[env:gh_release_rc]
|
||||
extends = base
|
||||
@@ -97,6 +161,7 @@ build_flags =
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||
|
||||
[env:slim]
|
||||
extends = base
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a test EPUB for <br> section-break rendering.
|
||||
|
||||
Tests that a bare <br> element between paragraphs produces a visible blank-line
|
||||
gap (section separator), while a <br> inside a paragraph only produces a line
|
||||
break with no extra spacing.
|
||||
|
||||
Cases covered:
|
||||
1. Standalone <br> between paragraphs (section break — must show gap).
|
||||
2. <br class="..."> with a CSS class (calibre-style section break).
|
||||
3. Multiple consecutive <br> elements (each adds one line of spacing).
|
||||
4. Inline <br> inside a <p> (line break only — no extra gap).
|
||||
5. <br> at start of chapter (no gap before first paragraph).
|
||||
6. <br> following a heading.
|
||||
|
||||
Visual verification instructions are embedded as the first paragraph of each
|
||||
chapter so a human tester can confirm the expected result on device.
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
|
||||
OUTPUT_PATH = OUTPUT_DIR / "test_br_section_break.epub"
|
||||
|
||||
FILLER = (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
|
||||
"tempor incididunt ut labore et dolore magna aliqua."
|
||||
)
|
||||
|
||||
CSS = """\
|
||||
body { margin: 0; padding: 0; }
|
||||
p { margin-top: 1pt; margin-bottom: 0; text-indent: 1em; text-align: justify; }
|
||||
h1 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||
h2 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||
.section-br { display: block; }
|
||||
"""
|
||||
|
||||
def xhtml(title, body):
|
||||
return f"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" type="text/css" href="styles/test.css"/>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 1 — standalone <br> between paragraphs
|
||||
# ---------------------------------------------------------------------------
|
||||
ch1 = xhtml("Ch1: Standalone br", f"""
|
||||
<h1>Ch 1: Standalone <br> Section Break</h1>
|
||||
<p>PASS: A visible blank-line gap should appear between the two sections below.</p>
|
||||
<p>{FILLER}</p>
|
||||
<br/>
|
||||
<p>{FILLER}</p>
|
||||
<p>PASS: The gap above should be roughly one line tall (same as a blank line).</p>
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 2 — <br class="..."> CSS-classed section break (calibre style)
|
||||
# ---------------------------------------------------------------------------
|
||||
ch2 = xhtml("Ch2: Classed br", f"""
|
||||
<h1>Ch 2: <br class="section-br"/></h1>
|
||||
<p>PASS: A blank-line gap should appear between the two sections below, identical
|
||||
to Ch 1, even though the <br> carries a CSS class.</p>
|
||||
<p>{FILLER}</p>
|
||||
<br class="section-br"/>
|
||||
<p>{FILLER}</p>
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 3 — multiple consecutive <br> elements
|
||||
# ---------------------------------------------------------------------------
|
||||
ch3 = xhtml("Ch3: Multiple br", f"""
|
||||
<h1>Ch 3: Multiple Consecutive <br> Elements</h1>
|
||||
<p>PASS: Two blank lines should appear between the sections (one per <br>).</p>
|
||||
<p>{FILLER}</p>
|
||||
<br/>
|
||||
<br/>
|
||||
<p>{FILLER}</p>
|
||||
<p>PASS: Three blank lines should appear below.</p>
|
||||
<p>{FILLER}</p>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<p>{FILLER}</p>
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 4 — inline <br> inside a paragraph (line break, NOT a gap)
|
||||
# ---------------------------------------------------------------------------
|
||||
ch4 = xhtml("Ch4: Inline br", """
|
||||
<h1>Ch 4: Inline <br> Inside a Paragraph</h1>
|
||||
<p>PASS: The two lines below should be adjacent with NO extra gap between them.
|
||||
The <br> is inside the paragraph and must only break the line.</p>
|
||||
<p>First line of the paragraph.<br/>Second line of the paragraph — directly below, no gap.</p>
|
||||
<p>PASS: Above should look like two closely-spaced lines, not like two paragraphs
|
||||
separated by a blank line.</p>
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 5 — <br> following a heading
|
||||
# ---------------------------------------------------------------------------
|
||||
ch5 = xhtml("Ch5: br after heading", f"""
|
||||
<h1>Ch 5: <br> After a Heading</h1>
|
||||
<br/>
|
||||
<p>PASS: There should be a blank-line gap between the heading above and this paragraph.</p>
|
||||
<p>{FILLER}</p>
|
||||
<h2>Section heading</h2>
|
||||
<br/>
|
||||
<p>PASS: There should be a blank-line gap between the section heading and this paragraph.</p>
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter 6 — <br> at very start of chapter (no spurious leading gap)
|
||||
# ---------------------------------------------------------------------------
|
||||
ch6 = xhtml("Ch6: br at chapter start", f"""<br/>
|
||||
<h1>Ch 6: <br> at Chapter Start</h1>
|
||||
<p>PASS: This heading should appear near the top of the page with no large blank
|
||||
area above it despite the <br> being the very first element.</p>
|
||||
<p>{FILLER}</p>
|
||||
""")
|
||||
|
||||
CHAPTERS = [
|
||||
("ch1", "chapter1.xhtml", "Chapter 1: Standalone br", ch1),
|
||||
("ch2", "chapter2.xhtml", "Chapter 2: Classed br", ch2),
|
||||
("ch3", "chapter3.xhtml", "Chapter 3: Multiple br", ch3),
|
||||
("ch4", "chapter4.xhtml", "Chapter 4: Inline br", ch4),
|
||||
("ch5", "chapter5.xhtml", "Chapter 5: br after heading", ch5),
|
||||
("ch6", "chapter6.xhtml", "Chapter 6: br at start", ch6),
|
||||
]
|
||||
|
||||
def build_epub(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as epub:
|
||||
# mimetype must be first and uncompressed
|
||||
epub.writestr("mimetype", "application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
epub.writestr("META-INF/container.xml", """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf"
|
||||
media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>""")
|
||||
|
||||
epub.writestr("OEBPS/styles/test.css", CSS)
|
||||
|
||||
manifest_items = []
|
||||
spine_items = []
|
||||
nav_items = []
|
||||
|
||||
for (chid, chfile, chtitle, chcontent) in CHAPTERS:
|
||||
epub.writestr(f"OEBPS/{chfile}", chcontent)
|
||||
manifest_items.append(
|
||||
f' <item id="{chid}" href="{chfile}" media-type="application/xhtml+xml"/>')
|
||||
spine_items.append(f' <itemref idref="{chid}"/>')
|
||||
nav_items.append(f' <li><a href="{chfile}">{chtitle}</a></li>')
|
||||
|
||||
manifest_items.append(
|
||||
' <item id="nav" href="nav.xhtml" '
|
||||
'media-type="application/xhtml+xml" properties="nav"/>')
|
||||
|
||||
content_opf = f"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">test-epub-br-section-break</dc:identifier>
|
||||
<dc:title>Test: br Section Break</dc:title>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
{chr(10).join(manifest_items)}
|
||||
</manifest>
|
||||
<spine>
|
||||
{chr(10).join(spine_items)}
|
||||
</spine>
|
||||
</package>"""
|
||||
epub.writestr("OEBPS/content.opf", content_opf)
|
||||
|
||||
nav_xhtml = f"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Table of Contents</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<ol>
|
||||
{chr(10).join(nav_items)}
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>"""
|
||||
epub.writestr("OEBPS/nav.xhtml", nav_xhtml)
|
||||
|
||||
print(f"Generated: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_epub(OUTPUT_PATH)
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "BleInput.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
namespace bleinput {
|
||||
|
||||
namespace {
|
||||
volatile bool g_startInProgress = false;
|
||||
}
|
||||
|
||||
// NimBLE controller init/deinit hang (interrupt WDT) if run at the 10 MHz low-power
|
||||
// frequency, so force normal CPU speed around both. Centralized here so every caller
|
||||
// (boot restore, settings toggle, reader toggle, sleep) is covered automatically.
|
||||
bool ensureStarted() {
|
||||
g_startInProgress = true;
|
||||
HalPowerManager::Lock powerLock;
|
||||
const bool ok = BleHid.begin(kHostName);
|
||||
g_startInProgress = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool startInProgress() { return g_startInProgress; }
|
||||
|
||||
// Full teardown (NimBLE deinit), not just a link drop, so the BLE stack's RAM is
|
||||
// returned to the heap — otherwise memory-hungry work like EPUB inflate can't
|
||||
// allocate even after the user turns Bluetooth off.
|
||||
void stop() {
|
||||
HalPowerManager::Lock powerLock;
|
||||
BleHid.end();
|
||||
}
|
||||
|
||||
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value) {
|
||||
if (ev.special != freeink::SpecialKey::None) {
|
||||
kind = 0;
|
||||
value = static_cast<uint8_t>(ev.special);
|
||||
return true;
|
||||
}
|
||||
if (ev.keycode != 0) {
|
||||
kind = 1;
|
||||
value = ev.keycode;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
const char* specialName(uint8_t value) {
|
||||
switch (static_cast<freeink::SpecialKey>(value)) {
|
||||
case freeink::SpecialKey::Enter:
|
||||
return "Enter";
|
||||
case freeink::SpecialKey::Backspace:
|
||||
return "Backspace";
|
||||
case freeink::SpecialKey::Tab:
|
||||
return "Tab";
|
||||
case freeink::SpecialKey::Escape:
|
||||
return "Escape";
|
||||
case freeink::SpecialKey::Delete:
|
||||
return "Delete";
|
||||
case freeink::SpecialKey::Left:
|
||||
return "Left";
|
||||
case freeink::SpecialKey::Right:
|
||||
return "Right";
|
||||
case freeink::SpecialKey::Up:
|
||||
return "Up";
|
||||
case freeink::SpecialKey::Down:
|
||||
return "Down";
|
||||
case freeink::SpecialKey::Home:
|
||||
return "Home";
|
||||
case freeink::SpecialKey::End:
|
||||
return "End";
|
||||
case freeink::SpecialKey::PageUp:
|
||||
return "Page Up";
|
||||
case freeink::SpecialKey::PageDown:
|
||||
return "Page Down";
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input) {
|
||||
if (!BleHid.isRunning() || BleHid.isConnected()) return;
|
||||
// drawPopup refreshes the panel itself, so draw once and let e-ink hold it while we
|
||||
// pump the host. Holds until the remote links, the user presses a button to bail, or
|
||||
// a generous timeout (a remote that slept after a disconnect needs a button to wake).
|
||||
GUI.drawPopup(renderer, tr(STR_BT_CONNECTING_POPUP));
|
||||
const unsigned long deadline = millis() + 10000;
|
||||
while (!BleHid.isConnected() && millis() < deadline) {
|
||||
BleHid.poll();
|
||||
input.update();
|
||||
if (input.wasAnyPressed()) break;
|
||||
delay(50);
|
||||
}
|
||||
// Note: the caller must redraw to clear the popup. For grayscale reader pages the
|
||||
// caller should also request a ghost-cleanup (HALF) refresh first — a plain fast/
|
||||
// partial refresh ghosts badly over the BW popup (see Activity::requestGhostCleanup).
|
||||
}
|
||||
|
||||
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen) {
|
||||
if (!out || outLen == 0) return;
|
||||
if (kind == 0) {
|
||||
const char* name = specialName(value);
|
||||
if (name) {
|
||||
strncpy(out, name, outLen - 1);
|
||||
out[outLen - 1] = '\0';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Printable ASCII usage handled as a generic key code; show the raw value.
|
||||
snprintf(out, outLen, "Key 0x%02X", static_cast<unsigned>(value));
|
||||
}
|
||||
|
||||
} // namespace bleinput
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
// CrossPoint <-> FreeInk BLE HID host glue.
|
||||
//
|
||||
// Thin, capability-safe helpers around freeink::BleKeyboardHost (the `BleHid`
|
||||
// singleton). When FREEINK_CAP_BLE_HID_HOST is compiled out the SDK links stubs,
|
||||
// so every call here is still valid and simply no-ops / returns false — callers
|
||||
// need no #ifdefs.
|
||||
//
|
||||
// The (kind, value) pair produced by encodeKey() is the stable identity stored in
|
||||
// CrossPointSettings::bleKeyMap. Page-turner remotes emit "special" keys
|
||||
// (PageUp/PageDown/arrows); plain keyboards emit usage codes. We deliberately
|
||||
// ignore modifiers and the printable char for matching (page turners don't use
|
||||
// modifiers), keeping the persisted entry a trivial two-byte comparison.
|
||||
|
||||
#include <BleKeyboardHost.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
class GfxRenderer;
|
||||
class MappedInputManager;
|
||||
|
||||
namespace bleinput {
|
||||
|
||||
// Advertised central name shown to peripherals during pairing.
|
||||
inline constexpr const char* kHostName = "CrossPoint";
|
||||
|
||||
// Heap floor for starting the NimBLE stack (measured begin() cost: ~52-57 KB).
|
||||
// The reader now lends the framebuffer to section builds, so BLE startup no
|
||||
// longer needs to reserve the old full build headroom. Keep a modest margin and
|
||||
// let the render/build shed paths handle genuinely tight moments.
|
||||
inline constexpr size_t kStartMinFreeHeap = 56 * 1024;
|
||||
|
||||
// Lower floor for the Bluetooth settings screen, where the user has explicitly asked
|
||||
// for BLE right now (scanning/pairing is dead without the stack). No page renders or
|
||||
// section builds run there, so the reader-sized reserve above doesn't apply — only
|
||||
// NimBLE's own ~57 KB plus working margin.
|
||||
inline constexpr size_t kStartMinFreeHeapExplicit = 70 * 1024;
|
||||
|
||||
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
|
||||
// NimBLE init failed. Safe to call repeatedly.
|
||||
bool ensureStarted();
|
||||
bool startInProgress();
|
||||
|
||||
// Drop the active link (e.g. before deep sleep or when the user disables BT).
|
||||
void stop();
|
||||
|
||||
// Encode a decoded key event into the stable (kind, value) identity used by the
|
||||
// settings map. kind: 0 = SpecialKey, 1 = HID usage. Returns false when the event
|
||||
// carries no usable identity (no special key and no usage code).
|
||||
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value);
|
||||
|
||||
// Human-readable name for a stored (kind, value) identity, for the mapping UI.
|
||||
// Writes a null-terminated string into out (e.g. "Page Down", "Key 0x4B").
|
||||
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen);
|
||||
|
||||
// Draw a "BT Connecting..." popup and pump the BLE host until the bonded remote
|
||||
// links, the user presses a button to dismiss, or a timeout. No-op if BLE isn't
|
||||
// running or is already connected. The caller must redraw afterward to clear it.
|
||||
void showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input);
|
||||
|
||||
} // namespace bleinput
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "I18nKeys.h"
|
||||
@@ -23,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) {
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 2;
|
||||
constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin";
|
||||
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
|
||||
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
|
||||
@@ -96,6 +97,7 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
|
||||
}
|
||||
|
||||
bool CrossPointSettings::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
||||
}
|
||||
@@ -106,7 +108,11 @@ bool CrossPointSettings::loadFromFile() {
|
||||
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
bool result;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
}
|
||||
if (result && resave) {
|
||||
if (saveToFile()) {
|
||||
LOG_DBG("CPS", "Resaved settings to update format");
|
||||
@@ -166,6 +172,7 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
@@ -222,13 +229,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string urlStr;
|
||||
serialization::readString(inputFile, urlStr);
|
||||
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
||||
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, textAntiAliasing);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
|
||||
@@ -237,20 +237,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, hyphenationEnabled);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string usernameStr;
|
||||
serialization::readString(inputFile, usernameStr);
|
||||
strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1);
|
||||
opdsUsername[sizeof(opdsUsername) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string passwordStr;
|
||||
serialization::readString(inputFile, passwordStr);
|
||||
strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1);
|
||||
opdsPassword[sizeof(opdsPassword) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, uiTheme);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <iosfwd>
|
||||
#include <mutex>
|
||||
|
||||
class CrossPointSettings {
|
||||
private:
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Private constructor for singleton
|
||||
CrossPointSettings() = default;
|
||||
|
||||
@@ -17,6 +20,10 @@ class CrossPointSettings {
|
||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
||||
|
||||
// Access the settings mutex for protecting multi-field reads/writes from other cores.
|
||||
// Callers must not re-enter SETTINGS methods that lock _mutex while holding it.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
enum SLEEP_SCREEN_MODE {
|
||||
DARK = 0,
|
||||
LIGHT = 1,
|
||||
@@ -218,6 +225,22 @@ class CrossPointSettings {
|
||||
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
||||
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
||||
uint8_t frontButtonRight = FRONT_HW_RIGHT;
|
||||
// --- Bluetooth (BLE HID page-turner) ---
|
||||
// Master on/off for the BLE HID host. Persisted; auto-restored on boot/wake.
|
||||
// Managed by BluetoothSettingsActivity and the in-reader "Toggle Bluetooth" menu item.
|
||||
uint8_t bluetoothEnabled = 0;
|
||||
// Remote-button mapping table: each slot binds a decoded BLE key identity to a
|
||||
// logical MappedInputManager::Button. Fixed-capacity POD (no heap), persisted
|
||||
// manually in JsonSettingsIO (like the front-button remap). 0xFF = empty/unassigned.
|
||||
// Headroom for several buttons plus optional presets and rolling-code remotes
|
||||
// (some buttons emit more than one code). Each entry is 3 bytes.
|
||||
static constexpr uint8_t BLE_MAP_CAPACITY = 10;
|
||||
struct BleKeyMapEntry {
|
||||
uint8_t keyKind = 0xFF; // 0 = SpecialKey, 1 = HID usage code; 0xFF = empty slot
|
||||
uint8_t keyValue = 0; // (uint8_t)freeink::SpecialKey, or the raw HID usage id
|
||||
uint8_t button = 0xFF; // (uint8_t)MappedInputManager::Button; 0xFF = unassigned
|
||||
};
|
||||
BleKeyMapEntry bleKeyMap[BLE_MAP_CAPACITY] = {};
|
||||
// Reader font settings
|
||||
uint8_t fontFamily = NOTOSERIF;
|
||||
uint8_t fontSize = MEDIUM;
|
||||
@@ -231,10 +254,6 @@ class CrossPointSettings {
|
||||
|
||||
// Reader screen margin settings
|
||||
uint8_t screenMargin = 5;
|
||||
// OPDS browser settings
|
||||
char opdsServerUrl[128] = "";
|
||||
char opdsUsername[64] = "";
|
||||
char opdsPassword[64] = "";
|
||||
// Hide battery percentage
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press page turn button behavior
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||
@@ -32,6 +33,7 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
|
||||
}
|
||||
|
||||
bool CrossPointState::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
||||
}
|
||||
@@ -41,6 +43,7 @@ bool CrossPointState::loadFromFile() {
|
||||
if (Storage.exists(STATE_FILE_JSON)) {
|
||||
String json = Storage.readFile(STATE_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
return JsonSettingsIO::loadState(*this, json.c_str());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,7 @@ bool CrossPointState::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
class CrossPointState {
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Static instance
|
||||
static CrossPointState instance;
|
||||
|
||||
public:
|
||||
// Access the state mutex for protecting multi-field reads/writes from other cores.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
||||
|
||||
std::string openEpubPath;
|
||||
|
||||
+30
-147
@@ -5,12 +5,15 @@
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
#include "BookmarkEntry.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SettingsList.h"
|
||||
@@ -142,6 +145,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
||||
doc["frontButtonLeft"] = s.frontButtonLeft;
|
||||
doc["frontButtonRight"] = s.frontButtonRight;
|
||||
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
|
||||
doc["bluetoothEnabled"] = s.bluetoothEnabled;
|
||||
JsonArray bleMap = doc["bleKeyMap"].to<JsonArray>();
|
||||
for (const auto& e : s.bleKeyMap) {
|
||||
if (e.keyKind == 0xFF || e.button == 0xFF) continue; // skip empty/unassigned slots
|
||||
JsonObject o = bleMap.add<JsonObject>();
|
||||
o["k"] = e.keyKind;
|
||||
o["v"] = e.keyValue;
|
||||
o["b"] = e.button;
|
||||
}
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
doc["fontFamily"] = s.fontFamily;
|
||||
// SD card font family name — not in SettingsList, save manually
|
||||
@@ -153,10 +166,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
@@ -243,6 +252,23 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
||||
CrossPointSettings::validateFrontButtonMapping(s);
|
||||
|
||||
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
|
||||
s.bluetoothEnabled = clamp(doc["bluetoothEnabled"] | (uint8_t)0, 2, 0);
|
||||
std::fill(std::begin(s.bleKeyMap), std::end(s.bleKeyMap), CrossPointSettings::BleKeyMapEntry{}); // reset to empty
|
||||
JsonArrayConst bleMap = doc["bleKeyMap"];
|
||||
if (!bleMap.isNull()) {
|
||||
uint8_t slot = 0;
|
||||
for (JsonObjectConst o : bleMap) {
|
||||
if (slot >= CrossPointSettings::BLE_MAP_CAPACITY) break;
|
||||
const uint8_t button = o["b"] | (uint8_t)0xFF;
|
||||
if (button >= MappedInputManager::kButtonCount) continue; // drop invalid mappings
|
||||
s.bleKeyMap[slot].keyKind = o["k"] | (uint8_t)0xFF;
|
||||
s.bleKeyMap[slot].keyValue = o["v"] | (uint8_t)0;
|
||||
s.bleKeyMap[slot].button = button;
|
||||
slot++;
|
||||
}
|
||||
}
|
||||
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
||||
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
||||
@@ -269,149 +295,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- WifiCredentialStore ----
|
||||
|
||||
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
|
||||
|
||||
JsonArray arr = doc["credentials"].to<JsonArray>();
|
||||
for (const auto& cred : store.getCredentials()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["ssid"] = cred.ssid;
|
||||
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) {
|
||||
if (needsResave) *needsResave = false;
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("WCS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string("");
|
||||
|
||||
store.credentials.clear();
|
||||
JsonArray arr = doc["credentials"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.credentials.size() >= store.MAX_NETWORKS) break;
|
||||
WifiCredential cred;
|
||||
cred.ssid = obj["ssid"] | std::string("");
|
||||
bool ok = false;
|
||||
cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
||||
if (!ok || cred.password.empty()) {
|
||||
cred.password = obj["password"] | std::string("");
|
||||
if (!cred.password.empty() && needsResave) *needsResave = true;
|
||||
}
|
||||
store.credentials.push_back(cred);
|
||||
}
|
||||
|
||||
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- RecentBooksStore ----
|
||||
|
||||
bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["books"].to<JsonArray>();
|
||||
for (const auto& book : store.getBooks()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["path"] = book.path;
|
||||
obj["title"] = book.title;
|
||||
obj["author"] = book.author;
|
||||
obj["coverBmpPath"] = book.coverBmpPath;
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) {
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("RBS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.recentBooks.clear();
|
||||
JsonArray arr = doc["books"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.getCount() >= 10) break;
|
||||
RecentBook book;
|
||||
book.path = obj["path"] | std::string("");
|
||||
book.title = obj["title"] | std::string("");
|
||||
book.author = obj["author"] | std::string("");
|
||||
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
|
||||
store.recentBooks.push_back(book);
|
||||
}
|
||||
|
||||
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- OpdsServerStore ----
|
||||
// Follows the same save/load pattern as WifiCredentialStore above.
|
||||
// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key).
|
||||
|
||||
bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
|
||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||
for (const auto& server : store.getServers()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["name"] = server.name;
|
||||
obj["url"] = server.url;
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) {
|
||||
if (needsResave) *needsResave = false;
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.servers.clear();
|
||||
JsonArray arr = doc["servers"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
||||
OpdsServer server;
|
||||
server.name = obj["name"] | std::string("");
|
||||
server.url = obj["url"] | std::string("");
|
||||
server.username = obj["username"] | std::string("");
|
||||
// Try the obfuscated key first; fall back to plaintext "password" for
|
||||
// files written before obfuscation was added (or hand-edited JSON).
|
||||
bool ok = false;
|
||||
server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
||||
if (!ok || server.password.empty()) {
|
||||
server.password = obj["password"] | std::string("");
|
||||
if (!server.password.empty() && needsResave) *needsResave = true;
|
||||
}
|
||||
store.servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Bookmarks ----
|
||||
|
||||
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
||||
|
||||
@@ -19,18 +19,6 @@ bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = n
|
||||
bool saveState(const CrossPointState& s, const char* path);
|
||||
bool loadState(CrossPointState& s, const char* json);
|
||||
|
||||
// WifiCredentialStore
|
||||
bool saveWifi(const WifiCredentialStore& store, const char* path);
|
||||
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
// RecentBooksStore
|
||||
bool saveRecentBooks(const RecentBooksStore& store, const char* path);
|
||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
||||
|
||||
// OpdsServerStore
|
||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
// Bookmarks
|
||||
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
||||
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "BleInput.h"
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||
@@ -74,17 +75,105 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||
bool MappedInputManager::bleEdge(const bool* arr, const Button button) const {
|
||||
// Mirror mapButton()'s composite navigation handling so a BLE key bound to a
|
||||
// physical direction also satisfies the derived NavNext / NavPrevious logical
|
||||
// buttons (used by list navigation), respecting the orientation axis flip.
|
||||
switch (button) {
|
||||
case Button::NavNext:
|
||||
return isNavDirectionSwapped() ? (arr[(int)Button::Up] || arr[(int)Button::Left])
|
||||
: (arr[(int)Button::Down] || arr[(int)Button::Right]);
|
||||
case Button::NavPrevious:
|
||||
return isNavDirectionSwapped() ? (arr[(int)Button::Down] || arr[(int)Button::Right])
|
||||
: (arr[(int)Button::Up] || arr[(int)Button::Left]);
|
||||
default:
|
||||
return arr[(int)button];
|
||||
}
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||
bool MappedInputManager::wasPressed(const Button button) const {
|
||||
return mapButton(button, &HalGPIO::wasPressed) || bleEdge(blePressEdge, button);
|
||||
}
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||
bool MappedInputManager::wasReleased(const Button button) const {
|
||||
return mapButton(button, &HalGPIO::wasReleased) || bleEdge(bleReleaseEdge, button);
|
||||
}
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const {
|
||||
// A BLE tap is momentary: report "pressed" only on the press-edge frame.
|
||||
return mapButton(button, &HalGPIO::isPressed) || bleEdge(blePressEdge, button);
|
||||
}
|
||||
|
||||
void MappedInputManager::setBleCaptureMode(const bool on) {
|
||||
bleCaptureMode = on;
|
||||
bleHasCaptured = false;
|
||||
if (on) {
|
||||
// Clear any stale overlay so a held remote key doesn't leak into the UI.
|
||||
for (uint8_t i = 0; i < kButtonCount; i++) {
|
||||
blePressEdge[i] = false;
|
||||
bleReleaseEdge[i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MappedInputManager::takeCapturedBleKey(uint8_t& kind, uint8_t& value) {
|
||||
if (!bleHasCaptured) return false;
|
||||
kind = bleCapturedKind;
|
||||
value = bleCapturedValue;
|
||||
bleHasCaptured = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void MappedInputManager::pollBle() {
|
||||
bleActivityThisFrame = false;
|
||||
// Age last frame's press edges into this frame's release edges (the FreeInk host
|
||||
// surfaces presses + synthetic repeats but never releases), then clear presses. A
|
||||
// pending release also counts as BLE activity this frame so getHeldTime() reports
|
||||
// zero on the release frame too (page-turn handlers often fire on release).
|
||||
for (uint8_t i = 0; i < kButtonCount; i++) {
|
||||
bleReleaseEdge[i] = blePressEdge[i];
|
||||
blePressEdge[i] = false;
|
||||
if (bleReleaseEdge[i]) bleActivityThisFrame = true;
|
||||
}
|
||||
|
||||
freeink::KeyEvent ev;
|
||||
while (BleHid.popKey(ev)) {
|
||||
uint8_t kind = 0xFF;
|
||||
uint8_t value = 0;
|
||||
if (!bleinput::encodeKey(ev, kind, value)) continue;
|
||||
|
||||
if (bleCaptureMode) {
|
||||
bleCapturedKind = kind;
|
||||
bleCapturedValue = value;
|
||||
bleHasCaptured = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the key identity against the persisted mapping table.
|
||||
for (const auto& e : SETTINGS.bleKeyMap) {
|
||||
if (e.button == 0xFF || e.keyKind != kind || e.keyValue != value) continue;
|
||||
if (e.button < kButtonCount) {
|
||||
blePressEdge[e.button] = true;
|
||||
bleActivityThisFrame = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||
|
||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||
|
||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||
unsigned long MappedInputManager::getHeldTime() const {
|
||||
// A BLE-mapped key is a momentary tap with no physical hold (we don't model BLE
|
||||
// press-and-hold). gpio.getHeldTime() returns the *last physical* button's hold
|
||||
// duration, which is stale — if a BLE edge drove input this frame, reporting that
|
||||
// stale value makes a tap look like a long-press (e.g. page tap -> chapter skip).
|
||||
// Report zero in that case so BLE taps are always treated as short presses.
|
||||
if (bleActivityThisFrame) return 0;
|
||||
return gpio.getHeldTime();
|
||||
}
|
||||
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
|
||||
@@ -7,6 +7,9 @@ class GfxRenderer;
|
||||
class MappedInputManager {
|
||||
public:
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||
// Number of values in Button (Back..NavPrevious). Used to size the BLE overlay and
|
||||
// to clamp persisted BLE mappings. Keep in sync with the enum above.
|
||||
static constexpr uint8_t kButtonCount = 11;
|
||||
|
||||
struct Labels {
|
||||
const char* btn1;
|
||||
@@ -28,6 +31,23 @@ class MappedInputManager {
|
||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||
int getPressedFrontButton() const;
|
||||
|
||||
// --- BLE page-turner overlay -------------------------------------------------
|
||||
// Drain decoded key events from the FreeInk BLE HID host and translate the ones
|
||||
// bound in SETTINGS.bleKeyMap into per-frame logical-button edges that OR into
|
||||
// wasPressed()/isPressed()/wasReleased(). Call once per main-loop iteration,
|
||||
// right after gpio.update() and BleHid.poll(). No-ops when BLE is compiled out.
|
||||
void pollBle();
|
||||
// True when a mapped BLE key produced an edge this frame — keeps the inactivity
|
||||
// / auto-sleep timer alive while a remote is the only input device in use.
|
||||
bool bleHadActivityThisFrame() const { return bleActivityThisFrame; }
|
||||
// Capture mode: while on, pollBle() stops mapping events and instead stashes the
|
||||
// raw decoded key identity so the button-mapping UI can read it without racing the
|
||||
// live mapping over the single popKey() queue.
|
||||
void setBleCaptureMode(bool on);
|
||||
// Pop a captured (kind, value) key identity grabbed while in capture mode.
|
||||
// Returns false when nothing has been captured since the last call.
|
||||
bool takeCapturedBleKey(uint8_t& kind, uint8_t& value);
|
||||
|
||||
// True when the control axis is flipped relative to the physical buttons: the user opted into
|
||||
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
|
||||
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
|
||||
@@ -44,4 +64,17 @@ class MappedInputManager {
|
||||
const GfxRenderer& renderer;
|
||||
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
// OR-in the BLE overlay for a logical button, mirroring mapButton()'s composite
|
||||
// handling of NavNext/NavPrevious so a remote key bound to Up/Down/Left/Right also
|
||||
// drives list navigation.
|
||||
bool bleEdge(const bool* arr, Button button) const;
|
||||
|
||||
// Per-frame BLE overlay, indexed by (uint8_t)Button.
|
||||
bool blePressEdge[kButtonCount] = {}; // press edge this frame -> wasPressed / isPressed
|
||||
bool bleReleaseEdge[kButtonCount] = {}; // release edge this frame -> wasReleased
|
||||
bool bleActivityThisFrame = false;
|
||||
bool bleCaptureMode = false;
|
||||
bool bleHasCaptured = false;
|
||||
uint8_t bleCapturedKind = 0xFF;
|
||||
uint8_t bleCapturedValue = 0;
|
||||
};
|
||||
|
||||
+36
-62
@@ -1,74 +1,48 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
OpdsServerStore OpdsServerStore::instance;
|
||||
|
||||
namespace {
|
||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
||||
} // namespace
|
||||
|
||||
bool OpdsServerStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON);
|
||||
void OpdsServerStore::toJson(JsonDocument& doc) const {
|
||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||
for (const auto& server : servers) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["name"] = server.name;
|
||||
obj["url"] = server.url;
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
||||
}
|
||||
}
|
||||
|
||||
bool OpdsServerStore::loadFromFile() {
|
||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
// resave flag is set when passwords were stored in plaintext and need re-obfuscation
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave);
|
||||
if (result && resave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// No opds.json found — attempt one-time migration from the legacy single-server
|
||||
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
|
||||
if (migrateFromSettings()) {
|
||||
LOG_DBG("OPS", "Migrated legacy OPDS settings");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::migrateFromSettings() {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OpdsServer server;
|
||||
server.name = "OPDS Server";
|
||||
server.url = SETTINGS.opdsServerUrl;
|
||||
server.username = SETTINGS.opdsUsername;
|
||||
server.password = SETTINGS.opdsPassword;
|
||||
servers.push_back(std::move(server));
|
||||
|
||||
if (saveToFile()) {
|
||||
// Clear legacy fields so migration won't run again on next boot
|
||||
SETTINGS.opdsServerUrl[0] = '\0';
|
||||
SETTINGS.opdsUsername[0] = '\0';
|
||||
SETTINGS.opdsPassword[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save failed — roll back in-memory state so we don't have a partial migration
|
||||
bool OpdsServerStore::fromJson(JsonVariantConst doc) {
|
||||
// Tolerate a missing/invalid 'servers' key (treat as empty list); only a
|
||||
// JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||
servers.clear();
|
||||
return false;
|
||||
JsonArrayConst arr = doc["servers"].as<JsonArrayConst>();
|
||||
servers.reserve(std::min(arr.size(), MAX_SERVERS));
|
||||
bool needsResave = false;
|
||||
|
||||
for (JsonObjectConst obj : arr) {
|
||||
if (servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
||||
OpdsServer server;
|
||||
server.name = obj["name"] | "";
|
||||
server.url = obj["url"] | "";
|
||||
server.username = obj["username"] | "";
|
||||
server.password = extractPassword(obj, needsResave);
|
||||
servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
|
||||
+8
-23
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
#include <PersistableStore.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -9,37 +12,25 @@ struct OpdsServer {
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
};
|
||||
|
||||
class OpdsServerStore;
|
||||
namespace JsonSettingsIO {
|
||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave);
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
/**
|
||||
* Singleton class for storing OPDS server configurations on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
* and base64-encoded before writing to JSON.
|
||||
*/
|
||||
class OpdsServerStore {
|
||||
class OpdsServerStore : public PersistableStore<OpdsServerStore> {
|
||||
private:
|
||||
static OpdsServerStore instance;
|
||||
std::vector<OpdsServer> servers;
|
||||
|
||||
static constexpr size_t MAX_SERVERS = 8;
|
||||
|
||||
OpdsServerStore() = default;
|
||||
|
||||
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
|
||||
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
|
||||
friend class PersistableStore<OpdsServerStore>;
|
||||
|
||||
public:
|
||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
||||
|
||||
static OpdsServerStore& getInstance() { return instance; }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
static const char* getFilePath() { return "/.crosspoint/opds.json"; }
|
||||
void toJson(JsonDocument& doc) const;
|
||||
bool fromJson(JsonVariantConst doc);
|
||||
|
||||
bool addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
@@ -49,12 +40,6 @@ class OpdsServerStore {
|
||||
const OpdsServer* getServer(size_t index) const;
|
||||
size_t getCount() const { return servers.size(); }
|
||||
bool hasServers() const { return !servers.empty(); }
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-server settings in CrossPointSettings.
|
||||
* Called once during first load if no opds.json exists.
|
||||
*/
|
||||
bool migrateFromSettings();
|
||||
};
|
||||
|
||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||
|
||||
+29
-107
@@ -3,23 +3,42 @@
|
||||
#include <Epub.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
||||
constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin";
|
||||
constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json";
|
||||
constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak";
|
||||
constexpr int MAX_RECENT_BOOKS = 10;
|
||||
} // namespace
|
||||
void RecentBooksStore::toJson(JsonDocument& doc) const {
|
||||
JsonArray arr = doc["books"].to<JsonArray>();
|
||||
for (const auto& book : recentBooks) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["path"] = book.path;
|
||||
obj["title"] = book.title;
|
||||
obj["author"] = book.author;
|
||||
obj["coverBmpPath"] = book.coverBmpPath;
|
||||
}
|
||||
}
|
||||
|
||||
RecentBooksStore RecentBooksStore::instance;
|
||||
bool RecentBooksStore::fromJson(JsonVariantConst doc) {
|
||||
// Tolerate a missing/invalid 'books' key (treat as empty list); only a
|
||||
// JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||
recentBooks.clear();
|
||||
JsonArrayConst arr = doc["books"].as<JsonArrayConst>();
|
||||
recentBooks.reserve(std::min(arr.size(), static_cast<size_t>(MAX_RECENT_BOOKS)));
|
||||
for (JsonObjectConst obj : arr) {
|
||||
if (getCount() >= MAX_RECENT_BOOKS) break;
|
||||
RecentBook book;
|
||||
book.path = obj["path"] | "";
|
||||
book.title = obj["title"] | "";
|
||||
book.author = obj["author"] | "";
|
||||
book.coverBmpPath = obj["coverBmpPath"] | "";
|
||||
recentBooks.push_back(book);
|
||||
}
|
||||
|
||||
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", getCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath) {
|
||||
@@ -92,11 +111,6 @@ bool RecentBooksStore::pruneMissing() {
|
||||
return recentBooks.size() != before;
|
||||
}
|
||||
|
||||
bool RecentBooksStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON);
|
||||
}
|
||||
|
||||
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
std::string lastBookFileName = "";
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
@@ -124,95 +138,3 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
}
|
||||
return RecentBook{path, "", "", ""};
|
||||
}
|
||||
|
||||
bool RecentBooksStore::loadFromFile() {
|
||||
// Try JSON first
|
||||
if (Storage.exists(RECENT_BOOKS_FILE_JSON)) {
|
||||
String json = Storage.readFile(RECENT_BOOKS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
return JsonSettingsIO::loadRecentBooks(*this, json.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to binary migration
|
||||
if (Storage.exists(RECENT_BOOKS_FILE_BIN)) {
|
||||
if (loadFromBinaryFile()) {
|
||||
saveToFile();
|
||||
Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK);
|
||||
LOG_DBG("RBS", "Migrated recent.bin to recent.json");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool RecentBooksStore::loadFromBinaryFile() {
|
||||
HalFile inputFile;
|
||||
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version == 1 || version == 2) {
|
||||
// Old version, just read paths
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
recentBooks.clear();
|
||||
recentBooks.reserve(count);
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path;
|
||||
serialization::readString(inputFile, path);
|
||||
|
||||
// load book to get missing data
|
||||
RecentBook book = getDataFromBook(path);
|
||||
if (book.title.empty() && book.author.empty() && version == 2) {
|
||||
// Fall back to loading what we can from the store
|
||||
std::string title, author;
|
||||
serialization::readString(inputFile, title);
|
||||
serialization::readString(inputFile, author);
|
||||
recentBooks.push_back({path, title, author, ""});
|
||||
} else {
|
||||
recentBooks.push_back(book);
|
||||
}
|
||||
}
|
||||
} else if (version == 3) {
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
|
||||
recentBooks.clear();
|
||||
recentBooks.reserve(count);
|
||||
uint8_t omitted = 0;
|
||||
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path, title, author, coverBmpPath;
|
||||
serialization::readString(inputFile, path);
|
||||
serialization::readString(inputFile, title);
|
||||
serialization::readString(inputFile, author);
|
||||
serialization::readString(inputFile, coverBmpPath);
|
||||
|
||||
// Omit books with missing title (e.g. saved before metadata was available)
|
||||
if (title.empty()) {
|
||||
omitted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
recentBooks.push_back({path, title, author, coverBmpPath});
|
||||
}
|
||||
|
||||
if (omitted > 0) {
|
||||
// Explicitly close() file before saveToFile() rewrites the same file
|
||||
inputFile.close();
|
||||
saveToFile();
|
||||
LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast<int>(recentBooks.size()));
|
||||
return true;
|
||||
}
|
||||
|
||||
+13
-19
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
#include <PersistableStore.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -11,24 +14,21 @@ struct RecentBook {
|
||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||
};
|
||||
|
||||
class RecentBooksStore;
|
||||
namespace JsonSettingsIO {
|
||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
class RecentBooksStore {
|
||||
// Static instance
|
||||
static RecentBooksStore instance;
|
||||
|
||||
class RecentBooksStore : public PersistableStore<RecentBooksStore> {
|
||||
private:
|
||||
std::vector<RecentBook> recentBooks;
|
||||
|
||||
friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*);
|
||||
static constexpr int MAX_RECENT_BOOKS = 10;
|
||||
|
||||
public:
|
||||
RecentBooksStore() = default;
|
||||
~RecentBooksStore() = default;
|
||||
|
||||
// Get singleton instance
|
||||
static RecentBooksStore& getInstance() { return instance; }
|
||||
friend class PersistableStore<RecentBooksStore>;
|
||||
|
||||
public:
|
||||
static const char* getFilePath() { return "/.crosspoint/recent.json"; }
|
||||
void toJson(JsonDocument& doc) const;
|
||||
bool fromJson(JsonVariantConst doc);
|
||||
|
||||
// Add a book to the recent list (moves to front if already exists)
|
||||
void addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
@@ -61,13 +61,7 @@ class RecentBooksStore {
|
||||
// Get the count of recent books
|
||||
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
||||
|
||||
bool saveToFile() const;
|
||||
|
||||
bool loadFromFile();
|
||||
RecentBook getDataFromBook(std::string path) const;
|
||||
|
||||
private:
|
||||
bool loadFromBinaryFile();
|
||||
};
|
||||
|
||||
// Helper macro to access recent books store
|
||||
|
||||
@@ -34,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
||||
manager_.unloadAll(renderer);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
return;
|
||||
}
|
||||
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||
@@ -96,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
|
||||
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
||||
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
||||
|
||||
@@ -6,3 +6,7 @@
|
||||
|
||||
void silentRestart(); // home screen
|
||||
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
|
||||
// True when this boot itself came from a silent restart. Callers that restart
|
||||
// as a last-resort defrag must check this so a failure that survives the
|
||||
// restart degrades to an error instead of a reboot loop.
|
||||
bool bootWasSilentRestart();
|
||||
|
||||
+27
-84
@@ -1,103 +1,46 @@
|
||||
#include "WifiCredentialStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
// Initialize the static instance
|
||||
WifiCredentialStore WifiCredentialStore::instance;
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
// File format version (for binary migration)
|
||||
constexpr uint8_t WIFI_FILE_VERSION = 2;
|
||||
void WifiCredentialStore::toJson(JsonDocument& doc) const {
|
||||
doc["lastConnectedSsid"] = lastConnectedSsid;
|
||||
|
||||
// File paths
|
||||
constexpr char WIFI_FILE_BIN[] = "/.crosspoint/wifi.bin";
|
||||
constexpr char WIFI_FILE_JSON[] = "/.crosspoint/wifi.json";
|
||||
constexpr char WIFI_FILE_BAK[] = "/.crosspoint/wifi.bin.bak";
|
||||
|
||||
// Legacy obfuscation key - "CrossPoint" in ASCII (only used for binary migration)
|
||||
constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74};
|
||||
constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY);
|
||||
|
||||
void legacyDeobfuscate(std::string& data) {
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH];
|
||||
JsonArray arr = doc["credentials"].to<JsonArray>();
|
||||
for (const auto& cred : credentials) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["ssid"] = cred.ssid;
|
||||
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool WifiCredentialStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveWifi(*this, WIFI_FILE_JSON);
|
||||
}
|
||||
|
||||
bool WifiCredentialStore::loadFromFile() {
|
||||
// Try JSON first
|
||||
if (Storage.exists(WIFI_FILE_JSON)) {
|
||||
String json = Storage.readFile(WIFI_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadWifi(*this, json.c_str(), &resave);
|
||||
if (result && resave) {
|
||||
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to binary migration
|
||||
if (Storage.exists(WIFI_FILE_BIN)) {
|
||||
if (loadFromBinaryFile()) {
|
||||
if (saveToFile()) {
|
||||
Storage.rename(WIFI_FILE_BIN, WIFI_FILE_BAK);
|
||||
LOG_DBG("WCS", "Migrated wifi.bin to wifi.json");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("WCS", "Failed to save wifi during migration");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool WifiCredentialStore::loadFromBinaryFile() {
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("WCS", WIFI_FILE_BIN, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version > WIFI_FILE_VERSION) {
|
||||
LOG_DBG("WCS", "Unknown file version: %u", version);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (version >= 2) {
|
||||
serialization::readString(file, lastConnectedSsid);
|
||||
} else {
|
||||
lastConnectedSsid.clear();
|
||||
}
|
||||
|
||||
uint8_t count;
|
||||
serialization::readPod(file, count);
|
||||
bool WifiCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
lastConnectedSsid = doc["lastConnectedSsid"] | "";
|
||||
|
||||
// Tolerate a missing/invalid 'credentials' key (treat as empty list); only
|
||||
// a JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||
credentials.clear();
|
||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
||||
JsonArrayConst arr = doc["credentials"].as<JsonArrayConst>();
|
||||
credentials.reserve(std::min(arr.size(), MAX_NETWORKS));
|
||||
bool needsResave = false;
|
||||
|
||||
for (JsonObjectConst obj : arr) {
|
||||
if (credentials.size() >= MAX_NETWORKS) break;
|
||||
WifiCredential cred;
|
||||
serialization::readString(file, cred.ssid);
|
||||
serialization::readString(file, cred.password);
|
||||
legacyDeobfuscate(cred.password);
|
||||
cred.ssid = obj["ssid"] | "";
|
||||
cred.password = extractPassword(obj, needsResave);
|
||||
credentials.push_back(cred);
|
||||
}
|
||||
|
||||
// LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size());
|
||||
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
#include <PersistableStore.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -7,21 +10,14 @@ struct WifiCredential {
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
};
|
||||
|
||||
class WifiCredentialStore;
|
||||
namespace JsonSettingsIO {
|
||||
bool saveWifi(const WifiCredentialStore& store, const char* path);
|
||||
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave);
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
/**
|
||||
* Singleton class for storing WiFi credentials on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||
* but prevents casual reading and ties credentials to the specific device).
|
||||
*/
|
||||
class WifiCredentialStore {
|
||||
class WifiCredentialStore : public PersistableStore<WifiCredentialStore> {
|
||||
private:
|
||||
static WifiCredentialStore instance;
|
||||
std::vector<WifiCredential> credentials;
|
||||
std::string lastConnectedSsid;
|
||||
|
||||
@@ -30,22 +26,12 @@ class WifiCredentialStore {
|
||||
// Private constructor for singleton
|
||||
WifiCredentialStore() = default;
|
||||
|
||||
bool loadFromBinaryFile();
|
||||
|
||||
friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*);
|
||||
friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*);
|
||||
friend class PersistableStore<WifiCredentialStore>;
|
||||
|
||||
public:
|
||||
// Delete copy constructor and assignment
|
||||
WifiCredentialStore(const WifiCredentialStore&) = delete;
|
||||
WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
|
||||
|
||||
// Get singleton instance
|
||||
static WifiCredentialStore& getInstance() { return instance; }
|
||||
|
||||
// Save/load from SD card
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
static const char* getFilePath() { return "/.crosspoint/wifi.json"; }
|
||||
void toJson(JsonDocument& doc) const;
|
||||
bool fromJson(JsonVariantConst doc);
|
||||
|
||||
// Credential management
|
||||
bool addCredential(const std::string& ssid, const std::string& password);
|
||||
|
||||
@@ -44,6 +44,17 @@ class Activity {
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
virtual bool isReaderActivity() const { return false; }
|
||||
// True if this activity needs the BLE stack resident (beyond the readers, which are
|
||||
// covered by isReaderActivity()). The Bluetooth settings screen overrides this so
|
||||
// pairing/scanning works there. Everywhere else BLE is torn down to free heap.
|
||||
virtual bool keepsBluetoothAlive() const { return false; }
|
||||
// True while the current activity is doing heap-heavy work that must finish
|
||||
// before the BLE stack (~52 KB) may start.
|
||||
virtual bool deferBluetoothStart() const { return false; }
|
||||
// Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather
|
||||
// than a fast/partial one. Used after drawing a transient popup over grayscale content
|
||||
// (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting.
|
||||
virtual void requestGhostCleanup() {}
|
||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||
|
||||
// Start a new activity without destroying the current one
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle // Task handle
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -46,10 +49,10 @@ void ActivityManager::renderTaskLoop() {
|
||||
}
|
||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||
TaskHandle_t waiter = nullptr;
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
waiter = waitingTaskHandle;
|
||||
waitingTaskHandle = nullptr;
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
if (waiter) {
|
||||
xTaskNotify(waiter, 1, eIncrement);
|
||||
}
|
||||
@@ -137,8 +140,7 @@ void ActivityManager::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedUpdate) {
|
||||
requestedUpdate = false;
|
||||
if (requestedUpdate.exchange(false)) {
|
||||
// Using direct notification to signal the render task to update
|
||||
// Increment counter so multiple rapid calls won't be lost
|
||||
if (renderTaskHandle) {
|
||||
@@ -254,6 +256,25 @@ bool ActivityManager::isReaderActivity() const {
|
||||
(currentActivity && currentActivity->isReaderActivity());
|
||||
}
|
||||
|
||||
bool ActivityManager::currentKeepsBluetoothAlive() const {
|
||||
return currentActivity && currentActivity->keepsBluetoothAlive();
|
||||
}
|
||||
|
||||
void ActivityManager::requestGhostCleanup() {
|
||||
if (currentActivity) currentActivity->requestGhostCleanup();
|
||||
}
|
||||
|
||||
bool ActivityManager::bluetoothShouldBeActive() const {
|
||||
const auto wants = [](const auto& activity) {
|
||||
return activity && (activity->isReaderActivity() || activity->keepsBluetoothAlive());
|
||||
};
|
||||
return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity);
|
||||
}
|
||||
|
||||
bool ActivityManager::bluetoothStartDeferred() const {
|
||||
return currentActivity && currentActivity->deferBluetoothStart();
|
||||
}
|
||||
|
||||
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
||||
|
||||
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
|
||||
@@ -280,7 +301,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
}
|
||||
|
||||
// Atomic section to perform checks
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||
@@ -289,7 +310,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||
waitingTaskHandle = currTaskHandler;
|
||||
}
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
|
||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -63,7 +64,7 @@ class ActivityManager {
|
||||
|
||||
// Whether to trigger a render after the current loop()
|
||||
// This variable must only be set by the main loop, to avoid race conditions
|
||||
bool requestedUpdate = false;
|
||||
std::atomic<bool> requestedUpdate{false};
|
||||
|
||||
public:
|
||||
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
@@ -101,6 +102,16 @@ class ActivityManager {
|
||||
|
||||
bool preventAutoSleep() const;
|
||||
bool isReaderActivity() const;
|
||||
bool currentKeepsBluetoothAlive() const;
|
||||
// True if BLE should be resident for the current context: any reader (page-turner
|
||||
// input) or the Bluetooth settings screen (pairing) is on the stack.
|
||||
bool bluetoothShouldBeActive() const;
|
||||
// True while the CURRENT activity is mid heap-heavy work that must complete before
|
||||
// NimBLE may start (see Activity::deferBluetoothStart). Current only, not the
|
||||
// stack: a reader stacked under a menu has its loop() paused, so its build never
|
||||
// advances — a stack-wide check would hold BLE off for as long as the menu stays
|
||||
// open.
|
||||
bool bluetoothStartDeferred() const;
|
||||
bool skipLoopDelay() const;
|
||||
ScreenshotInfo getScreenshotInfo() const;
|
||||
|
||||
@@ -108,6 +119,9 @@ class ActivityManager {
|
||||
// Otherwise, it will be deferred until the end of the current loop iteration.
|
||||
void requestUpdate(bool immediate = false);
|
||||
|
||||
// Ask the current activity to make its next render a ghost-cleanup (HALF) refresh.
|
||||
void requestGhostCleanup();
|
||||
|
||||
// Trigger a render and block until it completes.
|
||||
// Must NOT be called from the render task or while holding a RenderLock.
|
||||
void requestUpdateAndWait();
|
||||
|
||||
@@ -43,6 +43,10 @@ struct PageResult {
|
||||
struct ProgressChangeResult {
|
||||
int spineIndex = 0;
|
||||
int page = 0;
|
||||
int totalPages = 0;
|
||||
std::string xpath;
|
||||
float percentage = 0.0f;
|
||||
bool hasSavedProgress = false;
|
||||
};
|
||||
|
||||
enum class NetworkMode;
|
||||
|
||||
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
@@ -219,13 +219,11 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
// OEM grayscale pipeline base: on X3 this displays the frame with the
|
||||
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
|
||||
// in the calibrated state the gray nudge refresh expects; on X4 it is a
|
||||
// plain HALF refresh (previous behavior).
|
||||
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
|
||||
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
|
||||
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
|
||||
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
@@ -333,5 +331,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
|
||||
|
||||
void SleepActivity::renderBlankSleepScreen() const {
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
std::string getFileExtension(const std::string& filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -410,7 +410,8 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
||||
startY += height10 + metrics.verticalSpacing * 2;
|
||||
|
||||
// Show QR code for Wifi
|
||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||
// follows spec at https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
|
||||
const std::string wifiConfig = std::string("WIFI:T:nopass;S:") + connectedSSID + ";;";
|
||||
const Rect qrBoundsWifi(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT);
|
||||
QrUtils::drawQrCode(renderer, qrBoundsWifi, wifiConfig);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "BleInput.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
@@ -93,6 +94,11 @@ void WifiSelectionActivity::startWifiScan() {
|
||||
networks.clear();
|
||||
requestUpdate();
|
||||
|
||||
// Free the BLE stack before bringing WiFi up: the C3 has one radio and the two
|
||||
// stacks can't both fit in heap. Reachable from the reader via KOReader sync, where
|
||||
// BLE is still resident; a no-op when BLE is already off (launched from Settings).
|
||||
bleinput::stop();
|
||||
|
||||
// Set WiFi mode to station
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.disconnect();
|
||||
@@ -211,6 +217,7 @@ void WifiSelectionActivity::attemptConnection() {
|
||||
connectionError.clear();
|
||||
requestUpdate();
|
||||
|
||||
bleinput::stop(); // free the BLE stack before WiFi (shared C3 radio, tight heap)
|
||||
WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "EndOfBookOptions.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
#include "util/NextBookFinder.h"
|
||||
|
||||
namespace {
|
||||
// Display name without the file extension, mirroring the file browser rows
|
||||
std::string displayName(const std::string& filename) {
|
||||
const auto pos = filename.rfind('.');
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
|
||||
if (isLoaded.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
folder = FsHelpers::extractFolderPath(currentBookPath);
|
||||
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
|
||||
selector = 0;
|
||||
// Release-publish so the main task, which gates all access on isLoaded, never
|
||||
// observes a partially built list
|
||||
isLoaded.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
|
||||
|
||||
std::string EndOfBookOptions::fullPath(const size_t index) const {
|
||||
if (index >= names.size()) {
|
||||
return {};
|
||||
}
|
||||
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
|
||||
}
|
||||
|
||||
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
|
||||
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selector < static_cast<int>(names.size())) {
|
||||
if (openPath) {
|
||||
*openPath = fullPath(selector);
|
||||
}
|
||||
return Action::OpenBook;
|
||||
}
|
||||
return Action::GoHome; // "Home" entry selected
|
||||
}
|
||||
|
||||
// Short-press Back returns to the last page; a long press falls through to the
|
||||
// reader's own handler (file browser). Home is reached through the list's Home entry.
|
||||
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
return Action::LastPage;
|
||||
}
|
||||
|
||||
// Selection movement on the standard list navigation buttons (side Up/Down plus front
|
||||
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
|
||||
// (press-triggered by default, release-triggered when a long-press behavior is
|
||||
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
|
||||
// press-triggered turns, the press that turned the final page already fired in the
|
||||
// reader, and its release must not double-fire into this menu.
|
||||
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
|
||||
const auto triggered = [&](const MappedInputManager::Button button) {
|
||||
return usePress ? input.wasPressed(button) : input.wasReleased(button);
|
||||
};
|
||||
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
|
||||
if (triggered(MappedInputManager::Button::NavPrevious)) {
|
||||
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
|
||||
return Action::Redraw;
|
||||
}
|
||||
if (triggered(MappedInputManager::Button::NavNext)) {
|
||||
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
|
||||
return Action::Redraw;
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
if (!menuActive()) {
|
||||
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
|
||||
// the previous fixed position on the 480x800 panel and scales to other resolutions.
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
|
||||
EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
|
||||
// the physical front buttons, which is a logical side/top edge in the rotated
|
||||
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
|
||||
// positions derive from the safe-area height and font line heights so other panel
|
||||
// resolutions scale (review request on #2532).
|
||||
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int titleY = safe.y + safe.height / 8;
|
||||
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
|
||||
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
|
||||
|
||||
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
|
||||
|
||||
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
|
||||
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
|
||||
[this](const int index) {
|
||||
return index < static_cast<int>(names.size()) ? displayName(names[index])
|
||||
: std::string(tr(STR_EOB_HOME));
|
||||
});
|
||||
|
||||
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
class MappedInputManager;
|
||||
|
||||
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
|
||||
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
|
||||
// draws the end screen. With no suggestions the end screen keeps its historical
|
||||
// plain-title look and behavior.
|
||||
class EndOfBookOptions {
|
||||
public:
|
||||
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
|
||||
|
||||
static constexpr size_t MAX_SUGGESTIONS = 3;
|
||||
|
||||
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
|
||||
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
|
||||
// is the release/acquire publication point that lets the main task read the finished
|
||||
// list safely.
|
||||
void loadOnce(const std::string& currentBookPath);
|
||||
|
||||
// True when the suggestion menu is showing and should own the reader's input.
|
||||
bool menuActive() const;
|
||||
|
||||
// Menu input handling, following the standard list idiom: side Up/Down and front
|
||||
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
|
||||
// Back press returns to the last page of the book. Fills openPath when the result is
|
||||
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
|
||||
// their normal input path (keeping long-press Back to the file browser working).
|
||||
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
|
||||
|
||||
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
|
||||
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
|
||||
|
||||
private:
|
||||
std::string folder;
|
||||
// Written by the render task in loadOnce(), immutable afterwards; the main task only
|
||||
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
|
||||
std::vector<std::string> names;
|
||||
int selector = 0;
|
||||
std::atomic<bool> isLoaded{false};
|
||||
|
||||
std::string fullPath(size_t index) const;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user