Compare commits

..
Author SHA1 Message Date
Justin Mitchell 4e12083566 Add anti-aliasing mode translations (Full/Fast)
Add STR_TEXT_AA_FULL and STR_TEXT_AA_FAST translation strings across all supported languages. Also includes a complete retranslation of the Slovak language file.
2026-07-06 19:04:00 -04:00
Justin Mitchell cf4f7be8af Reformat anti-aliasing condition for readability
Consolidate multi-line conditional statement in GfxRenderer onto fewer lines without changing logic
2026-07-06 19:01:21 -04:00
Justin Mitchell e69241ef54 Reverts UI font change and adds setting for AA sd fonts 2026-07-06 18:59:25 -04:00
Justin Mitchell 72b99a2a9c Enable compression for ui fonts
Add --compress flag to font generation command and regenerate the font bitmap data with compression enabled. This reduces the bitmap array size while maintaining the same 2-bit mode and character intervals.
2026-07-05 19:46:43 -04:00
Justin Mitchell 62448b3c08 Add anti-aliased font rendering with dithering
Replace solid black rendering of anti-aliased font edges with period-2 dithering patterns. Full coverage pixels render as solid ink, while partial coverage pixels use checkerboard patterns (50% for dark grey, 25% for light grey) matching fillRectDither behavior. This prevents edge pixels from bolding to black on BW displays and produces proper anti-aliasing for 2-bit fonts.
2026-07-05 16:06:56 -04:00
96 changed files with 1355 additions and 2991 deletions
-6
View File
@@ -23,9 +23,3 @@ 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
-2
View File
@@ -8,8 +8,6 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
![CrossPoint Reader running on Xteink device](./docs/images/cover.jpg)
> 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.
+10 -20
View File
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 29
### Version 28
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 29 includes:
Version 28 includes:
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
image rendering mode, and Focus Reading
@@ -107,10 +107,6 @@ Version 29 includes:
- 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:
@@ -119,7 +115,7 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 29
#define EXPECTED_VERSION 28
#define MAX_STRING_LENGTH 65535
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 96
@@ -180,20 +176,14 @@ struct BlockStyle {
struct TextBlock {
u16 wordCount;
u8 hasFocus;
u16 textBytes [[comment("Total size of text[], including one NUL per word")]];
String words[wordCount];
s16 wordXPos[wordCount];
WordStyle wordStyle[wordCount];
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")]];
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")]];
}
BlockStyle blockStyle;
+20 -26
View File
@@ -33,24 +33,12 @@ void FontDecompressor::freePageBuffer() {
}
void FontDecompressor::freeHotGroup() {
free(hotGroup);
hotGroup = nullptr;
hotGroupCapacity = 0;
hotGroup.clear();
hotGroup.shrink_to_fit();
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
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;
hotGlyphBuf.clear();
hotGlyphBuf.shrink_to_fit();
}
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
@@ -182,20 +170,24 @@ 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 != nullptr && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
stats.cacheMisses++;
const EpdFontGroup& group = fontData->groups[groupIndex];
// 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)) {
hotGroup.resize(group.uncompressedSize);
if (hotGroup.empty()) {
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, group.uncompressedSize)) {
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
hotGroup.clear();
hotGroup.shrink_to_fit();
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
@@ -208,16 +200,18 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
}
// Compact just the requested glyph from byte-aligned data into scratch buffer
if (!ensureCapacity(hotGlyphBuf, hotGlyphBufCapacity, glyph->dataLength)) {
LOG_ERR("FDC", "Failed to allocate %u bytes for glyph scratch", (unsigned)glyph->dataLength);
if (glyph->dataLength > hotGlyphBuf.size()) {
hotGlyphBuf.resize(glyph->dataLength);
}
if (hotGlyphBuf.empty()) {
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf, glyph->width, glyph->height);
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
stats.getBitmapTimeUs += micros() - tStart;
return hotGlyphBuf;
return hotGlyphBuf.data();
}
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
+5 -12
View File
@@ -2,6 +2,8 @@
#include <InflateReader.h>
#include <vector>
#include "EpdFontData.h"
class FontDecompressor {
@@ -65,22 +67,13 @@ 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;
uint8_t* hotGroup = nullptr; // owned; freed in freeHotGroup()/dtor
uint32_t hotGroupCapacity = 0;
std::vector<uint8_t> hotGroup;
// Scratch buffer for compacting a single glyph from the hot group.
// 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);
// Valid until the next getBitmap() call.
std::vector<uint8_t> hotGlyphBuf;
void freePageBuffer();
void freeHotGroup();
+17 -44
View File
@@ -68,22 +68,6 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
const char* asCStr(const std::string& s) { return s.c_str(); }
const char* asCStr(const char* s) { return s; }
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
// current capacity. Freeing + reallocating slightly different sizes every page
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
// next page's need), eroding the largest contiguous block all session. With
// reuse, capacities converge on the book's max page after a few turns and page
// turns stop touching the allocator. Only three small instantiations exist
// (interval/glyph/byte arrays), so template bloat is negligible.
template <typename T, typename CapT>
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
if (buf && capacity >= needed) return true;
delete[] buf;
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
capacity = buf ? static_cast<CapT>(needed) : 0;
return buf != nullptr;
}
} // namespace
SdCardFont::~SdCardFont() { freeAll(); }
@@ -99,9 +83,6 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
s.miniBitmap = nullptr;
s.miniIntervalCount = 0;
s.miniGlyphCount = 0;
s.miniIntervalCapacity = 0;
s.miniGlyphCapacity = 0;
s.miniBitmapCapacity = 0;
freeStyleMiniKern(s);
memset(&s.miniData, 0, sizeof(s.miniData));
s.epdFont.data = &s.stubData;
@@ -128,9 +109,6 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
s.miniKernRightEntryCount = 0;
s.miniKernLeftClassCount = 0;
s.miniKernRightClassCount = 0;
s.miniKernLeftCapacity = 0;
s.miniKernRightCapacity = 0;
s.miniKernMatrixCapacity = 0;
}
void SdCardFont::freeStyleAll(PerStyle& s) {
@@ -333,13 +311,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
}
// Step 4: size the three mini buffers (reused across pages when they fit; the
// per-page sizes vary by a few entries, which as free+realloc churn was punching
// non-coalescing holes in the heap every page turn).
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
matrixBytes);
freeStyleMiniKern(s);
@@ -815,19 +793,12 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
return missed;
}
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
s.miniIntervalCount = 0;
s.miniGlyphCount = 0;
s.miniKernLeftEntryCount = 0;
s.miniKernRightEntryCount = 0;
s.miniKernLeftClassCount = 0;
s.miniKernRightClassCount = 0;
memset(&s.miniData, 0, sizeof(s.miniData));
s.epdFont.data = &s.stubData;
// Build mini intervals from sorted codepoints
freeStyleMiniData(s);
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
uint32_t intervalCapacity = validCount;
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
if (!s.miniIntervals) {
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
delete[] mappings;
return static_cast<int>(cpCount);
@@ -845,14 +816,15 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
}
}
// Mini glyph array (reused across pages when it fits)
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
// Allocate mini glyph array
s.miniGlyphCount = validCount;
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
if (!s.miniGlyphs) {
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
s.miniGlyphCount = validCount;
// Build sorted read order for sequential I/O
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
@@ -919,7 +891,8 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
totalBitmapSize += s.miniGlyphs[i].dataLength;
}
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
if (!s.miniBitmap) {
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
delete[] readOrder;
delete[] mappings;
+1 -14
View File
@@ -163,22 +163,13 @@ class SdCardFont {
// Stub EpdFontData returned when not prewarmed
EpdFontData stubData{};
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
// (capacities below track allocated sizes): freeing and reallocating slightly
// different sizes on every page turn was a primary heap fragmenter — each page's
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
// After a few pages the capacities converge on the book's max and page turns
// stop allocating entirely. freeStyleMiniData() still releases everything (and
// zeroes capacities) for style eviction / font unload.
// Mini EpdFontData built during prewarm
EpdFontData miniData{};
EpdUnicodeInterval* miniIntervals = nullptr;
EpdGlyph* miniGlyphs = nullptr;
uint8_t* miniBitmap = nullptr;
uint32_t miniIntervalCount = 0;
uint32_t miniGlyphCount = 0;
uint32_t miniIntervalCapacity = 0;
uint32_t miniGlyphCapacity = 0;
uint32_t miniBitmapCapacity = 0;
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
@@ -193,10 +184,6 @@ class SdCardFont {
uint8_t miniKernLeftClassCount = 0;
uint8_t miniKernRightClassCount = 0;
int8_t* miniKernMatrix = nullptr;
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
uint16_t miniKernLeftCapacity = 0;
uint16_t miniKernRightCapacity = 0;
uint32_t miniKernMatrixCapacity = 0;
// The EpdFont whose data pointer we manage
EpdFont epdFont{&stubData};
+1 -21
View File
@@ -39,17 +39,7 @@ std::unique_ptr<PageLine> PageLine::deserialize(HalFile& file) {
serialization::readPod(file, yPos);
auto tb = TextBlock::deserialize(file);
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);
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
@@ -158,10 +148,6 @@ 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;
@@ -169,15 +155,9 @@ 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);
+4 -15
View File
@@ -2,7 +2,6 @@
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Utf8.h>
#include <algorithm>
@@ -1134,14 +1133,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
if (!lineHasFocusSplit) {
// 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));
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
return;
}
@@ -1186,10 +1179,6 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
}
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));
processLine(std::make_shared<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
}
+6 -11
View File
@@ -11,9 +11,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// 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;
// v28: text decoration bits now include line-through in serialized wordStyles.
constexpr uint8_t SECTION_FILE_VERSION = 28;
// 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
@@ -26,11 +25,7 @@ constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
// 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 uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE;
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) +
@@ -738,10 +733,10 @@ std::string Section::getTextFromSectionFile() {
if (el->getTag() == TAG_PageLine) {
const auto& line = static_cast<const PageLine&>(*el);
if (line.getBlock()) {
const auto& block = *line.getBlock();
for (uint16_t i = 0; i < block.wordCount(); i++) {
const auto& words = line.getBlock()->getWords();
for (const auto& w : words) {
if (!fullText.empty()) fullText += " ";
fullText += block.wordText(i);
fullText += w;
}
}
}
-8
View File
@@ -32,11 +32,6 @@ 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; }
@@ -97,9 +92,6 @@ 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;
}
+72 -183
View File
@@ -3,114 +3,19 @@
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Memory.h>
#include <Serialization.h>
#include <cstring>
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) {
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// 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 = !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");
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());
return;
}
@@ -149,13 +54,12 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
}
};
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);
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;
// 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:
@@ -178,15 +82,14 @@ 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), static_cast<size_t>(wordTextLen(i)), sizeof(boldBuf) - 1});
memcpy(boldBuf, word, boldLen);
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);
boldBuf[boldLen] = '\0';
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir);
const int suffixX = wordX + focusSuffixXArr[i];
renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir);
const int suffixX = wordX + wordFocusSuffixX[i];
renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir);
} else {
renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir);
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
}
if (scanning) {
@@ -194,17 +97,18 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
}
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
const std::string& w = words[i];
int lineStartX = wordX;
int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir);
int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
lineWidth = (lineWidth + 1) / 2;
}
// 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;
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
static_cast<uint8_t>(w[2]) == 0x83) {
const char* visibleText = w.c_str() + 3;
lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir);
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
@@ -236,23 +140,29 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
}
bool TextBlock::serialize(HalFile& file) const {
if (!isValid) {
LOG_ERR("TXB", "Serialization failed: invalid block");
// 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()));
return false;
}
// 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;
}
// 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);
}
// Style (alignment + margins/padding/indent)
@@ -276,64 +186,41 @@ bool TextBlock::serialize(HalFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
uint16_t wc;
uint8_t hasFocus;
uint16_t textBytes;
serialization::readPod(file, wc);
serialization::readPod(file, hasFocus);
serialization::readPod(file, textBytes);
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;
// Sanity checks: cap the arena allocation and reject impossible geometry
// (every word carries at least its NUL terminator).
// Word count
serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
if (wc > 10000) {
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
return nullptr;
}
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;
}
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;
}
}
// 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);
}
// Style (alignment + margins/padding/indent)
BlockStyle& blockStyle = block->blockStyle;
serialization::readPod(file, blockStyle.alignment);
serialization::readPod(file, blockStyle.textAlignDefined);
serialization::readPod(file, blockStyle.marginTop);
@@ -349,5 +236,7 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
serialization::readPod(file, blockStyle.isRtl);
serialization::readPod(file, blockStyle.directionDefined);
return block;
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
blockStyle));
}
+28 -69
View File
@@ -9,83 +9,42 @@
#include "Block.h"
#include "BlockStyle.h"
// 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).
// Represents a line of text on a page
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:
// 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());
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) {}
~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; }
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; }
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
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; }
bool serialize(HalFile& file) const;
@@ -239,16 +239,7 @@ 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();
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));
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
flushPendingAnchor();
return;
@@ -864,14 +855,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
// 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);
self->startNewTextBlock(self->blockStyleStack.back().withoutBottom());
} else {
self->currentCssStyle = cssStyle;
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle,
+8 -14
View File
@@ -62,14 +62,7 @@ void FontCacheManager::resetStats() {
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
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';
}
scanText_ += text;
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);
@@ -87,15 +80,15 @@ FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manage
manager_->scanMode_ = ScanMode::Scanning;
manager_->clearCache();
manager_->resetStats();
manager_->scanTextLen_ = 0;
manager_->scanText_[0] = '\0';
manager_->scanText_.clear();
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
manager_->scanFontId_ = -1;
}
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
manager_->scanMode_ = ScanMode::None;
if (manager_->scanTextLen_ == 0) return;
if (manager_->scanText_.empty()) return;
// Build style bitmask from all styles that appeared during the scan
uint8_t styleMask = 0;
@@ -104,10 +97,11 @@ void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
}
if (styleMask == 0) styleMask = 1; // default to regular
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_, styleMask);
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
manager_->scanTextLen_ = 0;
manager_->scanText_[0] = '\0';
// Free scan string memory
manager_->scanText_.clear();
manager_->scanText_.shrink_to_fit();
}
FontCacheManager::PrewarmScope::~PrewarmScope() {
+2 -4
View File
@@ -2,9 +2,9 @@
#include <EpdFontFamily.h>
#include <cstddef>
#include <cstdint>
#include <map>
#include <string>
class FontDecompressor;
class SdCardFont;
@@ -51,9 +51,7 @@ class FontCacheManager {
enum class ScanMode : uint8_t { None, Scanning };
ScanMode scanMode_ = ScanMode::None;
static constexpr size_t SCAN_TEXT_CAPACITY = 2048;
char scanText_[SCAN_TEXT_CAPACITY] = {};
size_t scanTextLen_ = 0;
std::string scanText_;
uint32_t scanStyleCounts_[4] = {};
int scanFontId_ = -1;
};
+11 -16
View File
@@ -91,20 +91,6 @@ 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) {
@@ -296,8 +282,17 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
// Black (also paints over the grays in BW mode)
renderer.drawPixel(screenX, screenY, pixelState);
// Default: gray pixels paint solid black. Required when grayscale
// passes follow (their LUT lightens pixels it assumes were just
// driven solid black), and it is the plain non-AA look otherwise.
// Fast AA: partial (anti-aliased edge) coverage dithers with the
// same period-2 patterns as fillRectDither (dark grey = 50%
// checkerboard, light grey = 25%), approximating AA in a single
// BW pass. Skipped pixels leave the background untouched.
if (!renderer.isFastAntiAliasing() || bmpVal == 0 || (bmpVal == 1 && ((screenX + screenY) & 1) == 0) ||
(bmpVal == 2 && (screenX & 1) == 0 && (screenY & 1) == 0)) {
renderer.drawPixel(screenX, screenY, pixelState);
}
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
// Light gray (also mark the MSB if it's going to be a dark gray too)
// Dedicated X3 gray LUTs now provide proper 4-level gray on both devices
+9 -7
View File
@@ -44,6 +44,13 @@ class GfxRenderer {
RenderMode renderMode;
Orientation orientation;
bool fadingFix;
// Fast text anti-aliasing: when set, BW-mode rendering of 2-bit glyphs
// dithers partial-coverage (gray) pixels instead of painting them solid
// black, approximating AA in a single BW pass with no grayscale refresh.
// Must stay false when grayscale LSB/MSB passes follow the BW render: the
// gray LUT lightens pixels it assumes were just driven solid black, and
// dither-skipped white pixels would take its white-drive frames unopposed.
bool fastAntiAliasing = false;
uint8_t* frameBuffer = nullptr;
uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH;
uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT;
@@ -224,6 +231,8 @@ class GfxRenderer {
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
RenderMode getRenderMode() const { return renderMode; }
void setFastAntiAliasing(const bool enabled) { this->fastAntiAliasing = enabled; }
bool isFastAntiAliasing() const { return fastAntiAliasing; }
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
// takes the gray region in LOGICAL screen coordinates and rotates it to the
// panel; the no-arg overload settles the full frame. Call after the BW base
@@ -250,13 +259,6 @@ 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;
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Рэжым вокладкі сну"
STR_HIDE_BATTERY: "Схаваць % батарэі"
STR_EXTRA_SPACING: "Дадат. інтэрвал абзаца"
STR_TEXT_AA: "Згладжванне тэксту"
STR_TEXT_AA_FULL: "Поўнае"
STR_TEXT_AA_FAST: "Хуткае"
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
STR_ORIENTATION: "Арыентацыя чытання"
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
STR_HIDE_BATTERY: "Oculta el % de bateria"
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
STR_TEXT_AA: "Antialiàsing del text"
STR_TEXT_AA_FULL: "Complet"
STR_TEXT_AA_FAST: "Ràpid"
STR_IMAGES: "Imatges"
STR_IMAGES_DISPLAY: "Mostrar"
STR_IMAGES_PLACEHOLDER: "Text de mostra"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
STR_HIDE_BATTERY: "Skrýt baterii %"
STR_EXTRA_SPACING: "Extra mezery mezi odstavci"
STR_TEXT_AA: "Vyhlazování textu"
STR_TEXT_AA_FULL: "Plné"
STR_TEXT_AA_FAST: "Rychlé"
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
STR_ORIENTATION: "Orientace čtení"
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Hvile-skærm omslag-tilstand"
STR_HIDE_BATTERY: "Skjul batteri %"
STR_EXTRA_SPACING: "Ekstra afsnitsafstand"
STR_TEXT_AA: "Tekst Anti-Aliasing"
STR_TEXT_AA_FULL: "Fuld"
STR_TEXT_AA_FAST: "Hurtig"
STR_IMAGES: "Billeder"
STR_IMAGES_DISPLAY: "Vis"
STR_IMAGES_PLACEHOLDER: "Pladsholder"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Slaapscherm omslag-modus"
STR_HIDE_BATTERY: "Batterij % verbergen"
STR_EXTRA_SPACING: "Extra regelafstand alinea"
STR_TEXT_AA: "Tekst Anti-Aliasing"
STR_TEXT_AA_FULL: "Volledig"
STR_TEXT_AA_FAST: "Snel"
STR_IMAGES: "Afbeeldingen"
STR_IMAGES_DISPLAY: "Weergave"
STR_IMAGES_PLACEHOLDER: "Placeholder"
+2 -20
View File
@@ -18,7 +18,6 @@ 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"
@@ -67,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
STR_HIDE_BATTERY: "Hide Battery %"
STR_EXTRA_SPACING: "Extra Paragraph Spacing"
STR_TEXT_AA: "Text Anti-Aliasing"
STR_TEXT_AA_FULL: "Full"
STR_TEXT_AA_FAST: "Fast"
STR_IMAGES: "Images"
STR_IMAGES_DISPLAY: "Display"
STR_IMAGES_PLACEHOLDER: "Placeholder"
@@ -289,25 +290,6 @@ 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"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Lepotilanäytön kansitila"
STR_HIDE_BATTERY: "Piilota akun %"
STR_EXTRA_SPACING: "Kappaleiden lisäväli"
STR_TEXT_AA: "Tekstin reunanpehmennys"
STR_TEXT_AA_FULL: "Täysi"
STR_TEXT_AA_FAST: "Nopea"
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
STR_ORIENTATION: "Lukusuunta"
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mode écran de veille"
STR_HIDE_BATTERY: "Masquer % batterie"
STR_EXTRA_SPACING: "Espacement paragraphes"
STR_TEXT_AA: "Lissage du texte"
STR_TEXT_AA_FULL: "Complet"
STR_TEXT_AA_FAST: "Rapide"
STR_IMAGES: "Images"
STR_IMAGES_DISPLAY: "Affichage"
STR_IMAGES_PLACEHOLDER: "Espace réservé"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Standby-Bildmodus"
STR_HIDE_BATTERY: "Batterie % ausblenden"
STR_EXTRA_SPACING: "Absatzabstand"
STR_TEXT_AA: "Schriftglättung"
STR_TEXT_AA_FULL: "Voll"
STR_TEXT_AA_FAST: "Schnell"
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
STR_ORIENTATION: "Leseausrichtung"
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "תצוגת כריכה במצב שינה"
STR_HIDE_BATTERY: "הסתר אחוזי סוללה"
STR_EXTRA_SPACING: "מרווח פסקאות מוגדל"
STR_TEXT_AA: "החלקת קצוות טקסט"
STR_TEXT_AA_FULL: "מלאה"
STR_TEXT_AA_FAST: "מהירה"
STR_IMAGES: "תמונות"
STR_IMAGES_DISPLAY: "הצג תמונות"
STR_IMAGES_PLACEHOLDER: "סמן"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Alvásképernyő borítómód"
STR_HIDE_BATTERY: "Akkumulátor % elrejtése"
STR_EXTRA_SPACING: "Extra bekezdésköz"
STR_TEXT_AA: "Szöveg élsimítás"
STR_TEXT_AA_FULL: "Teljes"
STR_TEXT_AA_FAST: "Gyors"
STR_IMAGES: "Képek"
STR_IMAGES_DISPLAY: "Megjelenítés"
STR_IMAGES_PLACEHOLDER: "Helyőrző"
+2 -3
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Modalità copertina"
STR_HIDE_BATTERY: "Nascondi % batteria"
STR_EXTRA_SPACING: "Spaziatura extra paragrafi"
STR_TEXT_AA: "Anti-aliasing testo"
STR_TEXT_AA_FULL: "Completo"
STR_TEXT_AA_FAST: "Veloce"
STR_IMAGES: "Immagini"
STR_IMAGES_DISPLAY: "Visualizza"
STR_IMAGES_PLACEHOLDER: "Segnaposto"
@@ -383,6 +385,3 @@ 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"
+2
View File
@@ -64,6 +64,8 @@ STR_SLEEP_COVER_MODE: "Ұйқы экраны мұқаба режимі"
STR_HIDE_BATTERY: "Батарея % жасыру"
STR_EXTRA_SPACING: "Қосымша абзац аралығы"
STR_TEXT_AA: "Мәтін сырғытпасы"
STR_TEXT_AA_FULL: "Толық"
STR_TEXT_AA_FAST: "Жылдам"
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
STR_ORIENTATION: "Оқу бағдары"
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Viršelio režimas"
STR_HIDE_BATTERY: "Slėpti baterijos %"
STR_EXTRA_SPACING: "Tarpai tarp pastraipų"
STR_TEXT_AA: "Teksto glotninimas"
STR_TEXT_AA_FULL: "Pilnas"
STR_TEXT_AA_FAST: "Greitas"
STR_IMAGES: "Paveikslėliai"
STR_IMAGES_DISPLAY: "Rodyti"
STR_IMAGES_PLACEHOLDER: "Vietaženklis"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Okładki wygaszacza"
STR_HIDE_BATTERY: "Ukryj % baterii"
STR_EXTRA_SPACING: "Dodatkowe odstępy paragrafów"
STR_TEXT_AA: "Wygładzanie tekstu"
STR_TEXT_AA_FULL: "Pełne"
STR_TEXT_AA_FAST: "Szybkie"
STR_IMAGES: "Obrazki"
STR_IMAGES_DISPLAY: "Pokazuj"
STR_IMAGES_PLACEHOLDER: "Ramki"
+2
View File
@@ -66,6 +66,8 @@ 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_TEXT_AA_FULL: "Completa"
STR_TEXT_AA_FAST: "Rápida"
STR_IMAGES: "Imagens"
STR_IMAGES_DISPLAY: "Exibição"
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
STR_TEXT_AA: "Anti-Aliasing text"
STR_TEXT_AA_FULL: "Complet"
STR_TEXT_AA_FAST: "Rapid"
STR_IMAGES: "Imagini"
STR_IMAGES_DISPLAY: "Afişare"
STR_IMAGES_PLACEHOLDER: "Substituent"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Режим обложки сна"
STR_HIDE_BATTERY: "Скрыть % батареи"
STR_EXTRA_SPACING: "Доп. интервал абзаца"
STR_TEXT_AA: "Сглаживание текста"
STR_TEXT_AA_FULL: "Полное"
STR_TEXT_AA_FAST: "Быстрое"
STR_IMAGES: "Изображения"
STR_IMAGES_DISPLAY: "Показать"
STR_IMAGES_PLACEHOLDER: "Заглушки"
+375 -373
View File
@@ -1,381 +1,383 @@
_language_name: "Slovenčina"
_language_code: "SK"
_order: "24"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "SPÚŠŤANIE"
STR_SLEEPING: "SPÁNOK"
STR_ENTERING_SLEEP: "Prechod do režimu spánku"
STR_BROWSE_FILES: "Prehliadať súbory"
STR_FILE_TRANSFER: "Prenos súborov"
STR_SETTINGS_TITLE: "Nastavenia"
STR_CONTINUE_READING: "Pokračovať v čítaní"
STR_NO_OPEN_BOOK: "Žiadna otvorená kniha"
STR_START_READING: "Začnite čítať nižšie"
STR_NO_FILES_FOUND: "Neboli nájdené žiadne súbory"
STR_SELECT_CHAPTER: "Vybrať kapitolu"
STR_NO_CHAPTERS: "Žiadne kapitoly"
STR_END_OF_BOOK: "Koniec knihy"
STR_EMPTY_CHAPTER: "Prázdna kapitola"
STR_INDEXING: "Indexovanie"
STR_MEMORY_ERROR: "Chyba pamäte"
STR_PAGE_LOAD_ERROR: "Chyba načítania stránky"
STR_EMPTY_FILE: "Prázdny súbor"
STR_OUT_OF_BOUNDS: "Mimo rozsahu"
STR_LOADING: "Načítava sa..."
STR_LOADING_POPUP: "Načítavanie"
STR_WIFI_NETWORKS: "Wi-Fi siete"
STR_NO_NETWORKS: "Nenašli sa žiadne siete"
STR_NETWORKS_FOUND: "Nájdených %zu sietí"
STR_SCANNING: "Skenovanie..."
STR_CONNECTING: "Pripájanie..."
STR_CONNECTED: "Pripojené!"
STR_CONNECTION_FAILED: "Pripojenie zlyhalo"
STR_FORGET_NETWORK: "Zabudnúť sieť?"
STR_SAVE_PASSWORD: "Uložiť heslo na nabudúce?"
STR_PRESS_OK_SCAN: "Stlačte OK pre opätovné skenovanie"
STR_JOIN_NETWORK: "Pripojiť sa k sieti"
STR_CREATE_HOTSPOT: "Vytvoriť hotspot"
STR_JOIN_DESC: "Pripojiť sa k existujúcej Wi-Fi sieti"
STR_HOTSPOT_DESC: "Vytvoriť Wi-Fi sieť, ku ktorej sa môžu pripojiť ostatní"
STR_STARTING_HOTSPOT: "Spúšťanie hotspotu..."
STR_HOTSPOT_MODE: "Režim hotspotu"
STR_CONNECT_WIFI_HINT: "Pripojte svoje zariadenie k tejto Wi-Fi sieti"
STR_OPEN_URL_HINT: "Otvorte túto URL adresu vo svojom prehliadači"
STR_OR_HTTP_PREFIX: "alebo http://"
STR_SCAN_QR_HINT: "alebo naskenujte QR kód telefónom:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_NETWORK_LEGEND: "* = Šifrované | + = Uložené"
STR_MAC_ADDRESS: "MAC adresa:"
STR_CHECKING_WIFI: "Kontrola Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Zadajte heslo Wi-Fi"
STR_TO_PREFIX: "pre"
STR_CALIBRE_RECEIVING: "Prijímanie:"
STR_CALIBRE_RECEIVED: "Prijaté:"
STR_CALIBRE_INSTRUCTION_1: "1) Nainštalujte plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Buďte v rovnakej Wi-Fi sieti"
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odoslať do zariadenia“"
STR_CALIBRE_INSTRUCTION_4: "„Pri odosielaní nechajte túto obrazovku otvorenú“"
STR_CAT_DISPLAY: "Displej"
STR_CAT_READER: "Čítačka"
STR_CAT_CONTROLS: "Ovládanie"
STR_CAT_SYSTEM: "Systém"
STR_SLEEP_SCREEN: "Obrazovka spánku"
STR_QUICK_RESUME_TIMEOUT: "Rýchle obnovenie pri nečinnosti"
STR_SLEEP_COVER_MODE: "Obrazovka spánku režim krytu"
STR_HIDE_BATTERY: "Skryť % batérie"
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
STR_TEXT_AA: "Vyhladzovanie textu"
STR_IMAGES: "Obrázky"
STR_IMAGES_DISPLAY: "Zobraz"
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
STR_IMAGES_SUPPRESS: "Potlačiť"
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
STR_ORIENTATION: "Orientácia čítania"
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
STR_FONT_FAMILY: "Rodina písiem čítačky"
STR_FONT_SIZE: "Veľkosť písma rozhrania"
STR_LINE_SPACING: "Riadkovanie čítačky"
STR_SCREEN_MARGIN: "Okraj obrazovky čítačky"
STR_PARA_ALIGNMENT: "Zarovnanie odsekov čítačky"
STR_HYPHENATION: "Delenie slov"
STR_TIME_TO_SLEEP: "Čas do uspania"
STR_SHOW_HIDDEN_FILES: "Zobraz Skryté Súbory"
STR_REMOVE_READ_FROM_RECENTS: "Odstrániť prečítané knihy zo zoznamu nedávnych"
STR_MOVE_FINISHED_TO_READ: "Presunúť prečítané knihy do priečinka Read"
STR_REFRESH_FREQ: "Frekvencia obnovovania"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Skontrolovať aktualizácie"
STR_LANGUAGE: "Jazyk"
STR_CLEAR_READING_CACHE: "Vymazať vyrovnávaciu pamäť čítania"
STR_USERNAME: "Používateľské meno"
STR_PASSWORD: "Heslo"
STR_SYNC_SERVER_URL: "URL synchronizačného servera"
STR_DOCUMENT_MATCHING: "Párovanie dokumentov"
STR_AUTHENTICATE: "Over"
STR_KOREADER_USERNAME: "Používateľské meno KOReader"
STR_KOREADER_PASSWORD: "Heslo KOReader"
STR_FILENAME: "Názov súboru"
STR_BINARY: "Binárny"
STR_SET_CREDENTIALS_FIRST: "Najprv nastavte prihlasovacie údaje"
STR_WIFI_CONN_FAILED: "Pripojenie k Wi-Fi zlyhalo"
STR_AUTHENTICATING: "Overovanie..."
STR_AUTH_SUCCESS: "Overenie úspešné!"
STR_KOREADER_AUTH: "Overenie KOReader"
STR_SYNC_READY: "Synchronizácia KOReader je pripravená na použitie"
STR_AUTH_FAILED: "Overenie zlyhalo"
STR_DONE: "Hotovo"
STR_CLEAR_CACHE_WARNING_1: "Týmto vymažete všetky údaje kníh vo vyrovnávacej pamäti."
STR_CLEAR_CACHE_WARNING_2: "Všetok priebeh čítania bude stratený!"
STR_CLEAR_CACHE_WARNING_3: "Knihy bude potrebné znova indexovať"
STR_CLEAR_CACHE_WARNING_4: "pri ich opätovnom otvorení."
STR_CLEARING_CACHE: "Mazanie vyrovnávacej pamäte..."
STR_CACHE_CLEARED: "Vyrovnávacia pamäť vymazaná"
STR_ITEMS_REMOVED: "položiek odstránených"
STR_FAILED_LOWER: "zlyhalo"
STR_CLEAR_CACHE_FAILED: "Vymazanie vyrovnávacej pamäte zlyhalo"
STR_CHECK_SERIAL_OUTPUT: "Podrobnosti nájdete v sériovom výstupe"
STR_DARK: "Tmavý"
STR_LIGHT: "Svetlý"
STR_CUSTOM: "Vlastný"
STR_COVER: "Obálka"
STR_NONE_OPT: "Žiadny"
STR_FIT: "Prispôsobiť"
STR_CROP: "Orezať"
STR_NEVER: "Nikdy"
STR_IN_READER: "V čítačke"
STR_ALWAYS: "dy"
STR_IGNORE: "Ignorovať"
STR_SLEEP: "Spánok"
STR_PAGE_TURN: "Otáčanie stránok"
STR_FORCE_REFRESH: "Obnoviť obrazovku"
STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
_language_name: "Slovenčina"
_language_code: "SK"
_order: "24"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "SPÚŠŤANIE"
STR_SLEEPING: "SPÁNOK"
STR_ENTERING_SLEEP: "Prechod do režimu spánku"
STR_BROWSE_FILES: "Prehliadať súbory"
STR_FILE_TRANSFER: "Prenos súborov"
STR_SETTINGS_TITLE: "Nastavenia"
STR_CONTINUE_READING: "Pokračovať v čítaní"
STR_NO_OPEN_BOOK: "Žiadna otvorená kniha"
STR_START_READING: "Začnite čítať nižšie"
STR_NO_FILES_FOUND: "Neboli nájdené žiadne súbory"
STR_SELECT_CHAPTER: "Vybrať kapitolu"
STR_NO_CHAPTERS: "Žiadne kapitoly"
STR_END_OF_BOOK: "Koniec knihy"
STR_EMPTY_CHAPTER: "Prázdna kapitola"
STR_INDEXING: "Indexovanie"
STR_MEMORY_ERROR: "Chyba pamäte"
STR_PAGE_LOAD_ERROR: "Chyba načítania stránky"
STR_EMPTY_FILE: "Prázdny súbor"
STR_OUT_OF_BOUNDS: "Mimo rozsahu"
STR_LOADING: "Načítava sa..."
STR_LOADING_POPUP: "Načítavanie"
STR_WIFI_NETWORKS: "Wi-Fi siete"
STR_NO_NETWORKS: "Nenašli sa žiadne siete"
STR_NETWORKS_FOUND: "Nájdených %zu sietí"
STR_SCANNING: "Skenovanie..."
STR_CONNECTING: "Pripájanie..."
STR_CONNECTED: "Pripojené!"
STR_CONNECTION_FAILED: "Pripojenie zlyhalo"
STR_FORGET_NETWORK: "Zabudnúť sieť?"
STR_SAVE_PASSWORD: "Uložiť heslo na nabudúce?"
STR_PRESS_OK_SCAN: "Stlačte OK pre opätovné skenovanie"
STR_JOIN_NETWORK: "Pripojiť sa k sieti"
STR_CREATE_HOTSPOT: "Vytvoriť hotspot"
STR_JOIN_DESC: "Pripojiť sa k existujúcej Wi-Fi sieti"
STR_HOTSPOT_DESC: "Vytvoriť Wi-Fi sieť, ku ktorej sa môžu pripojiť ostatní"
STR_STARTING_HOTSPOT: "Spúšťanie hotspotu..."
STR_HOTSPOT_MODE: "Režim hotspotu"
STR_CONNECT_WIFI_HINT: "Pripojte svoje zariadenie k tejto Wi-Fi sieti"
STR_OPEN_URL_HINT: "Otvorte túto URL adresu vo svojom prehliadači"
STR_OR_HTTP_PREFIX: "alebo http://"
STR_SCAN_QR_HINT: "alebo naskenujte QR kód telefónom:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_NETWORK_LEGEND: "* = Šifrované | + = Uložené"
STR_MAC_ADDRESS: "MAC adresa:"
STR_CHECKING_WIFI: "Kontrola Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Zadajte heslo Wi-Fi"
STR_TO_PREFIX: "pre"
STR_CALIBRE_RECEIVING: "Prijímanie:"
STR_CALIBRE_RECEIVED: "Prijaté:"
STR_CALIBRE_INSTRUCTION_1: "1) Nainštalujte plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Buďte v rovnakej Wi-Fi sieti"
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odoslať do zariadenia“"
STR_CALIBRE_INSTRUCTION_4: "„Pri odosielaní nechajte túto obrazovku otvorenú“"
STR_CAT_DISPLAY: "Displej"
STR_CAT_READER: "Čítačka"
STR_CAT_CONTROLS: "Ovládanie"
STR_CAT_SYSTEM: "Systém"
STR_SLEEP_SCREEN: "Obrazovka spánku"
STR_QUICK_RESUME_TIMEOUT: "Rýchle obnovenie pri nečinnosti"
STR_SLEEP_COVER_MODE: "Obrazovka spánku režim krytu"
STR_HIDE_BATTERY: "Skryť % batérie"
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
STR_TEXT_AA: "Vyhladzovanie textu"
STR_TEXT_AA_FULL: "Plné"
STR_TEXT_AA_FAST: "Rýchle"
STR_IMAGES: "Obrázky"
STR_IMAGES_DISPLAY: "Zobraz"
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
STR_IMAGES_SUPPRESS: "Potlačiť"
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
STR_ORIENTATION: "Orientácia čítania"
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
STR_FONT_FAMILY: "Rodina písiem čítačky"
STR_FONT_SIZE: "Veľkosť písma rozhrania"
STR_LINE_SPACING: "Riadkovanie čítačky"
STR_SCREEN_MARGIN: "Okraj obrazovky čítačky"
STR_PARA_ALIGNMENT: "Zarovnanie odsekov čítačky"
STR_HYPHENATION: "Delenie slov"
STR_TIME_TO_SLEEP: "Čas do uspania"
STR_SHOW_HIDDEN_FILES: "Zobraz Skryté Súbory"
STR_REMOVE_READ_FROM_RECENTS: "Odstrániť prečítané knihy zo zoznamu nedávnych"
STR_MOVE_FINISHED_TO_READ: "Presunúť prečítané knihy do priečinka Read"
STR_REFRESH_FREQ: "Frekvencia obnovovania"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Skontrolovať aktualizácie"
STR_LANGUAGE: "Jazyk"
STR_CLEAR_READING_CACHE: "Vymazať vyrovnávaciu pamäť čítania"
STR_USERNAME: "Používateľské meno"
STR_PASSWORD: "Heslo"
STR_SYNC_SERVER_URL: "URL synchronizačného servera"
STR_DOCUMENT_MATCHING: "Párovanie dokumentov"
STR_AUTHENTICATE: "Overiť"
STR_KOREADER_USERNAME: "Používateľské meno KOReader"
STR_KOREADER_PASSWORD: "Heslo KOReader"
STR_FILENAME: "Názov súboru"
STR_BINARY: "Binárny"
STR_SET_CREDENTIALS_FIRST: "Najprv nastavte prihlasovacie údaje"
STR_WIFI_CONN_FAILED: "Pripojenie k Wi-Fi zlyhalo"
STR_AUTHENTICATING: "Overovanie..."
STR_AUTH_SUCCESS: "Overenie úspešné!"
STR_KOREADER_AUTH: "Overenie KOReader"
STR_SYNC_READY: "Synchronizácia KOReader je pripravená na použitie"
STR_AUTH_FAILED: "Overenie zlyhalo"
STR_DONE: "Hotovo"
STR_CLEAR_CACHE_WARNING_1: "Týmto vymažete všetky údaje kníh vo vyrovnávacej pamäti."
STR_CLEAR_CACHE_WARNING_2: "Všetok priebeh čítania bude stratený!"
STR_CLEAR_CACHE_WARNING_3: "Knihy bude potrebné znova indexovať"
STR_CLEAR_CACHE_WARNING_4: "pri ich opätovnom otvorení."
STR_CLEARING_CACHE: "Mazanie vyrovnávacej pamäte..."
STR_CACHE_CLEARED: "Vyrovnávacia pamäť vymazaná"
STR_ITEMS_REMOVED: "položiek odstránených"
STR_FAILED_LOWER: "zlyhalo"
STR_CLEAR_CACHE_FAILED: "Vymazanie vyrovnávacej pamäte zlyhalo"
STR_CHECK_SERIAL_OUTPUT: "Podrobnosti nájdete v sériovom výstupe"
STR_DARK: "Tmavý"
STR_LIGHT: "Svetlý"
STR_CUSTOM: "Vlastný"
STR_COVER: "Obálka"
STR_NONE_OPT: "Žiadny"
STR_FIT: "Prispôsobiť"
STR_CROP: "Orezať"
STR_NEVER: "Nikdy"
STR_IN_READER: "V čítačke"
STR_ALWAYS: "Vždy"
STR_IGNORE: "Ignorovať"
STR_SLEEP: "Spánok"
STR_PAGE_TURN: "Otáčanie stránok"
STR_FORCE_REFRESH: "Obnoviť obrazovku"
STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
STR_INVERTED: "Invertovaný"
STR_ORIENTATION_INVERTED: "Na výšku 180°"
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
STR_DISABLED: "Vypnuté"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
STR_SMALL: "Malý"
STR_MEDIUM: "Stredný"
STR_LARGE: "Veľký"
STR_X_LARGE: "Obrovský"
STR_TIGHT: "Tesný"
STR_NORMAL: "Normálny"
STR_WIDE: "Široký"
STR_JUSTIFY: "Zarovnať do bloku"
STR_ALIGN_LEFT: "Vľavo"
STR_CENTER: "Na stred"
STR_ALIGN_RIGHT: "Vpravo"
STR_PAGES_1: "1 strana"
STR_PAGES_5: "5 strán"
STR_PAGES_10: "10 strán"
STR_PAGES_15: "15 strán"
STR_PAGES_30: "30 strán"
STR_UPDATE: "Aktualizácia"
STR_CHECKING_UPDATE: "Kontrola aktualizácií…"
STR_NEW_UPDATE: "K dispozícii je nová aktualizácia!"
STR_CURRENT_VERSION: "Aktuálna verzia:"
STR_NEW_VERSION: "Nová verzia:"
STR_UPDATING: "Aktualizácia..."
STR_NO_UPDATE: "Nie je k dispozícii žiadna aktualizácia"
STR_UPDATE_FAILED: "Aktualizácia zlyhala"
STR_UPDATE_COMPLETE: "Aktualizácia dokončená"
STR_POWER_ON_HINT: "Stlačte a podržte tlačidlo napájania pre zapnutie"
STR_RESTARTING_HINT: "Reštartujem... Ak sa zariadenie nereštartuje, podrž tlačidlo na zapnutie niekoľko s."
STR_NO_ENTRIES: "Neboli nájdené žiadne položky"
STR_DOWNLOADING: "Sťahovanie..."
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
STR_ERROR_MSG: "Chyba:"
STR_UNNAMED: "Nepomenované"
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
STR_DISABLED: "Vypnuté"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
STR_SMALL: "Malý"
STR_MEDIUM: "Stredný"
STR_LARGE: "Veľký"
STR_X_LARGE: "Obrovský"
STR_TIGHT: "Tesný"
STR_NORMAL: "Normálny"
STR_WIDE: "Široký"
STR_JUSTIFY: "Zarovnať do bloku"
STR_ALIGN_LEFT: "Vľavo"
STR_CENTER: "Na stred"
STR_ALIGN_RIGHT: "Vpravo"
STR_PAGES_1: "1 strana"
STR_PAGES_5: "5 strán"
STR_PAGES_10: "10 strán"
STR_PAGES_15: "15 strán"
STR_PAGES_30: "30 strán"
STR_UPDATE: "Aktualizácia"
STR_CHECKING_UPDATE: "Kontrola aktualizácií…"
STR_NEW_UPDATE: "K dispozícii je nová aktualizácia!"
STR_CURRENT_VERSION: "Aktuálna verzia:"
STR_NEW_VERSION: "Nová verzia:"
STR_UPDATING: "Aktualizácia..."
STR_NO_UPDATE: "Nie je k dispozícii žiadna aktualizácia"
STR_UPDATE_FAILED: "Aktualizácia zlyhala"
STR_UPDATE_COMPLETE: "Aktualizácia dokončená"
STR_POWER_ON_HINT: "Stlačte a podržte tlačidlo napájania pre zapnutie"
STR_RESTARTING_HINT: "Reštartujem... Ak sa zariadenie nereštartuje, podrž tlačidlo na zapnutie niekoľko s."
STR_NO_ENTRIES: "Neboli nájdené žiadne položky"
STR_DOWNLOADING: "Sťahovanie..."
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
STR_ERROR_MSG: "Chyba:"
STR_UNNAMED: "Nepomenované"
STR_HOLD_OPEN_TO_DELETE: "Podržte Otvoriť pre vymazanie"
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
STR_NEXT_PAGE: "Ďaľsia strana »"
STR_PREV_PAGE: "« Predchádzajúca str."
STR_NETWORK_PREFIX: "Sieť:"
STR_IP_ADDRESS_PREFIX: "IP adresa:"
STR_ERROR_GENERAL_FAILURE: "Chyba: Všeobecná chyba"
STR_ERROR_NETWORK_NOT_FOUND: "Chyba: Sieť nebola nájdená"
STR_ERROR_CONNECTION_TIMEOUT: "Chyba: Časový limit pripojenia"
STR_SD_CARD: "SD karta"
STR_BACK: "« Späť"
STR_EXIT: "« Koniec"
STR_HOME: "« Domov"
STR_SELECT: "Vybrať"
STR_SELECTED: "Vybrané"
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
STR_NEXT_PAGE: "Ďaľsia strana »"
STR_PREV_PAGE: "« Predchádzajúca str."
STR_NETWORK_PREFIX: "Sieť:"
STR_IP_ADDRESS_PREFIX: "IP adresa:"
STR_ERROR_GENERAL_FAILURE: "Chyba: Všeobecná chyba"
STR_ERROR_NETWORK_NOT_FOUND: "Chyba: Sieť nebola nájdená"
STR_ERROR_CONNECTION_TIMEOUT: "Chyba: Časový limit pripojenia"
STR_SD_CARD: "SD karta"
STR_BACK: "« Späť"
STR_EXIT: "« Koniec"
STR_HOME: "« Domov"
STR_SELECT: "Vybrať"
STR_SELECTED: "Vybrané"
STR_TOGGLE: "Prepnúť"
STR_TOGGLE_BOOKMARK: "Prepnúť záložku"
STR_CONFIRM: "Potvrdiť"
STR_CANCEL: "Zrušiť"
STR_CONNECT: "Pripojiť"
STR_OPEN: "Otvoriť"
STR_DOWNLOAD: "Stiahnuť"
STR_RETRY: "Skúsiť znova"
STR_YES: "Áno"
STR_NO: "Nie"
STR_SHOW: "Zobraz"
STR_HIDE: "Skryť"
STR_STATE_ON: "ZAP"
STR_STATE_OFF: "VYP"
STR_NOT_SET: "Nenastavené"
STR_DIR_LEFT: "Vľavo"
STR_DIR_RIGHT: "Vpravo"
STR_DIR_UP: "Hore"
STR_DIR_DOWN: "Dole"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filter obrazovky spánku"
STR_FILTER_CONTRAST: "Kontrast"
STR_CUSTOMISE_STATUS_BAR: "Uprav status bar"
STR_CHAPTER_PAGE_COUNT: "Počítadlo strán kapitoly"
STR_BOOK_PROGRESS_PERCENTAGE: "Prečítané percent"
STR_PROGRESS_BAR: "Ukazovateľ čítania"
STR_PROGRESS_BAR_THICKNESS: "Hrúbka indikátora priebehu"
STR_PROGRESS_BAR_THIN: "Tenký"
STR_PROGRESS_BAR_MEDIUM: "Stredný"
STR_PROGRESS_BAR_THICK: "Hrubý"
STR_BOOK: "Kniha"
STR_CHAPTER: "Kapitola"
STR_EXAMPLE_CHAPTER: "Kapitola 21"
STR_EXAMPLE_BOOK: "Názov knihy"
STR_PREVIEW: "Ukážka"
STR_TITLE: "Názov"
STR_BATTERY: "Batéria"
STR_XTC_STATUS_BAR: "Stavový panel XTC"
STR_BOTTOM: "Dole"
STR_TOP: "Hore"
STR_CLOCK: "Hodiny"
STR_CLOCK_UTC_OFFSET: "UTC posun hodín"
STR_CLOCK_FORMAT: "Formát času"
STR_CLOCK_FORMAT_24H: "24-hodinový"
STR_CLOCK_FORMAT_12H: "12-hodinový"
STR_CURRENT_TIME: "Aktuálny čas:"
STR_NEXT_FIELD: "Ďalej"
STR_CLOCK_SYNC: "Synchronizácia hodín"
STR_CLOCK_SYNC_NOW: "Synchronizovať teraz"
STR_CLOCK_SYNCING: "Synchronizácia cez NTP..."
STR_CLOCK_SYNC_OK: "Hodiny synchronizované"
STR_CLOCK_SYNC_FAIL: "Synchronizácia zlyhala"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nie je pripojená"
STR_CLOCK_SYNC_NO_WIFI_HINT: "Najprv sa pripojte k Wi-Fi a potom skúste znova."
STR_CLOCK_SYNCED: "Hodiny synchronizované"
STR_UI_THEME: "Téma rozhrania"
STR_THEME_CLASSIC: "Klasická"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
STR_CONFIRM: "Potvrdiť"
STR_CANCEL: "Zrušiť"
STR_CONNECT: "Pripojiť"
STR_OPEN: "Otvoriť"
STR_DOWNLOAD: "Stiahnuť"
STR_RETRY: "Skúsiť znova"
STR_YES: "Áno"
STR_NO: "Nie"
STR_SHOW: "Zobraz"
STR_HIDE: "Skryť"
STR_STATE_ON: "ZAP"
STR_STATE_OFF: "VYP"
STR_NOT_SET: "Nenastavené"
STR_DIR_LEFT: "Vľavo"
STR_DIR_RIGHT: "Vpravo"
STR_DIR_UP: "Hore"
STR_DIR_DOWN: "Dole"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filter obrazovky spánku"
STR_FILTER_CONTRAST: "Kontrast"
STR_CUSTOMISE_STATUS_BAR: "Uprav status bar"
STR_CHAPTER_PAGE_COUNT: "Počítadlo strán kapitoly"
STR_BOOK_PROGRESS_PERCENTAGE: "Prečítané percent"
STR_PROGRESS_BAR: "Ukazovateľ čítania"
STR_PROGRESS_BAR_THICKNESS: "Hrúbka indikátora priebehu"
STR_PROGRESS_BAR_THIN: "Tenký"
STR_PROGRESS_BAR_MEDIUM: "Stredný"
STR_PROGRESS_BAR_THICK: "Hrubý"
STR_BOOK: "Kniha"
STR_CHAPTER: "Kapitola"
STR_EXAMPLE_CHAPTER: "Kapitola 21"
STR_EXAMPLE_BOOK: "Názov knihy"
STR_PREVIEW: "Ukážka"
STR_TITLE: "Názov"
STR_BATTERY: "Batéria"
STR_XTC_STATUS_BAR: "Stavový panel XTC"
STR_BOTTOM: "Dole"
STR_TOP: "Hore"
STR_CLOCK: "Hodiny"
STR_CLOCK_UTC_OFFSET: "UTC posun hodín"
STR_CLOCK_FORMAT: "Formát času"
STR_CLOCK_FORMAT_24H: "24-hodinový"
STR_CLOCK_FORMAT_12H: "12-hodinový"
STR_CURRENT_TIME: "Aktuálny čas:"
STR_NEXT_FIELD: "Ďalej"
STR_CLOCK_SYNC: "Synchronizácia hodín"
STR_CLOCK_SYNC_NOW: "Synchronizovať teraz"
STR_CLOCK_SYNCING: "Synchronizácia cez NTP..."
STR_CLOCK_SYNC_OK: "Hodiny synchronizované"
STR_CLOCK_SYNC_FAIL: "Synchronizácia zlyhala"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nie je pripojená"
STR_CLOCK_SYNC_NO_WIFI_HINT: "Najprv sa pripojte k Wi-Fi a potom skúste znova."
STR_CLOCK_SYNCED: "Hodiny synchronizované"
STR_UI_THEME: "Téma rozhrania"
STR_THEME_CLASSIC: "Klasická"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
STR_BOOKMARKS: "Záložky"
STR_BOOKMARK_ADDED: "Záložka pridaná."
STR_BOOKMARK_REMOVED: "Záložka odstránená."
STR_OPDS_BROWSER: "Prehliadač OPDS"
STR_SEARCH: "Hľadať"
STR_COVER_CUSTOM: "Obálka + Vlastné"
STR_QUICK_RESUME: "Rýchle obnovenie"
STR_MENU_RECENT_BOOKS: "Nedávne knihy"
STR_REMOVE_FROM_RECENTS: "Odstrániť z nedávnych kníh?"
STR_NO_RECENT_BOOKS: "Žiadne nedávne knihy"
STR_CALIBRE_DESC: "Používať bezdrôtové prenosy zariadení Calibre"
STR_FORGET_AND_REMOVE: "Zabudnúť sieť a odstrániť uložené heslo?"
STR_FORGET_BUTTON: "Zabudnúť"
STR_CALIBRE_STARTING: "Spúšťanie Calibre..."
STR_CALIBRE_SETUP: "Nastavenie"
STR_CALIBRE_STATUS: "Stav"
STR_CLEAR_BUTTON: "Vymazať"
STR_DEFAULT_VALUE: "Predvolené"
STR_REMAP_PROMPT: "Stlačte predné tlačidlo pre každú funkciu"
STR_UNASSIGNED: "Nepriradené"
STR_ALREADY_ASSIGNED: "Už priradené"
STR_REMAP_RESET_HINT: "Bočné tlačidlo Hore: Obnoviť predvolené rozloženie"
STR_REMAP_CANCEL_HINT: "Bočné tlačidlo Dole: Zrušiť premapovanie"
STR_HW_BACK_LABEL: "Späť (1. tlačidlo)"
STR_HW_CONFIRM_LABEL: "Potvrdiť (2. tlačidlo)"
STR_HW_LEFT_LABEL: "Vľavo (3. tlačidlo)"
STR_HW_RIGHT_LABEL: "Vpravo (4. tlačidlo)"
STR_GO_TO_PERCENT: "Prejsť na %"
STR_GO_HOME_BUTTON: "Prejsť na Domov"
STR_SYNC_PROGRESS: "Priebeh synchronizácie"
STR_DELETE_CACHE: "Vymazať vyrovnávaciu pamäť knihy"
STR_DELETE: "Vymazať"
STR_CONFIRM_DELETE_BOOKMARK: "Vymazať záložku?"
STR_DISPLAY_QR: "Zobraz stránku ako QR"
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_SYNCING_TIME: "Čas synchronizácie..."
STR_CALC_HASH: "Výpočet hashu dokumentu..."
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
STR_FETCH_PROGRESS: "Načítavanie vzdialeného priebehu..."
STR_UPLOAD_PROGRESS: "Nahrávanie priebehu..."
STR_NO_CREDENTIALS_MSG: "Prihlasovacie údaje nie sú nastavené"
STR_KOREADER_SETUP_HINT: "Nastavte účet KOReader v Nastaveniach"
STR_PROGRESS_FOUND: "Priebeh nájdený!"
STR_REMOTE_LABEL: "Vzdialený:"
STR_LOCAL_LABEL: "Lokálny:"
STR_PAGE_OVERALL_FORMAT: "Strana %d, celkovo %.2f%%"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Strana %d/%d, celkovo %.2f%%"
STR_DEVICE_FROM_FORMAT: " Zo zariadenia: %s"
STR_APPLY_REMOTE: "Použiť vzdialený priebeh"
STR_UPLOAD_LOCAL: "Nahrať lokálny priebeh"
STR_NO_REMOTE_MSG: "Nenašiel sa žiadny vzdialený priebeh"
STR_UPLOAD_PROMPT: "Nahrať aktuálnu pozíciu?"
STR_UPLOAD_SUCCESS: "Priebeh nahraný!"
STR_SYNC_FAILED_MSG: "Synchronizácia zlyhala"
STR_SAVE_PROGRESS_FAILED: "Nepodarilo sa uložiť priebeh"
STR_SECTION_PREFIX: "Sekcia"
STR_UPLOAD: "Nahrať"
STR_BOOK_S_STYLE: "Štýl knihy"
STR_EMBEDDED_STYLE: "Vložený štýl"
STR_FOCUS_READING: "Sústredené čítanie"
STR_OPDS_SERVER_URL: "URL adresa OPDS servera"
STR_SET_SLEEP_COVER: "Nastav obal"
STR_FOOTNOTES: "Poznámky pod čiarou"
STR_NO_FOOTNOTES: "Žiadne poznámky pod čiarou"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
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"
STR_DELETE_SERVER: "Odstrániť server"
STR_OPDS_SERVERS: "OPDS servery"
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
STR_MANAGE_FONTS: "Správa písiem"
STR_FONT_BROWSER: "Prehliadač písiem"
STR_LOADING_FONT_LIST: "Načítava sa zoznam písiem..."
STR_NO_FONTS_AVAILABLE: "Nie sú dostupné žiadne písma"
STR_FONT_INSTALLED: "Písmo nainštalované!"
STR_FONT_INSTALL_FAILED: "Inštalácia písma zlyhala"
STR_INSTALLED: "Nainštalované"
STR_DOWNLOAD_ALL: "Stiahnuť všetko"
STR_UPDATE_ALL: "Aktualizovať všetko"
STR_UPDATE_AVAILABLE: "Aktualizovať"
STR_CRASH_TITLE: "Zlyhanie systému"
STR_CRASH_DESCRIPTION: "Podrobná správa bola uložená do súboru crash_report.txt. Priložte tento súbor k hláseniu chyby."
STR_CRASH_REASON: "Dôvod zlyhania:"
STR_CRASH_NO_REASON: "(Nebola zaznamenaná žiadna príčina)"
STR_TILT_PAGE_TURN: "Otáčanie strán naklonením"
STR_KB_HINT_MOVE_CURSOR: "Stlačte VĽAVO alebo VPRAVO pre presun kurzora"
STR_KB_HINT_RETURN_CURSOR: "Stlačte VĽAVO pre návrat na pozíciu kurzora"
STR_KB_HINT_HIDE_PASSWORD: "Podržte VPRAVO a potom stlačte [***] na skrytie hesla"
STR_KB_HINT_SHOW_PASSWORD: "Podržte VPRAVO a potom stlačte [abc] na zobrazenie hesla"
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Stlačte [***] na skrytie hesla"
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Stlačte [abc] na zobrazenie hesla"
STR_KB_HINT_EDIT_ENTRY: "Podržte HORE pre úpravu položky"
STR_KB_TIPS: "Tipy:"
STR_KB_HINT_RETURN_KEYBOARD: "Stlačte DOLE pre návrat ku klávesnici"
STR_KB_HINT_EXIT_URL_MODE: "Stlačte ABC pre ukončenie režimu URL"
STR_KB_HINT_CLEAR_TEXT: "Podržte DEL pre vymazanie celého textu"
STR_KB_HINT_SECONDARY_CHAR: "Podržte SELECT pre alternatívny znak"
STR_KB_HINT_UPPER_SECONDARY: "Podržte SELECT pre VEĽKÉ písmeno alebo alternatívny znak"
STR_KB_HINT_LOWER_SECONDARY: "Podržte SELECT pre malé písmeno alebo alternatívny znak"
STR_KB_HINT_URL_SNIPPETS: "Stlačte URL pre úryvky"
STR_SD_FIRMWARE_UPDATE: "Aktualizácia firmvéru z SD karty"
STR_SELECT_FIRMWARE_FILE: "Vyberte súbor firmvéru (.bin)"
STR_NO_BIN_FILES: "Nenašli sa žiadne súbory .bin"
STR_VALIDATING_FIRMWARE: "Overuje sa firmvér..."
STR_INVALID_FIRMWARE: "Neplatný súbor firmvéru"
STR_FIRMWARE_TOO_LARGE: "Firmvér je príliš veľký pre partíciu"
STR_FIRMWARE_TOO_SMALL: "Súbor firmvéru je príliš malý"
STR_FIRMWARE_UPDATE_PROMPT: "Aktualizovať firmvér?"
STR_FIRMWARE_FILE_OPEN_FAILED: "Nie je možné otvoriť súbor"
STR_FIRMWARE_WRITE_FAILED: "Zápis firmvéru zlyhal"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nevypínajte zariadenie!"
STR_RECOVERY_MODE: "Režim obnovenia"
STR_RECOVERY_MODE_HINT: "Umiestnite firmware.bin do koreňového adresára SD karty a vyberte ho"
STR_OPDS_BROWSER: "Prehliadač OPDS"
STR_SEARCH: "Hľadať"
STR_COVER_CUSTOM: "Obálka + Vlastné"
STR_QUICK_RESUME: "Rýchle obnovenie"
STR_MENU_RECENT_BOOKS: "Nedávne knihy"
STR_REMOVE_FROM_RECENTS: "Odstrániť z nedávnych kníh?"
STR_NO_RECENT_BOOKS: "Žiadne nedávne knihy"
STR_CALIBRE_DESC: "Používať bezdrôtové prenosy zariadení Calibre"
STR_FORGET_AND_REMOVE: "Zabudnúť sieť a odstrániť uložené heslo?"
STR_FORGET_BUTTON: "Zabudnúť"
STR_CALIBRE_STARTING: "Spúšťanie Calibre..."
STR_CALIBRE_SETUP: "Nastavenie"
STR_CALIBRE_STATUS: "Stav"
STR_CLEAR_BUTTON: "Vymazať"
STR_DEFAULT_VALUE: "Predvolené"
STR_REMAP_PROMPT: "Stlačte predné tlačidlo pre každú funkciu"
STR_UNASSIGNED: "Nepriradené"
STR_ALREADY_ASSIGNED: "Už priradené"
STR_REMAP_RESET_HINT: "Bočné tlačidlo Hore: Obnoviť predvolené rozloženie"
STR_REMAP_CANCEL_HINT: "Bočné tlačidlo Dole: Zrušiť premapovanie"
STR_HW_BACK_LABEL: "Späť (1. tlačidlo)"
STR_HW_CONFIRM_LABEL: "Potvrdiť (2. tlačidlo)"
STR_HW_LEFT_LABEL: "Vľavo (3. tlačidlo)"
STR_HW_RIGHT_LABEL: "Vpravo (4. tlačidlo)"
STR_GO_TO_PERCENT: "Prejsť na %"
STR_GO_HOME_BUTTON: "Prejsť na Domov"
STR_SYNC_PROGRESS: "Priebeh synchronizácie"
STR_DELETE_CACHE: "Vymazať vyrovnávaciu pamäť knihy"
STR_DELETE: "Vymazať"
STR_CONFIRM_DELETE_BOOKMARK: "Vymazať záložku?"
STR_DISPLAY_QR: "Zobraz stránku ako QR"
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_SYNCING_TIME: "Čas synchronizácie..."
STR_CALC_HASH: "Výpočet hashu dokumentu..."
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
STR_FETCH_PROGRESS: "Načítavanie vzdialeného priebehu..."
STR_UPLOAD_PROGRESS: "Nahrávanie priebehu..."
STR_NO_CREDENTIALS_MSG: "Prihlasovacie údaje nie sú nastavené"
STR_KOREADER_SETUP_HINT: "Nastavte účet KOReader v Nastaveniach"
STR_PROGRESS_FOUND: "Priebeh nájdený!"
STR_REMOTE_LABEL: "Vzdialený:"
STR_LOCAL_LABEL: "Lokálny:"
STR_PAGE_OVERALL_FORMAT: "Strana %d, celkovo %.2f%%"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Strana %d/%d, celkovo %.2f%%"
STR_DEVICE_FROM_FORMAT: " Zo zariadenia: %s"
STR_APPLY_REMOTE: "Použiť vzdialený priebeh"
STR_UPLOAD_LOCAL: "Nahrať lokálny priebeh"
STR_NO_REMOTE_MSG: "Nenašiel sa žiadny vzdialený priebeh"
STR_UPLOAD_PROMPT: "Nahrať aktuálnu pozíciu?"
STR_UPLOAD_SUCCESS: "Priebeh nahraný!"
STR_SYNC_FAILED_MSG: "Synchronizácia zlyhala"
STR_SAVE_PROGRESS_FAILED: "Nepodarilo sa uložiť priebeh"
STR_SECTION_PREFIX: "Sekcia"
STR_UPLOAD: "Nahrať"
STR_BOOK_S_STYLE: "Štýl knihy"
STR_EMBEDDED_STYLE: "Vložený štýl"
STR_FOCUS_READING: "Sústredené čítanie"
STR_OPDS_SERVER_URL: "URL adresa OPDS servera"
STR_SET_SLEEP_COVER: "Nastav obal"
STR_FOOTNOTES: "Poznámky pod čiarou"
STR_NO_FOOTNOTES: "Žiadne poznámky pod čiarou"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
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"
STR_DELETE_SERVER: "Odstrániť server"
STR_OPDS_SERVERS: "OPDS servery"
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
STR_MANAGE_FONTS: "Správa písiem"
STR_FONT_BROWSER: "Prehliadač písiem"
STR_LOADING_FONT_LIST: "Načítava sa zoznam písiem..."
STR_NO_FONTS_AVAILABLE: "Nie sú dostupné žiadne písma"
STR_FONT_INSTALLED: "Písmo nainštalované!"
STR_FONT_INSTALL_FAILED: "Inštalácia písma zlyhala"
STR_INSTALLED: "Nainštalované"
STR_DOWNLOAD_ALL: "Stiahnuť všetko"
STR_UPDATE_ALL: "Aktualizovať všetko"
STR_UPDATE_AVAILABLE: "Aktualizovať"
STR_CRASH_TITLE: "Zlyhanie systému"
STR_CRASH_DESCRIPTION: "Podrobná správa bola uložená do súboru crash_report.txt. Priložte tento súbor k hláseniu chyby."
STR_CRASH_REASON: "Dôvod zlyhania:"
STR_CRASH_NO_REASON: "(Nebola zaznamenaná žiadna príčina)"
STR_TILT_PAGE_TURN: "Otáčanie strán naklonením"
STR_KB_HINT_MOVE_CURSOR: "Stlačte VĽAVO alebo VPRAVO pre presun kurzora"
STR_KB_HINT_RETURN_CURSOR: "Stlačte VĽAVO pre návrat na pozíciu kurzora"
STR_KB_HINT_HIDE_PASSWORD: "Podržte VPRAVO a potom stlačte [***] na skrytie hesla"
STR_KB_HINT_SHOW_PASSWORD: "Podržte VPRAVO a potom stlačte [abc] na zobrazenie hesla"
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Stlačte [***] na skrytie hesla"
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Stlačte [abc] na zobrazenie hesla"
STR_KB_HINT_EDIT_ENTRY: "Podržte HORE pre úpravu položky"
STR_KB_TIPS: "Tipy:"
STR_KB_HINT_RETURN_KEYBOARD: "Stlačte DOLE pre návrat ku klávesnici"
STR_KB_HINT_EXIT_URL_MODE: "Stlačte ABC pre ukončenie režimu URL"
STR_KB_HINT_CLEAR_TEXT: "Podržte DEL pre vymazanie celého textu"
STR_KB_HINT_SECONDARY_CHAR: "Podržte SELECT pre alternatívny znak"
STR_KB_HINT_UPPER_SECONDARY: "Podržte SELECT pre VEĽKÉ písmeno alebo alternatívny znak"
STR_KB_HINT_LOWER_SECONDARY: "Podržte SELECT pre malé písmeno alebo alternatívny znak"
STR_KB_HINT_URL_SNIPPETS: "Stlačte URL pre úryvky"
STR_SD_FIRMWARE_UPDATE: "Aktualizácia firmvéru z SD karty"
STR_SELECT_FIRMWARE_FILE: "Vyberte súbor firmvéru (.bin)"
STR_NO_BIN_FILES: "Nenašli sa žiadne súbory .bin"
STR_VALIDATING_FIRMWARE: "Overuje sa firmvér..."
STR_INVALID_FIRMWARE: "Neplatný súbor firmvéru"
STR_FIRMWARE_TOO_LARGE: "Firmvér je príliš veľký pre partíciu"
STR_FIRMWARE_TOO_SMALL: "Súbor firmvéru je príliš malý"
STR_FIRMWARE_UPDATE_PROMPT: "Aktualizovať firmvér?"
STR_FIRMWARE_FILE_OPEN_FAILED: "Nie je možné otvoriť súbor"
STR_FIRMWARE_WRITE_FAILED: "Zápis firmvéru zlyhal"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nevypínajte zariadenie!"
STR_RECOVERY_MODE: "Režim obnovenia"
STR_RECOVERY_MODE_HINT: "Umiestnite firmware.bin do koreňového adresára SD karty a vyberte ho"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Način naslovnice v spanju"
STR_HIDE_BATTERY: "Skrij % baterije"
STR_EXTRA_SPACING: "Dodaten razmik med odstavki"
STR_TEXT_AA: "Glajenje besedila (AA)"
STR_TEXT_AA_FULL: "Polno"
STR_TEXT_AA_FAST: "Hitro"
STR_IMAGES: "Slike"
STR_IMAGES_DISPLAY: "Prikaži"
STR_IMAGES_PLACEHOLDER: "Oznaka mesta"
+2
View File
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Modo de pantalla de suspensión"
STR_HIDE_BATTERY: "Ocultar % de batería"
STR_EXTRA_SPACING: "Espaciado entre párrafos"
STR_TEXT_AA: "Suavizado de texto"
STR_TEXT_AA_FULL: "Completo"
STR_TEXT_AA_FAST: "Rápido"
STR_IMAGES: "Imágenes"
STR_IMAGES_DISPLAY: "Mostrar"
STR_IMAGES_PLACEHOLDER: "Reemplazar"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Viloskärmens omslagsläge"
STR_HIDE_BATTERY: "Dölj batteriprocent"
STR_EXTRA_SPACING: "Extra paragrafmellanrum"
STR_TEXT_AA: "Textkantutjämning"
STR_TEXT_AA_FULL: "Full"
STR_TEXT_AA_FAST: "Snabb"
STR_IMAGES: "Bilder"
STR_IMAGES_DISPLAY: "Visa"
STR_IMAGES_PLACEHOLDER: "Platshållare"
+2
View File
@@ -64,6 +64,8 @@ STR_SLEEP_COVER_MODE: "Uyku Ekranı Kapak Modu"
STR_HIDE_BATTERY: "Pil Yüzdesini Gizle"
STR_EXTRA_SPACING: "Ekstra Paragraf Boşluğu"
STR_TEXT_AA: "Metin Yumuşatma (AA)"
STR_TEXT_AA_FULL: "Tam"
STR_TEXT_AA_FAST: "Hızlı"
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
STR_ORIENTATION: "Okuma Yönü"
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Режим заповнення"
STR_HIDE_BATTERY: "Приховати % батареї"
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
STR_TEXT_AA: "Згладжування тексту"
STR_TEXT_AA_FULL: "Повне"
STR_TEXT_AA_FAST: "Швидке"
STR_IMAGES: "Зображення"
STR_IMAGES_DISPLAY: "Показати"
STR_IMAGES_PLACEHOLDER: "Заглушка"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
STR_HIDE_BATTERY: "Oculta el % de bateria"
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
STR_TEXT_AA: "Antialiàsing del text"
STR_TEXT_AA_FULL: "Complet"
STR_TEXT_AA_FAST: "Ràpid"
STR_IMAGES: "Imatges"
STR_IMAGES_DISPLAY: "Mostrar"
STR_IMAGES_PLACEHOLDER: "Text de mostra"
+2
View File
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Kiểu ảnh bìa màn hình ngủ"
STR_HIDE_BATTERY: "Ẩn % pin"
STR_EXTRA_SPACING: "Giãn cách đoạn thêm"
STR_TEXT_AA: "Khử răng cưa chữ"
STR_TEXT_AA_FULL: "Đầy đủ"
STR_TEXT_AA_FAST: "Nhanh"
STR_IMAGES: "Hình ảnh"
STR_IMAGES_DISPLAY: "Hiển thị"
STR_IMAGES_PLACEHOLDER: "Khung thay thế"
+94 -19
View File
@@ -1,43 +1,118 @@
#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
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::saveToFile() const {
Storage.mkdir("/.crosspoint");
return KOReaderJsonIO::save(*this, KOREADER_FILE_JSON);
}
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
std::string user = doc["username"] | "";
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 needsResave = false;
std::string pass = extractPassword(doc, needsResave);
// 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;
}
}
}
setCredentials(user, pass);
setServerUrl(doc["serverUrl"] | "");
LOG_DBG("KRS", "No credentials file found");
return false;
}
uint8_t method = doc["matchMethod"] | (uint8_t)0;
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
setMatchMethod(static_cast<DocumentMatchMethod>(method));
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);
} else {
LOG_DBG("KRS", "Invalid matchMethod %u in JSON, resetting to FILENAME", method);
setMatchMethod(DocumentMatchMethod::FILENAME);
username.clear();
}
if (needsResave) {
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
saveToFile();
if (file.available()) {
serialization::readString(file, password);
legacyDeobfuscate(password);
} else {
password.clear();
}
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;
}
+13 -10
View File
@@ -1,7 +1,4 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <cstdint>
#include <string>
@@ -17,9 +14,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 : public PersistableStore<KOReaderCredentialStore> {
class KOReaderCredentialStore {
private:
static KOReaderCredentialStore instance;
std::string username;
std::string password;
std::string serverUrl; // Custom sync server URL (empty = default)
@@ -27,14 +24,20 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
// Private constructor for singleton
KOReaderCredentialStore() = default;
~KOReaderCredentialStore() = default;
friend class PersistableStore<KOReaderCredentialStore>;
bool loadFromBinaryFile();
public:
static const char* getFilePath() { return "/.crosspoint/koreader.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
// 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();
// Credential management
void setCredentials(const std::string& user, const std::string& pass);
+51
View File
@@ -0,0 +1,51 @@
#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
+8
View File
@@ -0,0 +1,8 @@
#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
-45
View File
@@ -1,45 +0,0 @@
#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;
}
-82
View File
@@ -1,82 +0,0 @@
#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>());
}
};
-4
View File
@@ -77,10 +77,6 @@ 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);
}
-6
View File
@@ -47,12 +47,6 @@ 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
+1 -66
View File
@@ -13,10 +13,7 @@ framework = arduino
monitor_speed = 115200
upload_speed = 921600
check_tool = cppcheck
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
; project header as missing (~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_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
check_skip_packages = yes
board_upload.flash_size = 16MB
@@ -43,16 +40,6 @@ 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
@@ -63,48 +50,6 @@ 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
@@ -118,15 +63,10 @@ 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
@@ -140,9 +80,6 @@ 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]
@@ -152,7 +89,6 @@ 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
@@ -161,7 +97,6 @@ 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
-210
View File
@@ -1,210 +0,0 @@
#!/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 &lt;br&gt; 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: &lt;br class="section-br"/&gt;</h1>
<p>PASS: A blank-line gap should appear between the two sections below, identical
to Ch 1, even though the &lt;br&gt; 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 &lt;br&gt; Elements</h1>
<p>PASS: Two blank lines should appear between the sections (one per &lt;br&gt;).</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 &lt;br&gt; Inside a Paragraph</h1>
<p>PASS: The two lines below should be adjacent with NO extra gap between them.
The &lt;br&gt; 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: &lt;br&gt; 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: &lt;br&gt; 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 &lt;br&gt; 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)
-121
View File
@@ -1,121 +0,0 @@
#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
-62
View File
@@ -1,62 +0,0 @@
#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
+22 -1
View File
@@ -24,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) {
}
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 2;
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
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";
@@ -229,6 +229,13 @@ 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,6 +244,20 @@ 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);
+12 -17
View File
@@ -173,6 +173,13 @@ class CrossPointSettings {
// Image rendering in EPUB reader
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
// Text anti-aliasing mode for 2-bit fonts. FULL runs the grayscale LUT
// passes after the BW page render (extra refresh per page turn). FAST
// approximates AA inside the single BW pass by dithering partial-coverage
// glyph pixels (GfxRenderer fast anti-aliasing flag). Value 1 must stay
// FULL: it was the "on" value of the old on/off toggle in saved settings.
enum TEXT_ANTIALIASING { TEXT_AA_OFF = 0, TEXT_AA_FULL = 1, TEXT_AA_FAST = 2, TEXT_ANTIALIASING_COUNT };
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
enum QUICK_RESUME_SLEEP_SCREEN {
@@ -209,7 +216,7 @@ class CrossPointSettings {
uint8_t clockHasBeenSynced = 0;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 1;
uint8_t textAntiAliasing = TEXT_AA_FULL;
// Short power button click behaviour
uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings
@@ -225,22 +232,6 @@ 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;
@@ -254,6 +245,10 @@ 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
+144 -29
View File
@@ -7,13 +7,11 @@
#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"
@@ -145,16 +143,6 @@ 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
@@ -252,23 +240,6 @@ 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);
@@ -295,6 +266,150 @@ 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>();
store.recentBooks.reserve(std::min(arr.size(), (size_t)10));
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) {
+12
View File
@@ -19,6 +19,18 @@ 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);
+4 -93
View File
@@ -2,7 +2,6 @@
#include <GfxRenderer.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
bool MappedInputManager::isNavDirectionSwapped() const {
@@ -75,105 +74,17 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
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::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
bool MappedInputManager::wasPressed(const Button button) const {
return mapButton(button, &HalGPIO::wasPressed) || bleEdge(blePressEdge, button);
}
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
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::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
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();
}
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
-33
View File
@@ -7,9 +7,6 @@ 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;
@@ -31,23 +28,6 @@ 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,
@@ -64,17 +44,4 @@ 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;
};
+62 -36
View File
@@ -1,48 +1,74 @@
#include "OpdsServerStore.h"
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <algorithm>
#include <cstring>
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);
}
#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);
}
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.
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
servers.clear();
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;
return false;
}
bool OpdsServerStore::addServer(const OpdsServer& server) {
+23 -8
View File
@@ -1,7 +1,4 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -12,25 +9,37 @@ 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 : public PersistableStore<OpdsServerStore> {
class OpdsServerStore {
private:
static OpdsServerStore instance;
std::vector<OpdsServer> servers;
static constexpr size_t MAX_SERVERS = 8;
OpdsServerStore() = default;
friend class PersistableStore<OpdsServerStore>;
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
public:
static const char* getFilePath() { return "/.crosspoint/opds.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
OpdsServerStore(const OpdsServerStore&) = delete;
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
static OpdsServerStore& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
bool addServer(const OpdsServer& server);
bool updateServer(size_t index, const OpdsServer& server);
@@ -40,6 +49,12 @@ class OpdsServerStore : public PersistableStore<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()
+107 -29
View File
@@ -3,42 +3,23 @@
#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>
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;
}
}
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
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;
}
RecentBooksStore RecentBooksStore::instance;
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
@@ -111,6 +92,11 @@ 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('/');
@@ -138,3 +124,95 @@ 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;
}
+20 -14
View File
@@ -1,7 +1,4 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -14,21 +11,24 @@ struct RecentBook {
bool operator==(const RecentBook& other) const { return path == other.path; }
};
class RecentBooksStore : public PersistableStore<RecentBooksStore> {
private:
class RecentBooksStore;
namespace JsonSettingsIO {
bool loadRecentBooks(RecentBooksStore& store, const char* json);
} // namespace JsonSettingsIO
class RecentBooksStore {
// Static instance
static RecentBooksStore instance;
std::vector<RecentBook> recentBooks;
static constexpr int MAX_RECENT_BOOKS = 10;
RecentBooksStore() = default;
~RecentBooksStore() = default;
friend class PersistableStore<RecentBooksStore>;
friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*);
public:
static const char* getFilePath() { return "/.crosspoint/recent.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
~RecentBooksStore() = default;
// Get singleton instance
static RecentBooksStore& getInstance() { return instance; }
// 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,7 +61,13 @@ class RecentBooksStore : public PersistableStore<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
+5 -2
View File
@@ -157,8 +157,11 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
"orientation", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
"extraParagraphSpacing", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
StrId::STR_CAT_READER),
// Option order must match TEXT_ANTIALIASING values (popup index is
// stored directly): OFF=0, FULL=1 (legacy toggle "on"), FAST=2.
SettingInfo::Enum(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing,
{StrId::STR_STATE_OFF, StrId::STR_TEXT_AA_FULL, StrId::STR_TEXT_AA_FAST}, "textAntiAliasing",
StrId::STR_CAT_READER),
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
"imageRendering", StrId::STR_CAT_READER),
-4
View File
@@ -6,7 +6,3 @@
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();
+86 -26
View File
@@ -1,46 +1,106 @@
#include "WifiCredentialStore.h"
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <Serialization.h>
#include <algorithm>
void WifiCredentialStore::toJson(JsonDocument& doc) const {
doc["lastConnectedSsid"] = lastConnectedSsid;
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
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 {
// File format version (for binary migration)
constexpr uint8_t WIFI_FILE_VERSION = 2;
// 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];
}
}
} // namespace
bool WifiCredentialStore::fromJson(JsonVariantConst doc) {
lastConnectedSsid = doc["lastConnectedSsid"] | "";
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);
// 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();
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;
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
WifiCredential cred;
cred.ssid = obj["ssid"] | "";
cred.password = extractPassword(obj, needsResave);
serialization::readString(file, cred.ssid);
serialization::readString(file, cred.password);
legacyDeobfuscate(cred.password);
credentials.push_back(cred);
}
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
if (needsResave) {
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
// LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size());
return true;
}
+22 -8
View File
@@ -1,7 +1,4 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -10,14 +7,21 @@ 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 : public PersistableStore<WifiCredentialStore> {
class WifiCredentialStore {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
@@ -26,12 +30,22 @@ class WifiCredentialStore : public PersistableStore<WifiCredentialStore> {
// Private constructor for singleton
WifiCredentialStore() = default;
friend class PersistableStore<WifiCredentialStore>;
bool loadFromBinaryFile();
friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*);
friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*);
public:
static const char* getFilePath() { return "/.crosspoint/wifi.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
// 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();
// Credential management
bool addCredential(const std::string& ssid, const std::string& password);
-11
View File
@@ -44,17 +44,6 @@ 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
View File
@@ -256,25 +256,6 @@ 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 {
-13
View File
@@ -102,16 +102,6 @@ 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;
@@ -119,9 +109,6 @@ 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();
@@ -410,8 +410,7 @@ void CrossPointWebServerActivity::renderServerRunning() const {
startY += height10 + metrics.verticalSpacing * 2;
// Show QR code for Wifi
// 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 std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
const Rect qrBoundsWifi(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT);
QrUtils::drawQrCode(renderer, qrBoundsWifi, wifiConfig);
@@ -6,7 +6,6 @@
#include <Logging.h>
#include <WiFi.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "WifiCredentialStore.h"
@@ -94,11 +93,6 @@ 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();
@@ -217,7 +211,6 @@ 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
+29 -228
View File
@@ -17,7 +17,6 @@
#include <iterator>
#include <limits>
#include "BleInput.h"
#include "BookmarkEntry.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
@@ -33,7 +32,6 @@
#include "QrDisplayActivity.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "SilentRestart.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkUtil.h"
@@ -67,55 +65,6 @@ bool isInReadFolder(const std::string& path) {
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
}
class FrameBufferBuildLoan {
public:
explicit FrameBufferBuildLoan(GfxRenderer& renderer) : renderer_(renderer) {}
~FrameBufferBuildLoan() {
if (active_ && !restore()) {
ESP.restart();
}
}
void release() {
if (active_ || !renderer_.hasFrameBuffer()) return;
if (bleinput::startInProgress()) {
LOG_INF("ERS", "Framebuffer loan waiting for BLE start to settle");
const uint32_t deadline = millis() + 1000;
while (bleinput::startInProgress() && millis() < deadline) {
delay(5);
}
}
renderer_.releaseFrameBufferForBuild();
active_ = true;
LOG_DBG("ERS", "Framebuffer lent for section build (ble=%u heap=%u maxAlloc=%u)", BleHid.isRunning() ? 1 : 0,
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
}
bool restore() {
if (!active_) return true;
active_ = false;
if (renderer_.restoreFrameBufferAfterBuild()) {
LOG_DBG("ERS", "Framebuffer restored after section build");
return true;
}
if (BleHid.isRunning()) {
LOG_INF("ERS", "Framebuffer restore needs heap; freeing BLE and retrying (heap=%u maxAlloc=%u)",
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
bleinput::stop();
if (renderer_.restoreFrameBufferAfterBuild()) {
LOG_DBG("ERS", "Framebuffer restored after freeing BLE");
return true;
}
}
LOG_ERR("ERS", "Framebuffer restore failed after section build");
return false;
}
private:
GfxRenderer& renderer_;
bool active_ = false;
};
struct ProgressRange {
float start;
float end;
@@ -306,32 +255,6 @@ void EpubReaderActivity::openReaderMenu() {
});
}
bool EpubReaderActivity::buildTickHeapGate() {
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxBlock = ESP.getMaxAllocHeap();
if (freeHeap >= BACKGROUND_BUILD_MIN_FREE_HEAP && maxBlock >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
return true;
}
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
if (lendableFrameBuffer > 0 && freeHeap + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_FREE_HEAP &&
maxBlock + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
return true;
}
// Below the floors. If the BLE stack is what's squeezing the heap, shed it — the
// established policy on this branch is that builds and resident BLE don't coexist,
// and this was the one build path without that protection (field crash: a tick's
// parse allocation aborted at maxAlloc ~11 KB with BLE resident). The lifecycle's
// build-pending deferral keeps BLE down until the window is caught up, then
// restarts it behind the start floor. Without BLE resident, just wait: page-turn
// transients free up between turns and the tick retries every loop pass.
if (BleHid.isRunning()) {
LOG_INF("ERS", "Background build needs heap (free=%u maxAlloc=%u); freeing BLE RAM", (unsigned)freeHeap,
(unsigned)maxBlock);
bleinput::stop();
}
return false;
}
void EpubReaderActivity::loop() {
if (!epub) {
// Should never happen
@@ -346,7 +269,7 @@ void EpubReaderActivity::loop() {
// Skip while the render mutex is busy so we never delay a pending render; re-check
// isBuilding() under the lock since render() may have just finished it.
if (section && section->isBuilding() && !RenderLock::peek() &&
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) {
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
RenderLock lock;
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
// build between the outer isBuilding() check and acquiring the lock here, in which case
@@ -354,8 +277,6 @@ void EpubReaderActivity::loop() {
// mutation, so it flags this as always true.
// cppcheck-suppress knownConditionTrueFalse
if (section->isBuilding()) {
FrameBufferBuildLoan buildLoan(renderer);
buildLoan.release();
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
LOG_ERR("ERS", "Background section build failed");
section.reset();
@@ -365,9 +286,6 @@ void EpubReaderActivity::loop() {
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
requestUpdate();
}
if (!buildLoan.restore()) {
ESP.restart();
}
}
}
@@ -969,36 +887,12 @@ void EpubReaderActivity::render(RenderLock&& lock) {
return;
}
FrameBufferBuildLoan buildLoan(renderer);
// If BLE leaves the reader below the render floor, lend the framebuffer before
// deserializing/loading the page instead of tearing BLE down immediately. The
// restore path still frees BLE if the framebuffer cannot be reallocated.
if (BleHid.isRunning() && ESP.getFreeHeap() < RENDER_MIN_FREE_HEAP) {
LOG_INF("ERS", "Render heap %u below floor %u; lending framebuffer", (unsigned)ESP.getFreeHeap(),
(unsigned)RENDER_MIN_FREE_HEAP);
buildLoan.release();
}
const auto showPendingSyncSaveError = [this]() {
if (!pendingSyncSaveError) return;
pendingSyncSaveError = false;
GUI.drawPopup(renderer, tr(STR_SAVE_PROGRESS_FAILED));
};
// A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the
// "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging.
// clearScreen first so the error popup doesn't overlay the stale "Indexing" popup.
const auto showBuildError = [this, &buildLoan]() {
if (!buildLoan.restore()) {
ESP.restart();
return;
}
renderer.clearScreen();
GUI.drawPopup(renderer, tr(STR_INDEX_FAILED));
automaticPageTurnActive = false;
};
// edge case handling for sub-zero spine index
if (currentSpineIndex < 0) {
currentSpineIndex = 0;
@@ -1072,44 +966,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
LOG_DBG("ERS", "Cache not found, building...");
}
// The layout code (line-break DP arrays, CSS lookups, glyph buffers) allocates freely
// and abort()s on OOM under -fno-exceptions, so a starved heap must be handled BEFORE
// the build: pre-flight the floor and take the recovery path up front instead of
// crashing mid-parse. Field data: builds succeed at ~46 KB free with BLE resident;
// abort() observed at ~11 KB free.
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
const bool heapTooLow = ESP.getFreeHeap() + lendableFrameBuffer < BUILD_MIN_FREE_HEAP;
if (heapTooLow) {
LOG_ERR("ERS", "Pre-build heap %u (+fb %u) below floor %u; entering build recovery",
(unsigned)ESP.getFreeHeap(), (unsigned)lendableFrameBuffer, (unsigned)BUILD_MIN_FREE_HEAP);
}
// Building a section needs a large contiguous inflate (deflate) window that the
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
// On build failure (or a pre-flight floor miss) with BT enabled: free the BLE stack
// and retry. The chapter is cached afterwards, so this recovery runs at most once
// per uncached chapter.
// Deliberately do NOT restart BLE inline: this render still has its own allocations
// to make. The main-loop lifecycle restarts BLE later, behind its activity,
// render-lock, framebuffer, and heap gates.
const auto retryWithBleFreed = [&](auto&& buildFn) {
LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying");
bleinput::stop();
return buildFn();
};
// Even with BLE freed, the build can fail when this session's parse churn has
// fragmented the heap beyond in-place recovery. A silent restart is the only
// real defrag on this heap (no compaction); it resumes into this book and
// rebuilds the section on a fresh heap. Guarded by bootWasSilentRestart() so a
// build that fails again after the restart degrades to the error popup below
// instead of reboot-looping.
const auto silentRestartDefrag = [&]() {
if (bootWasSilentRestart()) return;
LOG_ERR("ERS", "Section build failed after BLE recovery; silent restart to defrag heap");
silentRestartToReader();
};
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
// resolve their landing page until the whole chapter is laid out, so they take the full
@@ -1128,27 +984,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
pagesUntilFullRefresh = 1;
const auto popupFn = [this]() {
if (renderer.hasFrameBuffer()) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
}
};
buildLoan.release();
const auto buildSection = [&]() {
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
};
bool built = !heapTooLow && buildSection();
if (!built && SETTINGS.bluetoothEnabled) {
built = retryWithBleFreed(buildSection);
}
if (!built) {
silentRestartDefrag();
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
} else {
@@ -1184,25 +1027,13 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
buildLoan.release();
// startBuild does the zip inflate (the big contiguous allocation), so it gets
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
// retry safe.
const auto beginBuild = [&]() {
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed to start section build");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
while (!section->isBuildComplete() &&
@@ -1213,7 +1044,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
}
@@ -1260,7 +1091,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// ahead of the background builder; pages already built do no work here.
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
// Start a build to extend a partial toward the requested page.
buildLoan.release();
if (!section->isBuilding() &&
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
@@ -1268,7 +1098,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed to start partial extension build");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
// Extend until either the target page exists or the build completes.
@@ -1276,32 +1106,23 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
}
}
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
if (section->isBuilding()) {
buildLoan.release();
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showBuildError();
showPendingSyncSaveError();
return;
}
}
}
const auto restoreFramebufferForDraw = [&buildLoan]() {
if (!buildLoan.restore()) {
ESP.restart();
return false;
}
return true;
};
// The requested page is now as built as it will get. If it still lands past the end,
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
@@ -1316,10 +1137,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
applyDeferredReposition();
renderer.clearScreen();
if (section->pageCount == 0) {
LOG_DBG("ERS", "No pages to render");
if (!restoreFramebufferForDraw()) return;
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD);
renderStatusBar();
renderer.displayBuffer();
@@ -1330,8 +1151,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
if (!restoreFramebufferForDraw()) return;
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD);
renderStatusBar();
renderer.displayBuffer();
@@ -1348,44 +1167,24 @@ void EpubReaderActivity::render(RenderLock&& lock) {
auto p = section->loadPage(section->currentPage);
if (!p) {
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
automaticPageTurnActive = false;
// Retrying rebuilds a transiently corrupt section and usually recovers, but a page that keeps
// failing would loop forever on a blank screen, so bound the retries before giving up.
const bool giveUp = ++pageLoadRetryCount > MAX_PAGE_LOAD_RETRIES;
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
// and the destructor's suspend would otherwise commit tables into a deleted handle.
section->abandonBuild();
section->clearCache();
section.reset();
if (giveUp) {
LOG_ERR("ERS", "Page load retry limit reached, aborting");
pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh
if (!restoreFramebufferForDraw()) return;
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
showPendingSyncSaveError();
return;
}
requestUpdate(); // Try again after clearing cache
// TODO: prevent infinite loop if the page keeps failing to load for some reason
automaticPageTurnActive = false;
showPendingSyncSaveError();
return;
}
pageLoadRetryCount = 0; // Reset the retry counter once a page loads cleanly
// Collect footnotes from the loaded page
currentPageFootnotes = std::move(p->footnotes);
if (!restoreFramebufferForDraw()) return;
renderer.clearScreen();
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
// Fragmentation tracker: free vs largest block after every page. A falling
// maxAlloc/free ratio across pages points at whichever allocation pattern the
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
}
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
@@ -1442,7 +1241,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const auto tPrewarm = millis();
const bool pageHasImages = page->hasImages();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsTextGrayscale = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FULL;
const bool fastTextAA = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) {
@@ -1452,7 +1252,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
};
// Fast AA dithers 2-bit glyph edges in the BW pass itself, no grayscale
// refresh. Scoped to the page render so nothing else inherits the flag.
renderer.setFastAntiAliasing(fastTextAA);
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderer.setFastAntiAliasing(false);
renderStatusBar();
const auto tBwRender = millis();
@@ -1469,7 +1273,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// Re-render page content to restore images into the blanked area
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
renderer.setFastAntiAliasing(fastTextAA);
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderer.setFastAntiAliasing(false);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
@@ -1629,13 +1435,8 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
if (SETTINGS.bluetoothEnabled && !BleHid.isConnected()) {
const std::string btStatus = tr(STR_BT_CONNECTING_POPUP);
title = title.empty() ? btStatus : btStatus + " " + title;
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
section->isBuilding(), BleHid.isConnected());
section->isBuilding());
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -32,10 +32,6 @@ class EpubReaderActivity final : public Activity {
float pendingSpineProgress = 0.0f;
bool pendingScreenshot = false;
bool pendingSyncSaveError = false;
// Consecutive page-load failures. Each failure drops the section and rebuilds on the next render,
// which recovers a transiently corrupt cache; capped so a persistently bad page can't spin forever.
uint8_t pageLoadRetryCount = 0;
static constexpr uint8_t MAX_PAGE_LOAD_RETRIES = 3;
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool automaticPageTurnActive = false;
bool showBookmarkMessage = false;
@@ -63,21 +59,6 @@ class EpubReaderActivity final : public Activity {
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
int footnoteDepth = 0;
// Heap floor for entering a section build. The layout code allocates freely (line-break DP
// arrays sized by word count, CSS rule lookups, glyph buffers) and under -fno-exceptions an
// OOM there abort()s the firmware instead of failing cleanly -- so a starved heap must be
// handled *before* the build, not after. Field data: builds succeed at ~46 KB free with BLE
// resident; abort() observed at ~11 KB free. CSS styling already degrades below 48 KB
// (MIN_FREE_HEAP_FOR_CSS), so 40 KB trades a few early BLE teardowns for not crashing.
static constexpr size_t BUILD_MIN_FREE_HEAP = 40 * 1024;
// Heap floor for rendering a page at all. Page deserialization (TextBlock word
// vectors/strings) and glyph caching allocate through throwing paths that abort()
// on OOM; below this floor render() sheds the BLE stack (~52 KB back, and it
// restores a large contiguous block) before touching the page. Field data: a
// session with no shed ground to <2.2 KB free and aborted on a page load.
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
@@ -86,28 +67,6 @@ class EpubReaderActivity final : public Activity {
// background build chunk never noticeably delays input or a pending render.
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
// Skip background build ticks below this free-heap floor. The parse path grows
// word vectors of heap strings — throwing allocations that abort() on OOM under
// -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a
// background tick with the BLE stack resident). The tick is deferrable work:
// page-turn transients free up between turns and the build resumes; the render
// path still builds the page it actually needs regardless of this floor.
// Calibrated BETWEEN the measured states: steady reading with BLE resident runs at
// ~29.4 KB free / ~16.4 KB largest block (ticks are safe there — a 2-page parse
// transient is a few KB), while the field crash happened at 34.7 KB free with an
// ~11 KB largest block. A first cut at 32 KB/16 KB sat just ABOVE the healthy
// steady state, guaranteeing a pointless BLE shed the moment any build work was
// pending (the maxAlloc floor fired on a 12-byte shortfall).
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 26 * 1024;
// Fragmentation floor for the same gate: free heap says how much memory exists;
// maxAlloc says whether any single allocation can actually have it.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 13 * 1024;
// Gate for a background build tick: true when the heap can take parse allocations.
// When BLE is what's squeezing the heap, sheds it (build-pending deferral in the
// lifecycle then holds restarts off until the window is caught up) instead of
// stalling the build forever below the floors.
bool buildTickHeapGate();
// How many pages to keep laid out ahead of the reader for a still-building section. A page
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
// -- a tiny buffer is enough. The background build stops once the watermark is this far
@@ -156,7 +115,6 @@ class EpubReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; }
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
ScreenshotInfo getScreenshotInfo() const override;
CrossPointPosition getCurrentPosition() const;
};
@@ -2,12 +2,8 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -26,7 +22,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
bool hasBookmarks) {
std::vector<MenuItem> items;
items.reserve(13);
items.reserve(12);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
@@ -40,7 +36,6 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON});
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
items.push_back({MenuAction::TOGGLE_BLUETOOTH, StrId::STR_TOGGLE_BLUETOOTH});
items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
@@ -90,24 +85,6 @@ void EpubReaderMenuActivity::loop() {
return;
}
if (selectedAction == MenuAction::TOGGLE_BLUETOOTH) {
// Just flip the preference and stay in the menu. The main-loop lifecycle check
// brings the BLE stack up/down to match, so start/stop has a single owner.
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
SETTINGS.saveToFile();
// Turning BT on below the lifecycle's heap floor would otherwise wait
// until the heap happens to recover -- which a long session's fragmentation never
// gives back. The user asked for BT *now*: silent-restart into this book to
// defrag (fresh boot is ~118 KB free, comfortably above the floor), and BT
// auto-starts on the way back in.
if (SETTINGS.bluetoothEnabled && !BleHid.isRunning() && ESP.getFreeHeap() < bleinput::kStartMinFreeHeap) {
LOG_INF("ERM", "BT enabled below heap floor (%u); silent restart to defrag", ESP.getFreeHeap());
silentRestartToReader();
}
requestUpdate();
return;
}
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
finish();
return;
@@ -159,12 +136,6 @@ void EpubReaderMenuActivity::render(RenderLock&&) {
} else if (value == MenuAction::AUTO_PAGE_TURN) {
// Render current page turn value on the right edge of the content area.
return pageTurnLabels[selectedPageTurnOption];
} else if (value == MenuAction::TOGGLE_BLUETOOTH) {
if (SETTINGS.bluetoothEnabled) {
if (!BleHid.isRunning()) return tr(STR_CONNECTING);
return BleHid.isConnected() ? tr(STR_STATE_ON) : tr(STR_CONNECTING);
}
return tr(STR_STATE_OFF);
} else {
return "";
}
@@ -24,8 +24,7 @@ class EpubReaderMenuActivity final : public Activity {
DISPLAY_QR,
GO_HOME,
SYNC,
DELETE_CACHE,
TOGGLE_BLUETOOTH
DELETE_CACHE
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
+1 -2
View File
@@ -31,6 +31,5 @@ class ReaderActivity final : public Activity {
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath)
: Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {}
void onEnter() override;
bool isReaderActivity() const override { return false; }
bool deferBluetoothStart() const override { return true; }
bool isReaderActivity() const override { return true; }
};
+5 -2
View File
@@ -400,13 +400,16 @@ void TxtReaderActivity::renderPage() {
renderLines(); // scan pass — text accumulated, no drawing
scope.endScanAndPrewarm();
// BW rendering
// BW rendering. Fast AA dithers 2-bit glyph edges in this single pass;
// scoped to the page text so nothing else inherits the flag.
renderer.setFastAntiAliasing(SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST);
renderLines();
renderer.setFastAntiAliasing(false);
renderStatusBar();
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
if (SETTINGS.textAntiAliasing) {
if (SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FULL) {
ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); });
}
// scope destructor clears font cache via FontCacheManager
@@ -49,6 +49,5 @@ class TxtReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
ScreenshotInfo getScreenshotInfo() const override;
};
@@ -46,6 +46,5 @@ class XtcReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
ScreenshotInfo getScreenshotInfo() const override;
};
@@ -1,167 +0,0 @@
#include "BleButtonMapActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <cstdio>
#include <iterator>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "components/UITheme.h"
#include "fontIds.h"
// Logical functions offered for binding. Page navigation + confirm cover Free2 /
// Free3; the directions are included so a remote can also drive menu navigation.
const BleButtonMapActivity::Fn BleButtonMapActivity::kFunctions[] = {
{MappedInputManager::Button::PageForward, StrId::STR_BT_PAGE_FORWARD},
{MappedInputManager::Button::PageBack, StrId::STR_BT_PAGE_BACK},
{MappedInputManager::Button::Confirm, StrId::STR_CONFIRM},
{MappedInputManager::Button::Back, StrId::STR_BACK},
{MappedInputManager::Button::Up, StrId::STR_DIR_UP},
{MappedInputManager::Button::Down, StrId::STR_DIR_DOWN},
{MappedInputManager::Button::Left, StrId::STR_DIR_LEFT},
{MappedInputManager::Button::Right, StrId::STR_DIR_RIGHT},
};
const uint8_t BleButtonMapActivity::kFunctionCount = static_cast<uint8_t>(sizeof(kFunctions) / sizeof(kFunctions[0]));
void BleButtonMapActivity::onEnter() {
Activity::onEnter();
step = Step::WaitForKey;
capturedKind = 0xFF;
functionIndex = 0;
// Start every mapping session from a clean slate: the user re-maps each remote
// button once, so a button can't be left bound to a stale action and there's no
// separate "clear mappings" step to remember.
std::fill(std::begin(SETTINGS.bleKeyMap), std::end(SETTINGS.bleKeyMap), CrossPointSettings::BleKeyMapEntry{});
SETTINGS.saveToFile();
mappedInput.setBleCaptureMode(true);
requestUpdate();
}
void BleButtonMapActivity::onExit() {
mappedInput.setBleCaptureMode(false);
Activity::onExit();
}
bool BleButtonMapActivity::assignCapturedKey(MappedInputManager::Button button) {
const uint8_t btn = static_cast<uint8_t>(button);
// Mutated via std::replace_if below and through `slot`; cppcheck's CI parse
// (no include paths) can't see the writes and suggests const.
// cppcheck-suppress constVariableReference
auto& map = SETTINGS.bleKeyMap;
using Entry = CrossPointSettings::BleKeyMapEntry;
const uint8_t kind = capturedKind;
const uint8_t value = capturedValue;
// One key per action: drop any other key currently bound to this action so the same
// action can't be triggered by two different remote buttons.
std::replace_if(
std::begin(map), std::end(map),
[&](const Entry& e) { return e.button == btn && !(e.keyKind == kind && e.keyValue == value); }, Entry{});
// Reuse the slot already bound to this key, else the first free slot.
auto* slot = std::find_if(std::begin(map), std::end(map), [&](const Entry& e) {
return e.button != 0xFF && e.keyKind == kind && e.keyValue == value;
});
if (slot == std::end(map)) {
slot = std::find_if(std::begin(map), std::end(map),
[](const Entry& e) { return e.button == 0xFF || e.keyKind == 0xFF; });
}
if (slot == std::end(map)) return false; // table full
slot->keyKind = kind;
slot->keyValue = value;
slot->button = btn;
SETTINGS.saveToFile();
return true;
}
void BleButtonMapActivity::loop() {
// Front Back button exits the mapping screen at any step.
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (step == Step::WaitForKey) {
uint8_t kind = 0xFF;
uint8_t value = 0;
if (mappedInput.takeCapturedBleKey(kind, value)) {
capturedKind = kind;
capturedValue = value;
functionIndex = 0;
step = Step::SelectFunction;
requestUpdate();
}
return;
}
// Step::SelectFunction — pick a logical function for the captured key.
buttonNavigator.onNext([this] {
functionIndex = ButtonNavigator::nextIndex(functionIndex, kFunctionCount);
requestUpdate();
});
buttonNavigator.onPrevious([this] {
functionIndex = ButtonNavigator::previousIndex(functionIndex, kFunctionCount);
requestUpdate();
});
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
assignCapturedKey(kFunctions[functionIndex].button);
// Back to capturing so the user can map (or re-map) the next remote button.
step = Step::WaitForKey;
capturedKind = 0xFF;
requestUpdate();
}
}
void BleButtonMapActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_BT_MAP_BUTTONS));
const int topOffset = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - topOffset - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (step == Step::WaitForKey) {
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
tr(STR_BT_PRESS_REMOTE));
// Show the current mappings so the user sees progress.
int row = 0;
for (const auto& e : SETTINGS.bleKeyMap) {
if (e.button == 0xFF) continue;
char keyName[24];
bleinput::describeKey(e.keyKind, e.keyValue, keyName, sizeof(keyName));
const char* fnName = "";
for (uint8_t i = 0; i < kFunctionCount; i++) {
if (static_cast<uint8_t>(kFunctions[i].button) == e.button) {
fnName = I18N.get(kFunctions[i].label);
break;
}
}
char line[64];
snprintf(line, sizeof(line), "%s -> %s", keyName, fnName);
GUI.drawHelpText(renderer, Rect{0, topOffset + row * 22, pageWidth, 20}, line);
row++;
}
} else {
char captured[24];
bleinput::describeKey(capturedKind, capturedValue, captured, sizeof(captured));
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
captured);
GUI.drawList(
renderer, Rect{0, topOffset, pageWidth, contentHeight}, kFunctionCount, functionIndex,
[this](int i) { return std::string(I18N.get(kFunctions[i].label)); }, nullptr, nullptr, nullptr, false);
}
const char* confirm = step == Step::WaitForKey ? "" : tr(STR_SELECT);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirm, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -1,47 +0,0 @@
#pragma once
#include <I18n.h>
#include <cstdint>
#include "MappedInputManager.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// Capture-then-assign mapping for BLE page-turner buttons. The user presses a
// button on the remote; we capture its decoded key identity (via the
// MappedInputManager BLE capture mode) and let them bind it to a logical button.
// Repeat to map each remote button; Back exits. Mirrors ButtonRemapActivity's
// flow, but the input source is the BLE host instead of the front buttons.
class BleButtonMapActivity final : public Activity {
public:
explicit BleButtonMapActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("BleButtonMap", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
// Logical functions a remote button can be bound to.
struct Fn {
MappedInputManager::Button button;
StrId label;
};
static const Fn kFunctions[];
static const uint8_t kFunctionCount;
enum class Step { WaitForKey, SelectFunction };
Step step = Step::WaitForKey;
uint8_t capturedKind = 0xFF;
uint8_t capturedValue = 0;
int functionIndex = 0;
ButtonNavigator buttonNavigator;
// Bind the captured key to the chosen logical button in SETTINGS.bleKeyMap and
// persist. Returns false when the table is full and the key is new.
bool assignCapturedKey(MappedInputManager::Button button);
};
@@ -1,314 +0,0 @@
#include "BluetoothSettingsActivity.h"
#include <BleKeyboardHost.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <cstdio>
#include "BleButtonMapActivity.h"
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr unsigned long kBannerMs = 2000;
constexpr uint32_t kScanMs = 8000;
constexpr unsigned long kForgetHoldMs = 1200; // hold Confirm this long in the Paired view to forget
} // namespace
void BluetoothSettingsActivity::onEnter() {
Activity::onEnter();
view = View::Menu;
menuIndex = 0;
rebuildMenuRows();
requestUpdate();
}
void BluetoothSettingsActivity::onExit() {
if (BleHid.isScanning()) BleHid.stopScan();
Activity::onExit();
}
void BluetoothSettingsActivity::setBanner(const char* text) {
banner = text ? text : "";
bannerUntil = millis() + kBannerMs;
}
void BluetoothSettingsActivity::rebuildMenuRows() {
menuRows.clear();
menuRows.reserve(8);
menuRows.push_back({Action::ToggleBt, StrId::STR_BLUETOOTH});
if (SETTINGS.bluetoothEnabled) {
menuRows.push_back({Action::Scan, StrId::STR_BT_SCAN_PAIR});
if (BleHid.isConnected()) menuRows.push_back({Action::Disconnect, StrId::STR_BT_DISCONNECT});
menuRows.push_back({Action::PairedDevices, StrId::STR_BT_PAIRED_DEVICES});
menuRows.push_back({Action::MapButtons, StrId::STR_BT_MAP_BUTTONS});
}
if (menuIndex >= static_cast<int>(menuRows.size())) menuIndex = 0;
}
void BluetoothSettingsActivity::startScanView() {
LOG_INF("BLEUI", "scan view: begin running=%d scanning=%d devices=%u paired=%u", BleHid.isRunning(),
BleHid.isScanning(), BleHid.deviceCount(), BleHid.pairedCount());
view = View::Scan;
scanIndex = 0;
awaitingConnect = false;
lastLoggedScanState = false;
lastLoggedDeviceCount = 0xFF;
// The main-loop lifecycle owns steady-state start/stop, but a scan needs the stack
// this instant — entering this screen can precede the lifecycle's next tick, or its
// heap gate may have deferred the start. ensureStarted() is idempotent, and with
// this screen on top the lifecycle keeps the stack up (keepsBluetoothAlive).
if (!BleHid.isRunning() && !bleinput::ensureStarted()) {
LOG_ERR("BLEUI", "scan: BLE start failed (heap=%u)", ESP.getFreeHeap());
}
BleHid.startScan(kScanMs);
LOG_INF("BLEUI", "scan view: startScan requested scanning=%d devices=%u", BleHid.isScanning(), BleHid.deviceCount());
requestUpdate();
}
void BluetoothSettingsActivity::handleMenuConfirm() {
if (menuRows.empty()) return;
const Action action = menuRows[menuIndex].action;
switch (action) {
case Action::ToggleBt:
// Flip the preference only; the main-loop lifecycle check starts/stops the BLE
// stack to match (and shows the "BT Connecting..." popup). Single owner.
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
SETTINGS.saveToFile();
rebuildMenuRows();
requestUpdate();
break;
case Action::Scan:
startScanView();
break;
case Action::Disconnect:
BleHid.disconnect();
setBanner(tr(STR_BT_NOT_CONNECTED));
rebuildMenuRows();
requestUpdate();
break;
case Action::PairedDevices:
view = View::Paired;
pairedIndex = 0;
requestUpdate();
break;
case Action::MapButtons:
startActivityForResult(std::make_unique<BleButtonMapActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
rebuildMenuRows();
requestUpdate();
});
break;
}
}
void BluetoothSettingsActivity::loop() {
// Clear an expired status banner.
if (bannerUntil > 0 && millis() > bannerUntil) {
banner.clear();
bannerUntil = 0;
requestUpdate();
}
// Watch for an async connect result (from either the scan list or the paired list).
if (awaitingConnect) {
char reason[48];
if (BleHid.isConnected()) {
awaitingConnect = false;
BleHid.releaseScanResults();
view = View::Menu;
rebuildMenuRows();
char buf[64];
snprintf(buf, sizeof(buf), tr(STR_BT_CONNECTED_TO), BleHid.connectedName());
setBanner(buf);
requestUpdate();
} else if (BleHid.takeConnectFailure(reason, sizeof(reason))) {
awaitingConnect = false;
setBanner(reason);
requestUpdate();
}
}
// Back returns to the menu from a sub-view, or leaves the screen from the menu.
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (view == View::Menu) {
finish();
} else {
if (BleHid.isScanning()) BleHid.stopScan();
view = View::Menu;
rebuildMenuRows();
requestUpdate();
}
return;
}
// Navigation within the active list.
const int count = view == View::Menu ? static_cast<int>(menuRows.size())
: view == View::Scan ? BleHid.deviceCount()
: BleHid.pairedCount();
int* idx = view == View::Menu ? &menuIndex : view == View::Scan ? &scanIndex : &pairedIndex;
buttonNavigator.onNext([this, count, idx] {
if (count > 0) *idx = ButtonNavigator::nextIndex(*idx, count);
requestUpdate();
});
buttonNavigator.onPrevious([this, count, idx] {
if (count > 0) *idx = ButtonNavigator::previousIndex(*idx, count);
requestUpdate();
});
// Paired view: tap Confirm to connect, hold Confirm to forget. Uses release for
// connect so a hold can fire forget without also connecting on the same press.
if (view == View::Paired) {
if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
if (!pairedActionTaken && mappedInput.getHeldTime() >= kForgetHoldMs && pairedIndex < BleHid.pairedCount()) {
const auto& p = BleHid.paired(static_cast<uint8_t>(pairedIndex));
BleHid.forget(p.addr);
if (pairedIndex > 0) pairedIndex--;
setBanner(tr(STR_FORGET_BUTTON));
pairedActionTaken = true;
rebuildMenuRows();
requestUpdate();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!pairedActionTaken && !awaitingConnect && pairedIndex < BleHid.pairedCount()) {
const auto& p = BleHid.paired(static_cast<uint8_t>(pairedIndex));
awaitingConnect = true;
setBanner(tr(STR_CONNECTING));
BleHid.connect(p.addr);
requestUpdate();
}
pairedActionTaken = false;
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (view == View::Menu) {
handleMenuConfirm();
} else if (view == View::Scan) {
const int scanCount = BleHid.deviceCount();
if (!awaitingConnect && scanCount == 0 && !BleHid.isScanning()) {
LOG_INF("BLEUI", "scan view: restart scan requested");
if (!BleHid.isRunning() && !bleinput::ensureStarted()) {
LOG_ERR("BLEUI", "scan restart: BLE start failed (heap=%u)", ESP.getFreeHeap());
}
BleHid.startScan(kScanMs);
LOG_INF("BLEUI", "scan view: restart scan state scanning=%d devices=%u", BleHid.isScanning(),
BleHid.deviceCount());
requestUpdate();
} else if (!awaitingConnect && scanIndex < scanCount) {
if (BleHid.isScanning()) BleHid.stopScan();
const auto& d = BleHid.device(static_cast<uint8_t>(scanIndex));
LOG_INF("BLEUI", "scan view: connect addr=%s name='%s' rssi=%d type=%u hid=%d conn=%d", d.addr, d.name, d.rssi,
d.addrType, d.hid, d.connectable);
awaitingConnect = true;
setBanner(tr(STR_CONNECTING));
BleHid.connect(d.addr);
requestUpdate();
}
}
return;
}
// The scan list changes as devices are discovered — keep repainting while active.
if (view == View::Scan) {
const bool scanning = BleHid.isScanning();
const uint8_t deviceCount = BleHid.deviceCount();
if (scanning != lastLoggedScanState || deviceCount != lastLoggedDeviceCount) {
LOG_INF("BLEUI", "scan view: state scanning=%d devices=%u", scanning, deviceCount);
lastLoggedScanState = scanning;
lastLoggedDeviceCount = deviceCount;
}
if (scanning) requestUpdate();
}
}
std::string BluetoothSettingsActivity::deviceLabel(int index) const {
if (index >= BleHid.deviceCount()) return "";
const auto& d = BleHid.device(static_cast<uint8_t>(index));
return std::string(d.name);
}
std::string BluetoothSettingsActivity::pairedLabel(int index) const {
if (index >= BleHid.pairedCount()) return "";
const auto& p = BleHid.paired(static_cast<uint8_t>(index));
return std::string(p.name);
}
void BluetoothSettingsActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const char* title = tr(STR_BLUETOOTH);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title);
// Sub-header: connection status.
const char* status = BleHid.isConnected() ? BleHid.connectedName() : tr(STR_BT_NOT_CONNECTED);
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
status);
const int topOffset = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - topOffset - metrics.buttonHintsHeight - metrics.verticalSpacing;
const Rect listRect{0, topOffset, pageWidth, contentHeight};
if (view == View::Menu) {
GUI.drawList(
renderer, listRect, static_cast<int>(menuRows.size()), menuIndex,
[this](int i) { return std::string(I18N.get(menuRows[i].label)); }, nullptr, nullptr,
[this](int i) -> std::string {
if (menuRows[i].action == Action::ToggleBt)
return SETTINGS.bluetoothEnabled ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
return "";
},
true);
} else if (view == View::Scan) {
// Free2/3 remotes only advertise in the right slider mode — tell the user how.
GUI.drawHelpText(renderer, Rect{0, topOffset, pageWidth, 16}, tr(STR_BT_FREE_HINT1));
GUI.drawHelpText(renderer, Rect{0, topOffset + 16, pageWidth, 16}, tr(STR_BT_FREE_HINT2));
const int scanTop = topOffset + 38;
const int count = BleHid.deviceCount();
if (count == 0) {
GUI.drawHelpText(renderer, Rect{0, scanTop, pageWidth, 24},
BleHid.isScanning() ? tr(STR_SCANNING) : tr(STR_BT_NO_DEVICES));
} else {
GUI.drawList(
renderer, Rect{0, scanTop, pageWidth, contentHeight - 38}, count, scanIndex,
[this](int i) { return deviceLabel(i); }, nullptr, nullptr, nullptr, false);
}
} else { // Paired
const int count = BleHid.pairedCount();
if (count == 0) {
GUI.drawHelpText(renderer, Rect{0, topOffset + metrics.verticalSpacing, pageWidth, 24}, tr(STR_BT_NO_PAIRED));
} else {
GUI.drawList(
renderer, listRect, count, pairedIndex, [this](int i) { return pairedLabel(i); }, nullptr, nullptr, nullptr,
false);
}
}
// Transient banner above the hints.
if (!banner.empty()) {
GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - 22, pageWidth, 20}, banner.c_str());
}
// In the paired list, Confirm connects and a hold forgets — surface the hold hint.
if (view == View::Paired && BleHid.pairedCount() > 0 && banner.empty()) {
GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - 22, pageWidth, 20},
tr(STR_BT_FORGET_PROMPT));
}
// Button hints differ by view (Menu selects; Scan and Paired both connect).
const bool scanCanRestart = view == View::Scan && BleHid.deviceCount() == 0 && !BleHid.isScanning();
const char* confirm = view == View::Menu ? tr(STR_SELECT) : scanCanRestart ? "Scan" : tr(STR_CONNECT);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirm, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -1,65 +0,0 @@
#pragma once
#include <I18n.h>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// Bluetooth page-turner settings. One screen with three views:
// Menu — enable/disable BT, scan & pair, disconnect, map buttons, presets.
// Scan — live list of discovered BLE HID devices; Confirm connects.
// Paired — bonded devices; Confirm forgets the selected one.
// All BLE access goes through the FreeInk BleHid singleton; everything no-ops
// gracefully when BLE is compiled out (BleHid.begin() returns false).
class BluetoothSettingsActivity final : public Activity {
public:
explicit BluetoothSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("BluetoothSettings", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool keepsBluetoothAlive() const override { return true; }
private:
enum class View { Menu, Scan, Paired };
// Menu row actions.
enum class Action { ToggleBt, Scan, Disconnect, MapButtons, PairedDevices };
struct MenuRow {
Action action;
StrId label;
};
View view = View::Menu;
std::vector<MenuRow> menuRows;
int menuIndex = 0;
int scanIndex = 0;
int pairedIndex = 0;
ButtonNavigator buttonNavigator;
// Transient status banner (connect result, forget confirmation, etc.).
std::string banner;
unsigned long bannerUntil = 0;
// Set when a connect() has been issued and we're waiting for the async result.
bool awaitingConnect = false;
// Guards the Paired view's hold-to-forget so it fires once per hold and suppresses
// the tap-to-connect on the same press.
bool pairedActionTaken = false;
bool lastLoggedScanState = false;
uint8_t lastLoggedDeviceCount = 0xFF;
void rebuildMenuRows();
void handleMenuConfirm();
void startScanView();
void setBanner(const char* text);
std::string deviceLabel(int index) const; // scan list row text
std::string pairedLabel(int index) const; // paired list row text
};
@@ -7,7 +7,6 @@
#include <cstdio>
#include <cstring>
#include "BluetoothSettingsActivity.h"
#include "ButtonRemapActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
@@ -60,7 +59,6 @@ void SettingsActivity::rebuildSettingsLists() {
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
controlsSettings.push_back(SettingInfo::Action(StrId::STR_BLUETOOTH, SettingAction::Bluetooth));
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
@@ -297,9 +295,6 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::Language:
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::Bluetooth:
startActivityForResult(std::make_unique<BluetoothSettingsActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::None:
// Do nothing
break;
@@ -24,7 +24,6 @@ enum class SettingAction {
SdFirmwareUpdate,
Language,
DownloadFonts,
Bluetooth,
};
struct SettingInfo {
-7
View File
@@ -1,7 +0,0 @@
#pragma once
#include <cstdint>
// size: 16x16, generated from Lucide bluetooth.svg with FreeInk's icon generator.
static const uint8_t BluetoothStatusIcon[] = {0xFF, 0xFF, 0xFE, 0x7F, 0xFE, 0x3F, 0xFE, 0x1F, 0xF6, 0x4F, 0xFA,
0x5F, 0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xFA, 0x5F,
0xF6, 0x4F, 0xFE, 0x1F, 0xFE, 0x3F, 0xFE, 0x7F, 0xFF, 0xFF};
+4 -27
View File
@@ -13,7 +13,6 @@
#include "I18n.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/bluetooth.h"
#include "components/icons/bookmark.h"
#include "fontIds.h"
@@ -24,10 +23,8 @@ constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
constexpr int bookmarkStatusIconWidth = 16;
constexpr int bookmarkStatusIconHeight = 14;
constexpr int bookmarkStatusIconGap = 4;
constexpr int bookmarkStatusIconTopCrop = 2;
constexpr int bluetoothStatusIconWidth = 16;
constexpr int bluetoothStatusIconHeight = 16;
constexpr int statusIconGap = 4;
bool statusBarTextLaneVisible() {
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
@@ -46,17 +43,6 @@ void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int
}
}
void drawBluetoothStatusIcon(const GfxRenderer& renderer, const int x, const int y) {
constexpr int bytesPerRow = bluetoothStatusIconWidth / 8;
for (int row = 0; row < bluetoothStatusIconHeight; ++row) {
for (int col = 0; col < bluetoothStatusIconWidth; ++col) {
const uint8_t byte = BluetoothStatusIcon[row * bytesPerRow + col / 8];
const uint8_t mask = 1U << (7 - (col % 8));
renderer.drawPixel(x + col, y + row, (byte & mask) == 0);
}
}
}
} // namespace
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
@@ -763,8 +749,7 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated,
const bool bluetoothConnected) const {
const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated) const {
auto metrics = UITheme::getInstance().getMetrics();
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
@@ -860,17 +845,9 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
}
}
// Draw status icons
if (showStatusBarTextLane && bluetoothConnected) {
const int bluetoothGap = leftClusterWidth > 0 ? statusIconGap : 0;
const int bluetoothX = leftClusterX + leftClusterWidth + bluetoothGap;
const int bluetoothY = textY + 3;
drawBluetoothStatusIcon(renderer, bluetoothX, bluetoothY);
leftClusterWidth += bluetoothStatusIconWidth + bluetoothGap;
}
// Draw Bookmark
if (showStatusBarTextLane && isPageBookmarked) {
const int bookmarkGap = leftClusterWidth > 0 ? statusIconGap : 0;
const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0;
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
+1 -1
View File
@@ -238,7 +238,7 @@ class BaseTheme {
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
std::string title, const int paddingBottom = 0, const int textYOffset = 0,
const bool fillMargin = true, const bool isPageBookmarked = false,
const bool pageCountEstimated = false, const bool bluetoothConnected = false) const;
const bool pageCountEstimated = false) const;
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
int contentStartX = 0, int contentWidth = 0) const;
+2 -109
View File
@@ -18,7 +18,6 @@
#include <cstring>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "KOReaderCredentialStore.h"
@@ -128,12 +127,6 @@ enum class BootResume : uint8_t {
QuickResume, // wake from a quick-resume deep sleep (SD flag; survives power loss)
};
// Latched in setup() from the read-and-clear of the RTC flag, so the reboot-loop
// guard in bootWasSilentRestart() has the answer for the whole session.
static bool bootWasSilentRestartFlag = false;
bool bootWasSilentRestart() { return bootWasSilentRestartFlag; }
// Latched true once enterDeepSleep() commits to sleeping, before it tears down
// the current activity. WiFi activities call silentRestart() in onExit() to
// clear heap fragmentation on the way out, but deep sleep is a full chip reset
@@ -269,10 +262,6 @@ void enterDeepSleep(bool fromTimeout = false) {
WiFi.mode(WIFI_OFF);
}
// Drop any BLE HID link cleanly so the page-turner sees the disconnect promptly.
// bluetoothEnabled persists, so the setting is restored on the next wake.
bleinput::stop();
halTiltSensor.deepSleep();
display.deepSleep();
LOG_DBG("MAIN", "Entering deep sleep");
@@ -336,7 +325,6 @@ void setup() {
(isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_READER) ? silentRebootTarget : 0;
silentRebootMagic = 0;
silentRebootTarget = 0;
bootWasSilentRestartFlag = isSilentReboot;
gpio.begin();
powerManager.begin();
@@ -490,78 +478,6 @@ void setup() {
// Ensure we're not still holding the power button before leaving setup
waitForPowerRelease();
allowSleepAt = millis() + 2000;
// Bluetooth is started lazily by the lifecycle check in loop() once a reader or the
// Bluetooth settings screen is on the stack — not here at boot — so home/browser and
// WiFi activities keep the ~50 KB the BLE stack would otherwise hold.
}
// Bring the BLE stack up or down to match the current context. BLE is only resident
// while a reader (page-turner input) or the Bluetooth settings screen (pairing) is on
// the stack AND WiFi is off. Heap-heavy reader phases may stop BLE directly; this
// lifecycle then restarts it only after the normal activity/render/heap gates pass.
void updateBluetoothLifecycle() {
const bool wanted =
SETTINGS.bluetoothEnabled && activityManager.bluetoothShouldBeActive() && WiFi.getMode() == WIFI_MODE_NULL;
if (wanted && !BleHid.isRunning() && activityManager.bluetoothStartDeferred()) {
static uint32_t lastActivityDeferLogMs = 0;
if (millis() - lastActivityDeferLogMs > 10000) {
lastActivityDeferLogMs = millis();
LOG_INF("BLELC", "start deferred: activity busy heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
}
return;
}
// Heap gate: NimBLE needs ~57 KB. If the reader cannot spare that yet, retry
// on the next loop without entering any separate BLE hold state.
// The BT settings screen is explicit user intent to run BLE right now (scan/pair is
// dead without the stack). Its floor only needs to cover NimBLE itself — the 100 KB
// reader floor reserves build/render headroom that never gets used there.
const bool explicitBtContext = activityManager.currentKeepsBluetoothAlive();
const size_t startFloor = explicitBtContext ? bleinput::kStartMinFreeHeapExplicit : bleinput::kStartMinFreeHeap;
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && !renderer.hasFrameBuffer()) {
static uint32_t lastFramebufferLoanDeferLogMs = 0;
if (millis() - lastFramebufferLoanDeferLogMs > 10000) {
lastFramebufferLoanDeferLogMs = millis();
LOG_INF("BLELC", "start deferred: framebuffer lent heap=%u maxAlloc=%u", ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
}
return;
}
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && RenderLock::peek()) {
static uint32_t lastReaderRenderDeferLogMs = 0;
if (millis() - lastReaderRenderDeferLogMs > 10000) {
lastReaderRenderDeferLogMs = millis();
LOG_INF("BLELC", "start deferred: reader render in progress heap=%u maxAlloc=%u", ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
}
return;
}
if (wanted && !BleHid.isRunning() && ESP.getFreeHeap() < startFloor) {
static uint32_t lastGateLogMs = 0;
if (millis() - lastGateLogMs > 10000) {
lastGateLogMs = millis();
LOG_INF("BLELC", "start deferred: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor);
}
return;
}
if (wanted && !BleHid.isRunning()) {
LOG_INF("BLELC", "start requested enabled=%u reader=%d settings=%d wifi=%d paired=%u heap=%u maxAlloc=%u",
SETTINGS.bluetoothEnabled, activityManager.isReaderActivity(), activityManager.currentKeepsBluetoothAlive(),
WiFi.getMode(), BleHid.pairedCount(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
// Start immediately once the lifecycle gates pass. Do not draw a reconnect
// popup here: that schedules a reader redraw while BLE has just consumed ~53 KB,
// which can immediately trip the render heap shed path and create a start/stop loop.
if (!bleinput::ensureStarted()) {
LOG_ERR("BLELC", "start failed heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
return;
}
LOG_INF("BLELC", "started paired=%u heap=%u maxAlloc=%u", BleHid.pairedCount(), ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
} else if (!wanted && BleHid.isRunning()) {
LOG_INF("BLELC", "stop requested enabled=%u active=%d wifi=%d heap=%u maxAlloc=%u", SETTINGS.bluetoothEnabled,
activityManager.bluetoothShouldBeActive(), WiFi.getMode(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
bleinput::stop();
LOG_INF("BLELC", "stopped heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
}
}
void loop() {
@@ -570,24 +486,6 @@ void loop() {
static unsigned long lastMemPrint = 0;
gpio.update();
updateBluetoothLifecycle(); // bring BLE up/down for the current activity context
static bool lastBleConnected = false;
BleHid.poll(); // drive BLE auto-reconnect + key auto-repeat (no-op when BT off)
const bool bleConnected = BleHid.isConnected();
if (bleConnected && !lastBleConnected) {
LOG_INF("BLELC", "connected name=%s heap=%u maxAlloc=%u", BleHid.connectedName(), ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
if (activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive()) {
activityManager.requestUpdate();
}
} else if (!bleConnected && lastBleConnected) {
LOG_INF("BLELC", "disconnected heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
if (activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive()) {
activityManager.requestUpdate();
}
}
lastBleConnected = bleConnected;
mappedInputManager.pollBle(); // drain BLE keys -> logical-button overlay for this frame
halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity());
renderer.setFadingFix(SETTINGS.fadingFix);
@@ -618,7 +516,7 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
mappedInputManager.bleHadActivityThisFrame() || activityManager.preventAutoSleep()) {
activityManager.preventAutoSleep()) {
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
}
@@ -699,16 +597,11 @@ void loop() {
powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
// The BLE controller cannot run at the 10 MHz low-power frequency — NimBLE's
// controller reset/maintenance hangs the radio and trips the interrupt WDT (the
// same reason WiFi force-disables power saving in HalPowerManager). Keep full CPU
// speed whenever the BLE stack is actually resident, regardless of input idleness.
if (!BleHid.isRunning() && millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
delay(50);
} else {
if (BleHid.isRunning()) powerManager.setPowerSaving(false); // keep the BLE radio stable
// Short delay to prevent tight loop while still being responsive
delay(10);
}
+2 -76
View File
@@ -169,27 +169,6 @@
.modal.picker-mode {
max-width: 920px;
}
.modal.image-preview-mode {
max-width: 640px;
}
.image-preview-stage {
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
margin-bottom: 15px;
border-radius: 6px;
}
.image-preview-stage img {
max-width: 100%;
max-height: 65vh;
object-fit: contain;
}
#imagePreviewDownload {
display: inline-block;
width: auto;
text-decoration: none;
}
.picker-columns.picker-active {
display: flex;
flex-direction: row;
@@ -1800,21 +1779,6 @@
</div>
</div>
<!-- Image Preview Modal -->
<div class="modal-overlay" id="imagePreviewModal">
<div class="modal image-preview-mode">
<button class="modal-close" onclick="closeImagePreview()">&times;</button>
<h3>🖼️ Preview</h3>
<div class="folder-form">
<p class="file-info"><strong id="imagePreviewName"></strong></p>
<div class="image-preview-stage">
<img id="imagePreviewImg" alt="">
</div>
<a id="imagePreviewDownload" class="move-btn-confirm" rel="noopener noreferrer" target="_blank" href="#">Download</a>
</div>
</div>
</div>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
@@ -1922,7 +1886,6 @@
if (overlay.id === 'deleteModal') return closeDeleteModal();
if (overlay.id === 'renameModal') return closeRenameModal();
if (overlay.id === 'moveModal') return closeMoveModal();
if (overlay.id === 'imagePreviewModal') return closeImagePreview();
overlay.classList.remove('open');
}
});
@@ -2035,13 +1998,8 @@
// Checkbox cell + file row
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
const fileIsImage = isImageFile(file.name);
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : (fileIsImage ? '🖼️' : '📄')}</span>`;
if (fileIsImage) {
fileTableContent += `<a href="${downloadUrl(filePath)}" class="file-link image-preview-link">${escapeHtml(file.name)}</a>`;
} else {
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="${downloadUrl(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`;
}
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`;
fileTableContent += '</td>';
fileTableContent += file.isEpub ? '<td><span class="epub-badge">EPUB</span></td>' : `<td>${escapeHtml(file.name.split('.').pop().toUpperCase())}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
@@ -2059,30 +2017,6 @@
}
}
// Image preview
function isImageFile(name) {
return /\.(png|jpe?g|bmp|gif|webp)$/i.test(name);
}
function downloadUrl(filePath) {
return `/download?path=${encodeURIComponent(filePath)}`;
}
function openImagePreview(url, name) {
const img = document.getElementById('imagePreviewImg');
document.getElementById('imagePreviewName').textContent = name;
img.src = url;
img.alt = name;
document.getElementById('imagePreviewDownload').href = url;
document.getElementById('imagePreviewModal').classList.add('open');
}
function closeImagePreview() {
document.getElementById('imagePreviewModal').classList.remove('open');
// Clear src to free the decoded image and avoid showing the stale one on reopen.
document.getElementById('imagePreviewImg').src = '';
}
// Modal functions
function openUploadModal() {
// Reset converter variables to defaults
@@ -2958,14 +2892,6 @@
// Initialize quality settings handlers
document.addEventListener('DOMContentLoaded', function() {
// Delegated so it survives file-table re-renders (avoids inline onclick with untrusted names).
document.getElementById('file-table').addEventListener('click', function(e) {
const link = e.target.closest('.image-preview-link');
if (!link) return;
e.preventDefault();
openImagePreview(link.getAttribute('href'), link.textContent);
});
const qualitySlider = document.getElementById('qualitySlider');
const qualityInput = document.getElementById('qualityInput');
Binary file not shown.