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
Tom-Inge Larsen 3e1dd31e53 feat(reader): End of Book next-book suggestions (#2499) (#2532) 2026-07-04 23:12:59 +03:00
685d4e88f9 feat: Lazy incremental EPUB section indexing (#2452)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-07-04 21:21:24 +03:00
Jakub Sypiański 79b4d63ee2 fix: settings persist on font clear, reserve() before push_back, minor (#2519) 2026-07-03 18:58:24 +03:00
Leopoldo Pla SempereandJiangoJ 57447a56a6 feat(epub): improve text-decoration support (#2397)
Co-authored-by: JiangoJ <jiangj2620@gmail.com>
2026-07-02 12:35:24 +03:00
Rahat Ahmed 5c86bfe1fd feat(network): add device serial number to web UI (#2506) 2026-07-02 00:53:55 +03:00
Justin Mitchell 4cdefb137c Update README with firmware flashing warnings and crosspet status
Removed warning about flashing Papyrix on USB-locked devices and updated crosspet status to unmaintained.
2026-07-01 14:22:27 -04:00
Justin Mitchell e7f3406704 fix: Add socket module import to build-sd-fonts script (#2504) (#2505) 2026-07-01 12:10:15 -04:00
Bastian 6f08a88b12 fix: changing translation in czech to fix overflowing navigation (#2502) 2026-07-01 11:58:24 -04:00
Tom-Inge LarsenandClaude Opus 4.8 e4d3981469 fix(reader): correct slider side-button direction and legend on X3 (#2402) (#2428)
## Summary

* **What is the goal of this PR?** Fixes #2402. On the X3, the "Time to
Sleep"
picker's 5-minute side buttons were inverted (left increased, right
decreased)
  and the on-screen legend didn't match the physical buttons.
* **What changes are included?**
  * Flip the large-step (±5 min) direction on X3 so the left side button
decreases and the right increases, matching the layout. X4 is unchanged.
* Add a device-specific step-hint string
(`STR_SLEEP_TIMER_STEP_HINT_X3`)
selected via `gpio.deviceIsX3()`. X3 shows `+/-: 1 min Side buttons: 5
min`;
    X4 keeps the original `Left/Right: 1 min  Up/Down: 5 min`.
* Same fix applied to both slider pickers that read the raw up/down side
    buttons: the Time-to-Sleep picker and the "Go to %" picker
(`EpubReaderPercentSelectionActivity`), each with its own X3 hint string
    (`STR_SLEEP_TIMER_STEP_HINT_X3`, `STR_PERCENT_STEP_HINT_X3`).

## Additional Context

* **Root cause:** the X3's side buttons sit one on each edge of the
screen
(power on top), whereas the X4 has a vertical up/down rocker on the
right
edge. So `BTN_UP` is physically the *left* button on X3 but the *top*
button
  on X4. The picker mapped up→+5 / down→−5 unconditionally, which reads
naturally on the X4 rocker but inverted on the X3's left/right buttons.
The
  static legend ("Up/Down: 5 min") had the same X3-only mismatch.
* The codebase already detects the device at runtime and handles this
layout
difference elsewhere (e.g. `LyraTheme::drawSideButtonHints` draws "Up on
left,
Down on right" for X3), so this reuses the same `gpio.deviceIsX3()`
signal.
* Swept the codebase for other side-button sliders:
`ClockOffsetActivity` already
uses the `Next/Previous` (`ButtonNavigator`) abstraction, which resolves
to
left=decrement / right=increment on X3, so it needs no change. List/page
navigation (Wi-Fi, KOReader sync, BMP viewer) and the keyboard cursor
are also
unaffected. The two slider pickers above were the only ones using raw
up/down.
* **X4 is untouched** — same direction, same legend wording as before.
* **Tested on X3 hardware:** left side button now decreases, right
increases, and
  the legend matches. X4 not yet tested on device (no unit on hand); its
  behaviour and strings are unchanged from master.
* **Translations:** the new `STR_SLEEP_TIMER_STEP_HINT_X3` and
`STR_PERCENT_STEP_HINT_X3` were added to all 26 languages, but the
non-English
  ones are AI-generated and would benefit from a native-speaker pass —
particularly Hebrew (RTL ordering with a leading `+/-`), Kazakh, and
Vietnamese.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:06:31 -04:00
Bastian c9d27826f9 docs: update TOC in userguide (#2479) 2026-06-30 08:48:21 +03:00
Bastian 13bd00bf9d fix: sort sleep screen menu options to be more logically consistent (#2480) 2026-06-30 08:47:25 +03:00
Uri Tauberandpablohc fef39da23c feat: Selection Popup (#2358)
Co-authored-by: pablohc <pablonoviello@outlook.com>
2026-06-29 16:59:25 -04:00
Uri Tauber fd43ca2fe1 chore: Initial multi-core compatibility (#2294) 2026-06-29 21:57:21 +03:00
Pietro Campagnano 28255061fb fix(icons): align home menu icons with their labels (#2470) 2026-06-29 13:59:54 -04:00
Julia Nguyen 43ff1b6644 Merge remote-tracking branch 'origin/master' into develop 2026-06-29 12:29:58 -04:00
Егор Мартынов 2754a5ff01 feat: add Vollkorn font (#2473)
CI (build) / clang-format (push) Canceled after 0s
CI (build) / cppcheck (push) Canceled after 0s
CI (build) / build (push) Canceled after 0s
CI (build) / unit-tests (push) Canceled after 0s
CI (build) / Test Status (push) Canceled after 0s
2026-06-29 15:54:01 +03:00
Justin Mitchell 6edeed73c5 fix: x4 sleep/boot ghosting (#2471) 2026-06-29 12:04:33 +03:00
Justin Mitchell 8ac302018c fix: x4 ghosting by bumping sdk (#2469) 2026-06-28 22:38:35 +03:00
Rodrigo Matsuura a11517a62d fix: update Portuguese (Brasil) translations (#2458) 2026-06-28 15:07:52 +03:00
176 changed files with 3239 additions and 10364 deletions
+1 -5
View File
@@ -67,10 +67,6 @@ USB port or browser before assuming the device is locked. Only reach for the unl
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
> the firmware you flashed doesn't support OTA, **there is no way out**.
>
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
> Papyrix (or any other unsupported firmware) on a locked device.**
## Install firmware
@@ -249,7 +245,7 @@ One of the best things about open source is that anyone can take the code in a d
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
+21 -12
View File
@@ -5,6 +5,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [CrossPoint User Guide](#crosspoint-user-guide)
- [1. Hardware Overview](#1-hardware-overview)
- [Button Layout](#button-layout)
- [Taking a Screenshot](#taking-a-screenshot)
- [2. Power \& Startup](#2-power--startup)
- [Power On / Off](#power-on--off)
- [First Launch](#first-launch)
@@ -14,7 +15,11 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.3 Browse Files Screen](#33-browse-files-screen)
- [3.4 Recent Books Screen](#34-recent-books-screen)
- [3.5 File Transfer Screen](#35-file-transfer-screen)
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
- [Uploading Books](#uploading-books)
- [Removing a Book](#removing-a-book)
- [3.6 Settings](#36-settings)
- [3.6.1 Display](#361-display)
- [3.6.2 Reader](#362-reader)
@@ -23,21 +28,25 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
- [3.7 Sleep Screen](#37-sleep-screen)
- [Cover settings](#cover-settings)
- [Custom images](#custom-images)
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
- [Chapter Navigation](#chapter-navigation)
- [Auto Page Turn](#auto-page-turn)
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
- [Footnote Navigation](#footnote-navigation)
- [System Navigation](#system-navigation)
- [Supported Languages](#supported-languages)
- [Page Turning](#page-turning)
- [Chapter Navigation](#chapter-navigation)
- [Auto Page Turn](#auto-page-turn)
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
- [Footnote Navigation](#footnote-navigation)
- [System Navigation](#system-navigation)
- [Supported Languages](#supported-languages)
- [5. Reader Menu](#5-reader-menu)
- [5.1 Chapter Selection](#51-chapter-selection)
- [5.2 Bookmarks](#52-bookmarks)
- [6. Current Limitations & Roadmap](#6-current-limitations--roadmap)
- [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
- [5.1 Chapter Selection](#51-chapter-selection)
- [5.2 Bookmarks](#52-bookmarks)
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
## 1. Hardware Overview
+5 -3
View File
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 25
### 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 25 includes:
Version 28 includes:
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
image rendering mode, and Focus Reading
@@ -105,6 +105,8 @@ Version 25 includes:
- paragraph and list-item LUTs used by KOReader sync page refinement
- optional per-word Focus Reading split metadata
- per-page footnote entries
- serialized word style bits for underline, strikethrough, superscript, and
subscript
ImHex pattern:
@@ -113,7 +115,7 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 25
#define EXPECTED_VERSION 28
#define MAX_STRING_LENGTH 65535
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 96
+1 -1
View File
@@ -1,7 +1,7 @@
#include "EpdFontFamily.h"
const EpdFont* EpdFontFamily::getFont(const Style style) const {
// Extract font style bits (ignore UNDERLINE bit for font selection)
// Extract font style bits; render-time overlay bits do not affect font selection.
const bool hasBold = (style & BOLD) != 0;
const bool hasItalic = (style & ITALIC) != 0;
+4
View File
@@ -17,6 +17,7 @@ class EpdFontFamily {
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
};
static constexpr uint8_t TEXT_DECORATION_MASK = static_cast<uint8_t>(UNDERLINE | STRIKETHROUGH);
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
@@ -27,6 +28,9 @@ class EpdFontFamily {
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
static constexpr bool hasTextDecoration(const Style style) {
return (static_cast<uint8_t>(style) & TEXT_DECORATION_MASK) != 0;
}
private:
const EpdFont* regular;
-2
View File
@@ -37,5 +37,3 @@
#include <builtinFonts/ubuntu_10_regular.h>
#include <builtinFonts/ubuntu_12_bold.h>
#include <builtinFonts/ubuntu_12_regular.h>
#include <builtinFonts/ubuntu_14_bold.h>
#include <builtinFonts/ubuntu_14_regular.h>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-7
View File
@@ -94,13 +94,6 @@ ruby -rdigest -e 'puts [
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define UI_14_FONT_ID ($(
ruby -rdigest -e 'puts [
"./ubuntu_14_regular.h",
"./ubuntu_14_bold.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define SMALL_FONT_ID ($(
ruby -rdigest -e 'puts [
"./notosans_8_regular.h",
+35 -7
View File
@@ -33,6 +33,7 @@ import sys
import tempfile
import threading
import time
import socket
import urllib.request
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
@@ -49,17 +50,44 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
def download_font(url: str, dest: Path) -> Path:
"""Download a font file if not already cached. Returns the local path."""
_orig_getaddrinfo = socket.getaddrinfo
def _ipv4_only_getaddrinfo(*args, **kwargs):
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
"""Download a font file if not already cached. Returns the local path.
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
on a different mirror each request; a mirror may advertise an IPv6 address a
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
Retry on failure, forcing IPv4 resolution after the first attempt.
"""
if dest.exists():
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
print(f" Downloading {dest.name}...")
try:
urllib.request.urlretrieve(url, dest)
except Exception as e:
dest.unlink(missing_ok=True)
raise RuntimeError(f"Failed to download {url}: {e}") from e
last_err = None
for attempt in range(1, retries + 1):
force_ipv4 = attempt > 1
if force_ipv4:
socket.getaddrinfo = _ipv4_only_getaddrinfo
try:
urllib.request.urlretrieve(url, dest)
break
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
last_err = e
dest.unlink(missing_ok=True)
if attempt < retries:
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
finally:
if force_ipv4:
socket.getaddrinfo = _orig_getaddrinfo
else:
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
size_kb = dest.stat().st_size / 1024
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
return dest
+12 -31
View File
@@ -28,42 +28,23 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
done
done
# Small UI chrome (button devices, uiScale 1.0). Rendered 1-bit: crisp at these
# sizes and half the flash of 2-bit.
UI_FONT_SIZES=(10 12)
# Larger UI chrome substituted in on touch/high-density boards via the uiScale
# remap (see src/main.cpp setupFonts). Rendered 2-bit so it stays smooth when
# enlarged. Touch boards use uiScale 1.2, so UI_12 -> 14.4 -> 14 is the size the
# remap actually lands on; add larger sizes here if a board adopts a higher scale.
UI_FONT_SIZES_LARGE=(14)
UI_FONT_STYLES=("Regular" "Bold")
# Ubuntu lacks the Latin Extended Additional block (U+1EA0-U+1EF9) used for
# Vietnamese tone marks. Append a Vietnamese-only Ubuntu cut so those glyphs are
# filled from it while every glyph Ubuntu already has stays unchanged (fontstack
# is ordered by descending priority). NotoSansHebrew fills U+05D0-U+05EA so the
# Hebrew UI translation renders in menus and settings.
generate_ui_font() {
local size="$1" style="$2" extra_flags="$3"
local font_name="ubuntu_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
local font_path="../builtinFonts/source/Ubuntu/Ubuntu-${style}.ttf"
local hebrew_path="../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-${style}.ttf"
local viet_path="../builtinFonts/source/Ubuntu/Ubuntu-Vietnamese-${style}.ttf"
local output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path $hebrew_path $viet_path \
--additional-intervals 0x05D0,0x05EA $extra_flags > $output_path
echo "Generated $output_path"
}
for size in ${UI_FONT_SIZES[@]}; do
for style in ${UI_FONT_STYLES[@]}; do
generate_ui_font $size $style ""
done
done
for size in ${UI_FONT_SIZES_LARGE[@]}; do
for style in ${UI_FONT_STYLES[@]}; do
generate_ui_font $size $style "--2bit --compress"
font_name="ubuntu_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Ubuntu/Ubuntu-${style}.ttf"
hebrew_path="../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-${style}.ttf"
# Ubuntu lacks the Latin Extended Additional block (U+1EA0-U+1EF9) used for
# Vietnamese tone marks. Append a Vietnamese-only Ubuntu cut so those glyphs
# are filled from it while every glyph Ubuntu already has stays unchanged
# (fontstack is ordered by descending priority).
viet_path="../builtinFonts/source/Ubuntu/Ubuntu-Vietnamese-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path $hebrew_path $viet_path \
--additional-intervals 0x05D0,0x05EA > $output_path
echo "Generated $output_path"
done
done
+10 -1
View File
@@ -132,7 +132,16 @@ families:
bold: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
- name: Vollkorn
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 700}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Sans-serif ─────────────────────────────────────────────────────────
+13 -71
View File
@@ -14,47 +14,8 @@ constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-com
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
constexpr uint32_t MAX_CACHE_STRING_LEN = 4096;
bool readStringBounded(HalFile& file, std::string& out, const uint32_t maxLen = MAX_CACHE_STRING_LEN) {
uint32_t len = 0;
if (file.read(&len, sizeof(len)) != static_cast<int>(sizeof(len))) {
return false;
}
if (len > maxLen || len > static_cast<uint32_t>(file.available())) {
LOG_ERR("BMC", "Invalid cache string length: %lu (max=%lu available=%d)", static_cast<unsigned long>(len),
static_cast<unsigned long>(maxLen), file.available());
return false;
}
out.clear();
if (len == 0) {
return true;
}
out.resize(len);
return file.read(out.data(), len) == static_cast<int>(len);
}
class CacheIoLock {
public:
explicit CacheIoLock(SemaphoreHandle_t mutex) : mutex(mutex) {
if (mutex) xSemaphoreTakeRecursive(mutex, portMAX_DELAY);
}
~CacheIoLock() {
if (mutex) xSemaphoreGiveRecursive(mutex);
}
private:
SemaphoreHandle_t mutex;
};
} // namespace
BookMetadataCache::~BookMetadataCache() {
if (ioMutex) {
vSemaphoreDelete(ioMutex);
ioMutex = nullptr;
}
}
/* ============= WRITING / BUILDING FUNCTIONS ================ */
bool BookMetadataCache::beginWrite() {
@@ -414,7 +375,6 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
/* ============= READING / LOADING FUNCTIONS ================ */
bool BookMetadataCache::load() {
CacheIoLock ioLock(ioMutex);
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
return false;
}
@@ -432,13 +392,11 @@ bool BookMetadataCache::load() {
serialization::readPod(bookFile, spineCount);
serialization::readPod(bookFile, tocCount);
if (!readStringBounded(bookFile, coreMetadata.title) || !readStringBounded(bookFile, coreMetadata.author) ||
!readStringBounded(bookFile, coreMetadata.language) || !readStringBounded(bookFile, coreMetadata.coverItemHref) ||
!readStringBounded(bookFile, coreMetadata.textReferenceHref)) {
LOG_ERR("BMC", "Invalid cache metadata strings");
bookFile.close();
return false;
}
serialization::readString(bookFile, coreMetadata.title);
serialization::readString(bookFile, coreMetadata.author);
serialization::readString(bookFile, coreMetadata.language);
serialization::readString(bookFile, coreMetadata.coverItemHref);
serialization::readString(bookFile, coreMetadata.textReferenceHref);
loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
@@ -446,7 +404,6 @@ bool BookMetadataCache::load() {
}
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
CacheIoLock ioLock(ioMutex);
if (!loaded) {
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
return {};
@@ -461,16 +418,11 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
bookFile.seek(lutOffset + sizeof(uint32_t) * index);
uint32_t spineEntryPos;
serialization::readPod(bookFile, spineEntryPos);
if (spineEntryPos >= bookFile.size()) {
LOG_ERR("BMC", "Spine entry offset out of range: %lu", static_cast<unsigned long>(spineEntryPos));
return {};
}
bookFile.seek(spineEntryPos);
return readSpineEntry(bookFile);
}
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
CacheIoLock ioLock(ioMutex);
if (!loaded) {
LOG_ERR("BMC", "getTocEntry called but cache not loaded");
return {};
@@ -485,34 +437,24 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index);
uint32_t tocEntryPos;
serialization::readPod(bookFile, tocEntryPos);
if (tocEntryPos >= bookFile.size()) {
LOG_ERR("BMC", "TOC entry offset out of range: %lu", static_cast<unsigned long>(tocEntryPos));
return {};
}
bookFile.seek(tocEntryPos);
return readTocEntry(bookFile);
}
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const {
SpineEntry entry;
if (!readStringBounded(file, entry.href) ||
file.read(&entry.cumulativeSize, sizeof(entry.cumulativeSize)) !=
static_cast<int>(sizeof(entry.cumulativeSize)) ||
file.read(&entry.tocIndex, sizeof(entry.tocIndex)) != static_cast<int>(sizeof(entry.tocIndex))) {
LOG_ERR("BMC", "Invalid spine cache entry");
return {};
}
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
return entry;
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
TocEntry entry;
if (!readStringBounded(file, entry.title) || !readStringBounded(file, entry.href) ||
!readStringBounded(file, entry.anchor) ||
file.read(&entry.level, sizeof(entry.level)) != static_cast<int>(sizeof(entry.level)) ||
file.read(&entry.spineIndex, sizeof(entry.spineIndex)) != static_cast<int>(sizeof(entry.spineIndex))) {
LOG_ERR("BMC", "Invalid TOC cache entry");
return {};
}
serialization::readString(file, entry.title);
serialization::readString(file, entry.href);
serialization::readString(file, entry.anchor);
serialization::readPod(file, entry.level);
serialization::readPod(file, entry.spineIndex);
return entry;
}
+2 -10
View File
@@ -1,7 +1,6 @@
#pragma once
#include <HalStorage.h>
#include <freertos/semphr.h>
#include <algorithm>
#include <deque>
@@ -55,7 +54,6 @@ class BookMetadataCache {
// Temp file handles during build
HalFile spineFile;
HalFile tocFile;
SemaphoreHandle_t ioMutex;
// Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry {
@@ -87,14 +85,8 @@ class BookMetadataCache {
BookMetadata coreMetadata;
explicit BookMetadataCache(std::string cachePath)
: cachePath(std::move(cachePath)),
lutOffset(0),
spineCount(0),
tocCount(0),
loaded(false),
buildMode(false),
ioMutex(xSemaphoreCreateRecursiveMutex()) {}
~BookMetadataCache();
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
~BookMetadataCache() = default;
// Building phase (stream to disk immediately)
bool beginWrite();
+513 -109
View File
@@ -2,6 +2,7 @@
#include <HalStorage.h>
#include <Logging.h>
#include <Memory.h>
#include <Serialization.h>
#include "Epub/css/CssParser.h"
@@ -10,34 +11,59 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
constexpr uint8_t SECTION_FILE_VERSION = 27;
// 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
// as unknown and clears -- so an incomplete file is never mistaken for a valid one.
constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
// Written when a build is suspended partway (reader exited or device slept mid-build).
// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse
// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile
// accepts it so a resume shows those pages instantly; the reader extends it by
// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION,
// so finalized files are untouched by this feature; older firmware treats the sentinel
// as an unknown version and rebuilds, which is a safe downgrade.
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) +
sizeof(uint32_t) + sizeof(uint32_t);
struct PageLutEntry {
uint32_t fileOffset;
uint16_t paragraphIndex;
uint16_t listItemIndex;
};
} // namespace
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
// constructed/destroyed where the parser's full definition is visible.
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
: epub(epub),
spineIndex(spineIndex),
renderer(renderer),
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
// Suspend any in-progress build so every section.reset() / navigation / sleep path
// persists the pages already laid out as a partial .bin instead of discarding them
// (no-op once a build has completed or never started).
Section::~Section() { suspendBuild(); }
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
if (!file) {
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_);
return 0;
}
const uint32_t position = file.position();
if (!page->serialize(file)) {
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
return 0;
}
LOG_DBG("SCT", "Page %d processed", pageCount);
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
pageCount++;
builtPageCount_++;
// pageCount is the pages available to read: a rebuild over a partial only raises it
// once it has laid out more pages than the partial already covers.
if (builtPageCount_ > pageCount) {
pageCount = builtPageCount_;
}
return position;
}
@@ -56,7 +82,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
// Written as the incomplete sentinel; finalizeBuild() patches it to
// SECTION_FILE_VERSION as the last step, committing the file.
serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION);
serialization::writePod(file, fontId);
serialization::writePod(file, lineCompression);
serialization::writePod(file, extraParagraphSpacing);
@@ -83,16 +111,18 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
}
// Match parameters
bool filePartial = false;
{
uint8_t version;
serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) {
if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) {
// Explicit close() required: member variable persists beyond function scope
file.close();
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
clearCache();
return false;
}
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
int fileFontId;
uint16_t fileViewportWidth, fileViewportHeight;
@@ -127,14 +157,42 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
}
serialization::readPod(file, pageCount);
if (filePartial) {
// A partial's pageCount is the watermark of a suspended build. Read the watermark
// trailer (appended after the li LUT) so estimatedTotalPages can extrapolate.
uint32_t liLutOffset = 0;
file.seek(HEADER_SIZE - sizeof(uint32_t));
serialization::readPod(file, liLutOffset);
const uint32_t trailerOffset = liLutOffset + static_cast<uint32_t>(pageCount) * sizeof(uint16_t);
const bool trailerValid =
pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size();
if (!trailerValid) {
file.close();
LOG_ERR("SCT", "Deserialization failed: malformed partial section");
clearCache();
pageCount = 0;
return false;
}
file.seek(trailerOffset);
serialization::readPod(file, partialBytesConsumed_);
serialization::readPod(file, partialTotalBytes_);
partial_ = true;
partialPageCount_ = pageCount;
}
// Explicit close() required: member variable persists beyond function scope
file.close();
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : "");
return true;
}
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const {
const std::string tmpBin = binTmpPath();
if (Storage.exists(tmpBin.c_str())) {
Storage.remove(tmpBin.c_str());
}
if (!Storage.exists(filePath.c_str())) {
LOG_DBG("SCT", "Cache does not exist, no action needed");
return true;
@@ -154,8 +212,43 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering, const bool focusReadingEnabled,
const std::function<void()>& popupFn) {
// One-shot build: start, then lay out the whole section in a single pass.
if (!startBuild(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled, popupFn)) {
return false;
}
if (!buildSomeMore(0)) { // 0 = build to completion
return false;
}
return buildComplete_;
}
bool Section::startBuild(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight,
const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering,
const bool focusReadingEnabled, const std::function<void()>& popupFn) {
if (build_) {
LOG_ERR("SCT", "startBuild called while a build is already active");
return false;
}
buildComplete_ = false;
builtPageCount_ = 0;
// Pages from a loaded partial stay readable (from filePath) while this build writes
// to the tmp .bin, so availability never drops below the partial's watermark.
pageCount = partial_ ? partialPageCount_ : 0;
// Remove a stale tmp .bin from a crash-interrupted build; this build recreates it.
{
const std::string staleTmp = binTmpPath();
if (Storage.exists(staleTmp.c_str())) {
Storage.remove(staleTmp.c_str());
}
}
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
const auto htmlDir = epub->getCachePath() + "/html";
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
// Create cache directory if it doesn't exist
{
@@ -163,62 +256,101 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Storage.mkdir(sectionsDir.c_str());
}
// Retry logic for SD card timing issues
bool success = false;
uint32_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
delay(50); // Brief delay before retry
// Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the
// book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation
// that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip
// inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so
// even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a
// reopen skip the multi-second inflate. If htmlPath exists it is known-complete.
const bool reusedHtml = Storage.exists(htmlPath.c_str());
bool htmlCached = reusedHtml;
if (reusedHtml) {
LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str());
} else {
Storage.mkdir(htmlDir.c_str());
// Retry logic for SD card timing issues
bool streamed = false;
uint32_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !streamed; attempt++) {
if (attempt > 0) {
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
delay(50); // Brief delay before retry
}
// Remove any incomplete file from previous attempt before retrying
if (Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
}
HalFile tmpHtml;
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
// Larger chunks mean far fewer SD writes inflating the HTML; a 1KB chunk turned a 584KB
// single-spine novel into ~570 tiny writes (multi-second). 8KB keeps the transient buffers
// small while cutting the write count 8x.
streamed = epub->readItemContentsToStream(localPath, tmpHtml, 8192);
fileSize = tmpHtml.size();
// Explicitly close() file before calling Storage.remove()
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!streamed && Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
}
}
// Remove any incomplete file from previous attempt before retrying
if (Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
if (!streamed) {
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
return false;
}
HalFile tmpHtml;
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
// Explicitly close() file before calling Storage.remove()
tmpHtml.close();
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
// If streaming failed, remove the incomplete file immediately
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
// Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are
// valid regardless of whether the layout build finishes, so reopening (even a window-only spine
// that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp.
if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) {
htmlCached = true;
} else {
LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp");
}
}
if (!success) {
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
return false;
}
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
if (!Storage.openFileForWrite("SCT", filePath, file)) {
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
return false;
}
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
std::vector<PageLutEntry> lut = {};
auto ctx = makeUniqueNoThrow<BuildContext>();
if (!ctx) {
LOG_ERR("SCT", "OOM: BuildContext");
file.close();
Storage.remove(binTmpPath().c_str());
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
return false;
}
// htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild
// then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up.
ctx->reusedHtml = htmlCached;
ctx->htmlPath = htmlPath;
ctx->tmpHtmlPath = tmpHtmlPath;
ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath;
// Derive the content base directory and image cache path prefix for the parser
size_t lastSlash = localPath.find_last_of('/');
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
const size_t lastSlash = localPath.find_last_of('/');
ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
CssParser* cssParser = nullptr;
if (embeddedStyle) {
cssParser = epub->getCssParser();
if (cssParser) {
if (!cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
}
ctx->cssParser = epub->getCssParser();
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
}
}
@@ -235,104 +367,367 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
}
ChapterHtmlSlimParser visitor(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, focusReadingEnabled,
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
// The parser stores the path/contentBase/imageBasePath by reference, so they must
// live in the BuildContext (which outlives the parser). The page-complete callback
// captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the
// context for the parser's whole lifetime.
BuildContext* ctxPtr = ctx.get();
ctx->parser = makeUniqueNoThrow<ChapterHtmlSlimParser>(
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
[this, ctxPtr](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
},
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
Storage.remove(tmpHtmlPath.c_str());
if (!success) {
LOG_ERR("SCT", "Failed to parse XML and build pages");
// Explicitly close() file before calling Storage.remove()
embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn,
ctxPtr->cssParser);
if (!ctx->parser) {
LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser");
if (ctx->cssParser) ctx->cssParser->clear();
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
Storage.remove(binTmpPath().c_str());
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
return false;
}
Hyphenator::setPreferredLanguage(epub->getLanguage());
build_ = std::move(ctx);
if (!build_->parser->beginParse()) {
LOG_ERR("SCT", "Failed to begin parse");
abandonBuild();
return false;
}
build_->totalBytes = build_->parser->parseTotalBytes();
return true;
}
bool Section::buildSomeMore(const int maxPages) {
if (!build_ || !build_->parser) {
LOG_ERR("SCT", "buildSomeMore with no active build");
return false;
}
// Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial,
// pageCount stays pinned at the partial's watermark until the build passes it, which
// would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark.
const int startCount = builtPageCount_;
for (;;) {
const auto status = build_->parser->parseStep();
if (status == ChapterHtmlSlimParser::ParseStatus::Error) {
LOG_ERR("SCT", "Parse error during incremental build");
abandonBuild();
return false;
}
if (status == ChapterHtmlSlimParser::ParseStatus::Done) {
return finalizeBuild();
}
// ParseStatus::More: yield once we've laid out the requested number of pages.
if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) {
build_->bytesConsumed = build_->parser->parseBytesConsumed();
return true;
}
}
}
bool Section::hasHtmlCache() const {
const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html";
return Storage.exists(htmlPath.c_str());
}
std::optional<uint16_t> Section::findAnchorDuringBuild(const std::string& anchor) const {
if (!build_ || !build_->parser) return std::nullopt;
for (const auto& [key, page] : build_->parser->getAnchors()) {
if (key == anchor) return page;
}
return std::nullopt;
}
std::optional<uint16_t> Section::findAnchor(const std::string& anchor) const {
if (const auto page = findAnchorDuringBuild(anchor)) {
return page;
}
// Fall back to the on-disk anchor map: a finalized section, or a partial whose map
// covers everything up to its watermark (nullopt past it -- build further and retry).
return getPageForAnchor(anchor);
}
uint16_t Section::estimatedTotalPages() const {
// Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA
// damping is needed. Also the best guess while a rebuild is running but hasn't laid out
// enough pages yet to extrapolate from its own progress.
const auto partialEstimate = [this]() -> uint16_t {
if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) {
return pageCount;
}
const uint64_t est = static_cast<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
if (est <= pageCount) return pageCount;
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
};
if (!build_) {
return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact
}
const uint32_t consumed = build_->bytesConsumed;
const uint32_t total = build_->totalBytes;
if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate();
// Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This
// re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses
// dense vs sparse regions of the chapter.
const uint64_t raw = static_cast<uint64_t>(builtPageCount_) * total / consumed;
// Damp that jitter with an exponential moving average. Step it once per build advance (keyed on
// bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how
// often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so
// the average settles onto the true count (and finalizeBuild then returns the exact pageCount).
constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle
if (build_->smoothedEstimate <= 0) {
build_->smoothedEstimate = static_cast<float>(raw); // seed on the first estimate
} else if (consumed != build_->smoothedAtConsumed) {
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
}
build_->smoothedAtConsumed = consumed;
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
if (est <= pageCount) return pageCount; // never fewer than the pages already available
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
}
// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built
// page count and table offsets, stamp `version` as the commit point, then swap the tmp
// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer
// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate
// the total page count. The parser must still be alive (anchors are read from it).
// On failure the tmp is removed and any pre-existing file at filePath is left intact.
bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) {
const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION);
const auto failCommit = [this]() {
// Explicit close() required before remove (member variable, O_RDWR handle).
file.close();
Storage.remove(binTmpPath().c_str());
return false;
};
const uint32_t lutOffset = file.position();
bool hasFailedLutRecords = false;
// Write LUT
for (const auto& entry : lut) {
for (const auto& entry : build_->lut) {
if (entry.fileOffset == 0) {
hasFailedLutRecords = true;
break;
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
return failCommit();
}
serialization::writePod(file, entry.fileOffset);
}
if (hasFailedLutRecords) {
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(filePath.c_str());
return false;
}
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
// Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a
// partial, skip anchors that landed on the incomplete trailing page the suspend drops.
const uint32_t anchorMapOffset = file.position();
const auto& anchors = visitor.getAnchors();
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
const auto& anchors = build_->parser->getAnchors();
uint16_t anchorCount = 0;
for (const auto& [anchor, page] : anchors) {
if (!asPartial || page < builtPageCount_) anchorCount++;
}
serialization::writePod(file, anchorCount);
for (const auto& [anchor, page] : anchors) {
if (asPartial && page >= builtPageCount_) continue;
serialization::writeString(file, anchor);
serialization::writePod(file, page);
}
const uint32_t paragraphLutOffset = file.position();
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
for (const auto& entry : lut) {
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
for (const auto& entry : build_->lut) {
serialization::writePod(file, entry.paragraphIndex);
}
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
for (const auto& entry : lut) {
for (const auto& entry : build_->lut) {
serialization::writePod(file, entry.listItemIndex);
}
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
serialization::writePod(file, pageCount);
if (asPartial) {
// Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t).
serialization::writePod(file, bytesConsumed);
serialization::writePod(file, totalBytes);
}
// Patch header with the built page count and section offsets...
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_));
serialization::writePod(file, builtPageCount_);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
serialization::writePod(file, paragraphLutOffset);
serialization::writePod(file, liLutFileOffset);
// ...then commit by overwriting the sentinel version with the real one. Writing the
// version last makes it the commit point: a crash before here leaves version 0.
file.seek(0);
serialization::writePod(file, version);
// Explicit close() required: member variable persists beyond function scope
file.close();
if (cssParser) {
cssParser->clear();
// Swap into place. A crash between remove and rename loses the old file but keeps a
// fully-committed tmp; the next build just removes it and rebuilds.
if (Storage.exists(filePath.c_str())) {
Storage.remove(filePath.c_str());
}
if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) {
LOG_ERR("SCT", "Failed to move built section into place");
Storage.remove(binTmpPath().c_str());
return false;
}
return true;
}
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
if (!Storage.openFileForRead("SCT", filePath, file)) {
bool Section::finalizeBuild() {
// Flush the trailing page (emits the last page via the completePageFn into the LUT).
build_->parser->finishParse();
if (!build_->reusedHtml) {
// Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future
// rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded.
if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) {
LOG_DBG("SCT", "Failed to promote HTML cache, removing temp");
Storage.remove(build_->tmpHtmlPath.c_str());
}
}
const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0);
if (build_->cssParser) build_->cssParser->clear();
build_.reset();
if (!committed) {
// commitBuildFile removed filePath before the failed swap, so nothing valid remains.
partial_ = false;
partialPageCount_ = 0;
pageCount = 0;
builtPageCount_ = 0;
return false;
}
buildComplete_ = true;
partial_ = false;
partialPageCount_ = 0;
pageCount = builtPageCount_;
return true;
}
void Section::suspendBuild() {
if (!build_) return;
// Only worth persisting if this build produced pages a pre-existing partial doesn't
// already cover; otherwise keep the older (bigger) partial and just drop the tmp.
const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_);
bool committed = false;
if (worthKeeping) {
// Capture the parse watermark and commit before tearing the parser down (the anchor
// map is read from it). The incomplete trailing page is intentionally not flushed:
// only fully laid-out pages are persisted, and the rebuild re-derives the rest.
const uint32_t consumed = static_cast<uint32_t>(build_->parser->parseBytesConsumed());
committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes);
if (committed) {
partial_ = true;
partialPageCount_ = builtPageCount_;
partialBytesConsumed_ = consumed;
partialTotalBytes_ = build_->totalBytes;
LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_);
}
}
if (build_->parser) build_->parser->abortParse();
if (build_->cssParser) build_->cssParser->clear();
if (!committed && file) {
// Explicit close() required before remove (member variable, O_RDWR handle).
file.close();
Storage.remove(binTmpPath().c_str());
}
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
Storage.remove(build_->tmpHtmlPath.c_str());
}
build_.reset();
buildComplete_ = false;
pageCount = partial_ ? partialPageCount_ : 0;
builtPageCount_ = 0;
}
void Section::abandonBuild() {
if (!build_) return;
if (build_->parser) build_->parser->abortParse();
if (build_->cssParser) build_->cssParser->clear();
if (file) {
// Explicit close() required before remove (member variable, O_RDWR handle).
file.close();
Storage.remove(binTmpPath().c_str());
}
// A parse error would recur against the same HTML, so drop any partial too -- resuming
// from it would just re-enter the failing build every open.
if (Storage.exists(filePath.c_str())) {
Storage.remove(filePath.c_str());
}
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
Storage.remove(build_->tmpHtmlPath.c_str());
}
build_.reset();
buildComplete_ = false;
partial_ = false;
partialPageCount_ = 0;
pageCount = 0;
builtPageCount_ = 0;
}
std::unique_ptr<Page> Section::loadPageDuringBuild(const int page) {
if (!build_ || page < 0 || page >= static_cast<int>(build_->lut.size()) || !file) {
return nullptr;
}
const uint32_t pos = build_->lut[page].fileOffset;
if (pos == 0) {
return nullptr;
}
// The .bin is open O_RDWR for the build. Read the already-written page, then restore
// the write cursor so the next onPageComplete keeps appending where it left off.
const uint32_t writePos = file.position();
file.seek(pos);
auto p = Page::deserialize(file);
file.seek(writePos);
return p;
}
// Read a page from the committed file at filePath (finalized section or partial from a
// previous session). Uses a local handle so it is safe while a build holds the member
// `file` open on the tmp .bin.
std::unique_ptr<Page> Section::loadPageAt(const int page) const {
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return nullptr;
}
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
serialization::readPod(f, lutOffset);
f.seek(lutOffset + sizeof(uint32_t) * page);
uint32_t pagePos;
serialization::readPod(file, pagePos);
file.seek(pagePos);
serialization::readPod(f, pagePos);
f.seek(pagePos);
auto page = Page::deserialize(file);
// Explicit close() required: member variable persists beyond function scope
file.close();
return page;
return Page::deserialize(f);
// No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
}
std::unique_ptr<Page> Section::loadPage(const int page) {
if (page < 0) {
return nullptr;
}
if (build_ && page < static_cast<int>(build_->lut.size())) {
return loadPageDuringBuild(page);
}
// Not (yet) in the active build: serve from the file on disk -- a finalized section,
// or a partial from a previous session whose pages the rebuild hasn't reached again.
const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount);
if (page >= onDisk) {
return nullptr;
}
return loadPageAt(page);
}
std::string Section::getTextFromSectionFile() {
std::string fullText;
auto p = this->loadPageFromSectionFile();
auto p = loadPage(currentPage);
if (p) {
for (const auto& el : p->elements) {
if (el->getTag() == TAG_PageLine) {
@@ -361,6 +756,15 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
return std::nullopt;
}
// Only a finalized section's count is the chapter total; a partial's count is just the
// suspended build's watermark, which would skew progress mapping. Callers fall back to
// their own estimates.
uint8_t version;
serialization::readPod(f, version);
if (version != SECTION_FILE_VERSION) {
return std::nullopt;
}
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
uint16_t count;
serialization::readPod(f, count);
+105 -7
View File
@@ -3,11 +3,14 @@
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Epub.h"
class Page;
class GfxRenderer;
class ChapterHtmlSlimParser;
class CssParser;
class Section {
std::shared_ptr<Epub> epub;
@@ -21,16 +24,67 @@ class Section {
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
uint32_t onPageComplete(std::unique_ptr<Page> page);
// Page-offset table entry, kept in RAM while an incremental build is running so
// already-built pages can be located in the partially-written .bin.
struct PageLutEntry {
uint32_t fileOffset;
uint16_t paragraphIndex;
uint16_t listItemIndex;
};
// Held only while an incremental build is in progress (see startBuild). Carries the
// live parser plus the strings it references (the parser stores them by reference)
// and the in-RAM page-offset table.
struct BuildContext {
std::unique_ptr<ChapterHtmlSlimParser> parser;
std::vector<PageLutEntry> lut;
std::string parsePath;
std::string contentBase;
std::string imageBasePath;
std::string htmlPath;
std::string tmpHtmlPath;
bool reusedHtml = false;
CssParser* cssParser = nullptr;
// HTML byte progress, for estimating the section's total page count while it's still building.
uint32_t bytesConsumed = 0;
uint32_t totalBytes = 0;
// Exponentially-smoothed page-count estimate (0 = not yet seeded) and the bytesConsumed at its
// last update. The raw byte-ratio estimate jitters as the build crosses dense/sparse regions;
// the EMA is stepped once per build advance (not per redraw) to damp that wobble.
float smoothedEstimate = 0;
uint32_t smoothedAtConsumed = 0;
};
std::unique_ptr<BuildContext> build_;
bool buildComplete_ = false;
// Pages laid out by the active build (== build_->lut.size()). Distinct from pageCount,
// which is the pages *available to read* and also counts a loaded partial file's pages.
uint16_t builtPageCount_ = 0;
// A partial section file (suspended build from a previous session) is loaded at filePath.
// Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them.
bool partial_ = false;
uint16_t partialPageCount_ = 0;
// Parse watermark from the partial's trailer, for estimating the total page count.
uint32_t partialBytesConsumed_ = 0;
uint32_t partialTotalBytes_ = 0;
bool finalizeBuild();
// Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the
// header, stamp the version byte, and swap the tmp .bin over filePath.
bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes);
// Builds write here and are swapped over filePath only on commit, so a prior
// partial/finalized file stays readable while a rebuild is in progress.
std::string binTmpPath() const { return filePath + ".part"; }
std::unique_ptr<Page> loadPageAt(int page) const;
// Read a page already laid out by the in-progress build (page < build LUT size), from
// the partially-written tmp .bin without disturbing the build's write cursor.
std::unique_ptr<Page> loadPageDuringBuild(int page);
public:
uint16_t pageCount = 0;
int currentPage = 0;
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
: epub(epub),
spineIndex(spineIndex),
renderer(renderer),
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default;
// Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the
// forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp.
explicit Section(const std::shared_ptr<Epub>& epub, int spineIndex, GfxRenderer& renderer);
~Section();
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, bool focusReadingEnabled);
@@ -39,12 +93,56 @@ class Section {
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, bool focusReadingEnabled,
const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Incremental build: lay out the section a few pages at a time so a large chapter
// can show its first page immediately and keep the UI responsive while the rest
// builds. createSectionFile() above is the one-shot wrapper over these.
// if (!startBuild(...)) fail;
// each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop.
bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, bool focusReadingEnabled, const std::function<void()>& popupFn = nullptr);
// Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns
// false on error (the build is abandoned). Sets isBuildComplete() when finished.
bool buildSomeMore(int maxPages);
bool isBuilding() const { return static_cast<bool>(build_); }
bool isBuildComplete() const { return buildComplete_; }
// Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based
// estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine
// is still building, so "page X of Y" / progress don't read off the small build watermark.
uint16_t estimatedTotalPages() const;
void abandonBuild();
// Persist an in-progress build as a partial section file (version sentinel + LUTs +
// watermark trailer) instead of discarding it, so the next open of this spine can show
// its pages instantly and only rebuild in the background. Called by the destructor, so
// any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a
// pre-existing partial when it covers more pages than this build reached.
void suspendBuild();
// True when a partial file was loaded: pageCount is a watermark, not the chapter total.
bool isPartial() const { return partial_; }
// Unified page read: from the active build if it has reached the page, otherwise from
// the on-disk file (finalized section, or a partial the rebuild hasn't caught up to).
std::unique_ptr<Page> loadPage(int page);
std::string getTextFromSectionFile();
// Resolve an anchor from the in-progress build first, then the on-disk anchor map
// (covers finalized sections and partials from a previous session).
std::optional<uint16_t> findAnchor(const std::string& anchor) const;
// True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a
// giant spine) zip inflation. Lets the reader skip the indexing popup on a fast reopen/rebuild.
bool hasHtmlCache() const;
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Look up an anchor among the pages built so far by the in-progress build, so an anchor jump
// (TOC / chapter select, usually the chapter top = page 0) can resolve without laying out the
// whole chapter. Returns nullopt if the anchor hasn't been reached yet (build more) or no build.
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
// Get the page count from the section cache file without fully loading it.
std::optional<uint16_t> getCachedPageCount() const;
+71 -5
View File
@@ -21,6 +21,39 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
const bool scanning = renderer.isFontCacheScanning();
const int ascender = renderer.getFontAscenderSize(fontId);
struct DecorationLineTracker {
EpdFontFamily::Style style;
int yOffset;
int startX = -1;
int endX = -1;
int yPos = 0;
bool active() const { return startX != -1; }
void reset() {
startX = -1;
endX = -1;
yPos = 0;
}
};
DecorationLineTracker decorationLines[] = {
{EpdFontFamily::UNDERLINE, ascender + 2},
{EpdFontFamily::STRIKETHROUGH, ascender * 4 / 5},
};
const auto flushDecoration = [&](DecorationLineTracker& line) {
if (line.active()) {
renderer.drawLine(line.startX, line.yPos, line.endX, line.yPos, 2, true);
line.reset();
}
};
const auto flushDecorations = [&]() {
for (auto& line : decorationLines) {
flushDecoration(line);
}
};
for (size_t i = 0; i < words.size(); i++) {
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
@@ -59,18 +92,51 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
}
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
if (scanning) {
continue;
}
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
const std::string& w = words[i];
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
const int underlineY = wordY + ascender + 2;
int lineStartX = wordX;
int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
underlineWidth = (underlineWidth + 1) / 2;
lineWidth = (lineWidth + 1) / 2;
}
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
// Do not decorate the synthetic em-space used for paragraph indentation.
if (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) {
lineWidth = (lineWidth + 1) / 2;
}
}
for (auto& line : decorationLines) {
if ((currentStyle & line.style) == 0) {
flushDecoration(line);
continue;
}
const int lineY = wordY + line.yOffset;
if (line.active() && line.yPos != lineY) {
flushDecoration(line);
}
if (!line.active()) {
line.startX = lineStartX;
line.yPos = lineY;
}
line.endX = lineStartX + lineWidth;
}
} else {
flushDecorations();
}
}
flushDecorations();
}
bool TextBlock::serialize(HalFile& file) const {
+15 -13
View File
@@ -67,13 +67,6 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe
[](char a, char b) { return asciiToLower(a) == b; });
}
// Case-insensitive ASCII substring search. Only needed by text-decoration,
// which accepts multi-value strings like "underline solid red".
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
}
// Walk s and invoke fn(token) for each non-empty run between delimiters.
// Tokens are boundary-trimmed and yielded as string_views into s; no
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
@@ -252,11 +245,20 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
}
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
// text-decoration can have multiple space-separated values
if (icontainsAscii(val, "underline")) {
return CssTextDecoration::Underline;
}
return CssTextDecoration::None;
// text-decoration can have multiple space-separated values. Compare whole tokens
// so malformed values like "notunderline" do not accidentally enable a line.
CssTextDecoration result = CssTextDecoration::None;
bool explicitNone = false;
forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) {
if (iequalsAscii(token, "none")) {
explicitNone = true;
} else if (iequalsAscii(token, "underline")) {
result = result | CssTextDecoration::Underline;
} else if (iequalsAscii(token, "line-through")) {
result = result | CssTextDecoration::LineThrough;
}
});
return explicitNone ? CssTextDecoration::None : result;
}
CssLength CssParser::interpretLength(std::string_view val) {
@@ -868,7 +870,7 @@ bool CssParser::loadFromCache() {
rulesBySelector_.clear();
return false;
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
+1 -1
View File
@@ -33,7 +33,7 @@
class CssParser {
public:
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 6;
static constexpr uint8_t CSS_CACHE_VERSION = 7;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
+13 -2
View File
@@ -52,8 +52,19 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
// Font weight options - CSS supports 100-900, we simplify to normal/bold
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
// Text decoration options
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Text decoration options. Values are bit flags so CSS can combine multiple line decorations.
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 };
constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) {
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
}
constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) {
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
}
constexpr uint8_t CSS_TEXT_DECORATION_MASK =
static_cast<uint8_t>(CssTextDecoration::Underline) | static_cast<uint8_t>(CssTextDecoration::LineThrough);
// Display options - only None and Block are relevant for e-ink rendering
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
+137 -93
View File
@@ -34,6 +34,7 @@ constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
constexpr const char* IMAGE_TAGS[] = {"img"};
constexpr const char* SKIP_TAGS[] = {"head"};
@@ -89,13 +90,50 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
}
}
// Update effective bold/italic/underline based on block style and inline style stack
EpdFontFamily::Style ChapterHtmlSlimParser::fontStyleForTextDecoration(const CssTextDecoration decoration) {
EpdFontFamily::Style style = EpdFontFamily::REGULAR;
if ((decoration & CssTextDecoration::Underline) != CssTextDecoration::None) {
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::UNDERLINE);
}
if ((decoration & CssTextDecoration::LineThrough) != CssTextDecoration::None) {
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::STRIKETHROUGH);
}
return style;
}
void ChapterHtmlSlimParser::applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css) {
if (css.hasTextDecoration()) {
entry.hasTextDecoration = true;
entry.textDecoration = css.textDecoration;
}
}
void ChapterHtmlSlimParser::pushDecorationStyleEntry(const CssTextDecoration defaultDecoration,
const CssStyle& cssStyle) {
StyleStackEntry entry;
entry.depth = depth;
entry.hasTextDecoration = true;
entry.textDecoration = cssStyle.hasTextDecoration() ? cssStyle.textDecoration : defaultDecoration;
if (cssStyle.hasFontWeight()) {
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyDirectionToEntry(entry, cssStyle);
inlineStyleStack.push_back(entry);
updateEffectiveInlineStyle();
}
// Update effective bold/italic/decorations based on block style and inline style stack
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Start with block-level styles
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
effectiveUnderline =
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
effectiveTextDecoration =
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
effectiveDirectionDefined = currentCssStyle.hasDirection();
effectiveDirection = currentCssStyle.direction;
effectiveSup = false;
@@ -109,8 +147,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
if (entry.hasItalic) {
effectiveItalic = entry.italic;
}
if (entry.hasUnderline) {
effectiveUnderline = entry.underline;
// CSS line decorations propagate through descendants; child entries add
// their own lines but cannot cancel an ancestor's already active line.
if (entry.hasTextDecoration) {
effectiveTextDecoration = effectiveTextDecoration | entry.textDecoration;
}
if (entry.hasDirection) {
effectiveDirectionDefined = true;
@@ -164,7 +204,6 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
// Determine font style from depth-based tracking and CSS effective style
const bool isBold = boldUntilDepth < depth || effectiveBold;
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
// Combine style flags using bitwise OR
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
@@ -174,9 +213,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (isItalic) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
}
if (isUnderline) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
}
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
if (effectiveSup) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
} else if (effectiveSub) {
@@ -420,14 +457,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
headerStyle.bold = false;
headerStyle.hasItalic = true;
headerStyle.italic = true;
headerStyle.hasUnderline = true;
headerStyle.underline = false;
self->inlineStyleStack.push_back(headerStyle);
self->updateEffectiveInlineStyle();
const CssTextDecoration savedTextDecoration = self->effectiveTextDecoration;
self->effectiveTextDecoration = CssTextDecoration::None;
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->effectiveTextDecoration = savedTextDecoration;
self->nextWordContinues = false;
self->inlineStyleStack.pop_back();
self->updateEffectiveInlineStyle();
@@ -760,12 +798,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentFootnote.number[0] = '\0';
self->currentFootnoteLinkTextLen = 0;
// Apply underline style to visually indicate the link
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
// Apply underline style to visually indicate the link.
StyleStackEntry entry;
entry.depth = self->depth;
entry.hasUnderline = true;
entry.underline = true;
entry.hasTextDecoration = true;
entry.textDecoration = CssTextDecoration::Underline;
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -837,23 +874,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
// Push inline style entry for underline tag
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
entry.hasUnderline = true;
entry.underline = true;
if (cssStyle.hasFontWeight()) {
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
self->pushDecorationStyleEntry(CssTextDecoration::Underline, cssStyle);
} else if (matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS))) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
self->pushDecorationStyleEntry(CssTextDecoration::LineThrough, cssStyle);
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
@@ -870,10 +898,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyTextDecorationToEntry(entry, cssStyle);
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -893,10 +918,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyTextDecorationToEntry(entry, cssStyle);
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -935,10 +957,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyTextDecorationToEntry(entry, cssStyle);
applyDirectionToEntry(entry, cssStyle);
if (cssStyle.hasVerticalAlign()) {
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
@@ -1150,9 +1169,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic;
const bool headerOrBlockTag = isHeaderOrBlock(name);
const bool tableStructuralTag = isTableStructuralTag(name);
@@ -1171,7 +1189,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) ||
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
if (shouldFlush) {
@@ -1230,11 +1249,6 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->italicUntilDepth = INT_MAX;
}
// Leaving underline tag
if (self->underlineUntilDepth == self->depth) {
self->underlineUntilDepth = INT_MAX;
}
// Pop from inline style stack if we pushed an entry at this depth
// This handles all inline elements: b, i, u, span, etc.
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
@@ -1261,7 +1275,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
bool ChapterHtmlSlimParser::beginParse() {
// Initialize block style stack with a root entry representing "no ancestor block elements".
// The user's paragraph alignment is set as the default so child elements without explicit
// text-align inherit it correctly through getCombinedBlockStyle.
@@ -1279,67 +1295,78 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
paragraphAlignmentBlockStyle.alignment = align;
startNewTextBlock(paragraphAlignmentBlockStyle);
XML_Parser parser = XML_ParserCreate(nullptr);
int done;
if (!parser) {
xmlParser_ = XML_ParserCreate(nullptr);
if (!xmlParser_) {
LOG_ERR("EHP", "Couldn't allocate memory for parser");
return false;
}
// Handle HTML entities (like &nbsp;) that aren't in XML spec or DTD
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand);
HalFile file;
if (!Storage.openFileForRead("EHP", filepath, file)) {
destroyXmlParser(parser);
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
destroyXmlParser(xmlParser_);
xmlParser_ = nullptr;
return false;
}
// Get file size to decide whether to show indexing popup.
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
popupFn();
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
XML_SetUserData(xmlParser_, this);
XML_SetElementHandler(xmlParser_, startElement, endElement);
XML_SetCharacterDataHandler(xmlParser_, characterData);
// Compute the time taken to parse and build pages
const uint32_t chapterStartTime = millis();
do {
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
if (!buf) {
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
destroyXmlParser(parser);
file.close();
return false;
}
parseStartTime_ = millis();
return true;
}
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() {
void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE);
if (!buf) {
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
return ParseStatus::Error;
}
if (len == 0 && file.available() > 0) {
LOG_ERR("EHP", "File read error");
destroyXmlParser(parser);
file.close();
return false;
}
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
done = file.available() == 0;
if (len == 0 && parseFile_.available() > 0) {
LOG_ERR("EHP", "File read error");
return ParseStatus::Error;
}
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
destroyXmlParser(parser);
file.close();
return false;
}
} while (!done);
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
const int done = parseFile_.available() == 0;
destroyXmlParser(parser);
file.close();
if (XML_ParseBuffer(xmlParser_, static_cast<int>(len), done) == XML_STATUS_ERROR) {
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_),
XML_ErrorString(XML_GetErrorCode(xmlParser_)));
return ParseStatus::Error;
}
return done ? ParseStatus::Done : ParseStatus::More;
}
void ChapterHtmlSlimParser::abortParse() {
if (xmlParser_) {
destroyXmlParser(xmlParser_);
xmlParser_ = nullptr;
}
// Only close the file if it was successfully opened in beginParse()
if (parseFile_.isOpen()) {
parseFile_.close();
}
}
bool ChapterHtmlSlimParser::finishParse() {
if (xmlParser_) {
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_);
destroyXmlParser(xmlParser_);
xmlParser_ = nullptr;
}
parseFile_.close();
// Process last page if there is still text
if (currentTextBlock) {
@@ -1357,6 +1384,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return true;
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
if (!beginParse()) {
return false;
}
for (;;) {
const ParseStatus status = parseStep();
if (status == ParseStatus::Error) {
abortParse();
return false;
}
if (status == ParseStatus::Done) {
break;
}
}
return finishParse();
}
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
+38 -4
View File
@@ -1,5 +1,6 @@
#pragma once
#include <HalStorage.h>
#include <expat.h>
#include <climits>
@@ -31,7 +32,6 @@ class ChapterHtmlSlimParser {
int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX;
int italicUntilDepth = INT_MAX;
int underlineUntilDepth = INT_MAX;
// buffer for building up words from characters, will auto break if longer than this
// leave one char at end for null pointer
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
@@ -60,7 +60,8 @@ class ChapterHtmlSlimParser {
int depth = 0;
bool hasBold = false, bold = false;
bool hasItalic = false, italic = false;
bool hasUnderline = false, underline = false;
bool hasTextDecoration = false;
CssTextDecoration textDecoration = CssTextDecoration::None;
bool hasDirection = false;
CssTextDirection direction = CssTextDirection::Ltr;
bool hasSup = false, sup = false;
@@ -71,7 +72,7 @@ class ChapterHtmlSlimParser {
CssStyle currentCssStyle;
bool effectiveBold = false;
bool effectiveItalic = false;
bool effectiveUnderline = false;
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
bool effectiveDirectionDefined = false;
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
bool effectiveSup = false;
@@ -96,12 +97,25 @@ class ChapterHtmlSlimParser {
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
int wordsExtractedInBlock = 0;
// Resumable parse state. The one-shot parseAndBuildPages() drives these
// internally; the incremental section builder drives them across render ticks
// so a large single chapter can yield between pages instead of blocking the UI
// until the whole thing is laid out. parseFile_ and the expat parser stay alive
// for the lifetime of the parse so it can be paused and resumed at buffer
// boundaries.
XML_Parser xmlParser_ = nullptr;
HalFile parseFile_;
uint32_t parseStartTime_ = 0;
void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPendingAnchor();
void flushPartWordBuffer();
void makePages();
static EpdFontFamily::Style fontStyleForTextDecoration(CssTextDecoration decoration);
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
static void applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css);
void pushDecorationStyleEntry(CssTextDecoration defaultDecoration, const CssStyle& cssStyle);
void emitHorizontalRule(const BlockStyle& blockStyle);
// XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
@@ -141,8 +155,28 @@ class ChapterHtmlSlimParser {
imageBasePath(imageBasePath),
tocAnchors(std::move(tocAnchors)) {}
~ChapterHtmlSlimParser() = default;
~ChapterHtmlSlimParser();
// One-shot parse: builds every page before returning (begin + step* + finish).
bool parseAndBuildPages();
// Resumable parse, for the incremental section builder. Drive as:
// if (!beginParse()) fail;
// loop: switch (parseStep()) { More: keep going / yield; Done: finishParse(); Error: abortParse(); }
// Pages are emitted via completePageFn as they complete during parseStep(), so
// the caller can stop once enough pages are built and resume on a later tick.
enum class ParseStatus { More, Done, Error };
bool beginParse();
ParseStatus parseStep();
bool finishParse(); // flush the trailing page and tear down; returns true
void abortParse(); // tear down without flushing (error / abandon)
void addLineToPage(std::shared_ptr<TextBlock> line);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
// Byte progress of the in-flight parse, used to estimate a still-building section's total page
// count (a giant single-spine book never fully lays out, so its real count is unknown). Valid
// between beginParse() and finishParse()/abortParse().
size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; }
size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; }
};
@@ -150,6 +150,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
self->state = IN_GUIDE;
// TODO Remove print
LOG_DBG("COF", "Entering guide state.");
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
+48 -40
View File
@@ -79,6 +79,53 @@ std::string normalisePath(const std::string& path) {
return result;
}
bool naturalLess(const std::string& str1, const std::string& str2) {
// Naive natural sort: numeric-aware, case-insensitive
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// ctype functions require unsigned char values: passing a negative char (UTF-8
// bytes above 0x7f with signed char) is undefined behavior
const auto isDigit = [](const char c) { return isdigit(static_cast<unsigned char>(c)) != 0; };
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isDigit(*s1) && isDigit(*s2)) {
// Skip leading zeros and track them
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isDigit(s1[len1])) len1++;
while (isDigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
const int c1 = tolower(static_cast<unsigned char>(*s1));
const int c2 = tolower(static_cast<unsigned char>(*s2));
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
}
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
// Directories first
@@ -86,46 +133,7 @@ void sortFileList(std::vector<std::string>& strs) {
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
return naturalLess(str1, str2);
});
}
+4
View File
@@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path);
std::string normalisePath(const std::string& path);
// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders
// before str2. Same ordering sortFileList applies within the file/directory groups.
bool naturalLess(const std::string& str1, const std::string& str2);
void sortFileList(std::vector<std::string>& strs);
/**
+36 -97
View File
@@ -3,7 +3,6 @@
#include <BidiUtils.h>
#include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Icon.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <Utf8.h>
@@ -101,23 +100,6 @@ void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
}
}
void GfxRenderer::setUiFontRemap(const int* from, const int* to, int count) {
if (count < 0) count = 0;
if (count > MAX_UI_FONT_REMAP) count = MAX_UI_FONT_REMAP;
for (int i = 0; i < count; ++i) {
uiFontFrom_[i] = from[i];
uiFontTo_[i] = to[i];
}
uiFontRemapCount_ = count;
}
int GfxRenderer::remapUiFont(const int fontId) const {
for (int i = 0; i < uiFontRemapCount_; ++i) {
if (uiFontFrom_[i] == fontId) return uiFontTo_[i];
}
return fontId;
}
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
// This should always be inlined for better performance
static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX,
@@ -300,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
@@ -383,7 +374,7 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF
return 0;
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -425,7 +416,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
@@ -1072,20 +1063,21 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
}
void GfxRenderer::drawIcon(const freeink::Icon& icon, const int x, const int y, const bool black) const {
// Bits are un-rotated, so each drawn pixel goes through drawPixel (which applies
// the orientation transform) — correct in every orientation, unlike the legacy
// uint8_t* overload that pre-rotates for one orientation.
const int rowBytes = (icon.w + 7) / 8;
for (int row = 0; row < icon.h; ++row) {
const uint8_t* r = icon.bits + static_cast<int>(row) * rowBytes;
for (int col = 0; col < icon.w; ++col) {
if (((r[col >> 3] >> (7 - (col & 7))) & 1) == 0) { // 0 = drawn
drawPixel(x + col, y + row, black);
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int size) const {
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
// icon's position to 8px (one byte) along the rotated axis, which prevents it
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
// only called by the UI themes, which all render in forced Portrait.
const int rowBytes = (size + 7) / 8;
for (int row = 0; row < size; row++) {
for (int col = 0; col < size; col++) {
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
if (ink) {
drawPixel(x + (size - 1 - row), y + col, true);
}
}
}
@@ -1499,38 +1491,6 @@ int GfxRenderer::getScreenHeight() const {
return panelWidth;
}
void GfxRenderer::tapToLogical(float nx, float ny, int& outX, int& outY) const {
// Native panel pixel of the tap (wasTouchTap normalizes over the native panel).
int phyX = static_cast<int>(nx * panelWidth);
int phyY = static_cast<int>(ny * panelHeight);
if (phyX < 0) phyX = 0;
if (phyX > panelWidth - 1) phyX = panelWidth - 1;
if (phyY < 0) phyY = 0;
if (phyY > panelHeight - 1) phyY = panelHeight - 1;
// Inverse of rotateCoordinates() (see the forward transform above): map a
// physical/native point back into the current orientation's logical frame.
switch (orientation) {
case Portrait: // forward: phyX=logY, phyY=panelHeight-1-logX
outX = panelHeight - 1 - phyY;
outY = phyX;
break;
case PortraitInverted: // forward: phyX=panelWidth-1-logY, phyY=logX
outX = phyY;
outY = panelWidth - 1 - phyX;
break;
case LandscapeClockwise: // forward: phyX=panelWidth-1-logX, phyY=panelHeight-1-logY
outX = panelWidth - 1 - phyX;
outY = panelHeight - 1 - phyY;
break;
case LandscapeCounterClockwise: // forward: identity
default:
outX = phyX;
outY = phyY;
break;
}
}
// Translate a logical rect through rotateCoordinates and take the bounding
// box of its four corners on the physical panel. Output coords are inclusive
// and clamped. Returns false if the rect ends up fully off-panel.
@@ -1622,7 +1582,7 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1643,7 +1603,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
@@ -1657,7 +1617,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
@@ -1689,7 +1649,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
return fp4::toPixel(widthFP);
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1725,7 +1685,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1735,7 +1695,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
}
int GfxRenderer::getLineHeight(const int fontId) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1745,7 +1705,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
}
int GfxRenderer::getTextHeight(const int fontId) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1753,27 +1713,6 @@ int GfxRenderer::getTextHeight(const int fontId) const {
return fontIt->second.getData(EpdFontFamily::REGULAR)->ascender;
}
// Height of a reference glyph above the baseline (glyph->top): 'H' gives the real
// cap height, the actual visible font extent for vertical centering.
int GfxRenderer::glyphTop(const int fontId, const uint32_t cp) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) return 0;
const EpdGlyph* g = fontIt->second.getGlyph(cp, EpdFontFamily::REGULAR);
return g ? g->top : 0;
}
int GfxRenderer::getFontCapHeight(const int fontId) const { return glyphTop(fontId, 'H'); }
int GfxRenderer::getTextVisualCenterOffset(const int fontId) const {
// Cap-height middle: baseline is at top + ascender, caps reach capHeight above it,
// so the optical center is ascender - capHeight/2 below the top. Cap (not x) height
// because UI labels are capital-led. Falls back to ascender*0.65 without an 'H'.
const int ascender = getFontAscenderSize(fontId);
const int capHeight = getFontCapHeight(fontId);
if (capHeight <= 0) return (ascender * 65) / 100;
return ascender - capHeight / 2;
}
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
@@ -1781,7 +1720,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
return;
}
const auto fontIt = fontMap.find(remapUiFont(fontId));
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
+10 -30
View File
@@ -13,9 +13,6 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 };
class FontCacheManager;
class SdCardFont;
namespace freeink {
struct Icon;
}
#include <cstring>
#include <map>
@@ -47,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;
@@ -54,13 +58,6 @@ class GfxRenderer {
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
std::vector<uint8_t*> bwBufferChunks;
std::map<int, EpdFontFamily> fontMap;
// UI chrome font remap table (see setUiFontRemap). Empty = identity.
static constexpr int MAX_UI_FONT_REMAP = 8;
int uiFontFrom_[MAX_UI_FONT_REMAP] = {0};
int uiFontTo_[MAX_UI_FONT_REMAP] = {0};
int uiFontRemapCount_ = 0;
int remapUiFont(int fontId) const;
int glyphTop(int fontId, uint32_t cp) const;
// Mutable because ensureSdCardFontReady() is const (called from layout code
// that holds a const GfxRenderer&) but triggers SD card reads and heap
// allocation inside the SdCardFont objects. Same pragmatic compromise as
@@ -111,14 +108,6 @@ class GfxRenderer {
// Setup
void begin(); // must be called right after display.begin()
void insertFont(int fontId, EpdFontFamily font);
// Orientation-correct icon blit for the freeink::Icon format (un-rotated bits +
// optical-center metadata). Prefer this over the legacy raw-bitmap drawIcon.
void drawIcon(const freeink::Icon& icon, int x, int y, bool black = true) const;
// UI chrome font scaling: firmware supplies a small remap table (from the board
// uiScale) that substitutes a larger font for each scaled UI font id at lookup
// time, so layout and drawing stay consistent with no call-site changes. Reader
// body fonts aren't in the table, so book text is unaffected. count <= 8.
void setUiFontRemap(const int* from, const int* to, int count);
// Clears both the flash-font map and any SD-font registration for fontId.
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
// the underlying SdCardFont and forget the SD-side unregister.
@@ -159,11 +148,6 @@ class GfxRenderer {
void clearScreen(uint8_t color = 0xFF) const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
// Map a touch tap (normalized 0..1 in panel-native orientation, from
// InputManager::wasTouchTap) to logical screen coordinates matching the Rects the
// UI draws in. Inverse of rotateCoordinates() for the current orientation.
void tapToLogical(float nx, float ny, int& outX, int& outY) const;
// Tiled grayscale strip target. While active, drawPixel() and clearScreen()
// operate on `scratch` (panelWidthBytes * stripRows bytes, holding physical
// rows [stripY0, stripY0 + stripRows)) instead of the framebuffer; pixels
@@ -206,7 +190,7 @@ class GfxRenderer {
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
bool roundBottomLeft, bool roundBottomRight, Color color) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int size) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
@@ -231,12 +215,6 @@ class GfxRenderer {
int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const;
// Cap height (from the 'H' glyph), the real visible font extent for vertical
// alignment that follows the font instead of a guessed ascender fraction.
int getFontCapHeight(int fontId) const;
// Offset from text top (drawText's y) to the text's optical center; align an
// element to a line via centerY = textTop + getTextVisualCenterOffset(fontId).
int getTextVisualCenterOffset(int fontId) const;
std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
/// Word-wrap \p text into at most \p maxLines lines, each no wider than
@@ -253,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
+4 -3
View File
@@ -65,10 +65,11 @@ 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: "Бакавыя кнопкі"
STR_TOUCH_READER_CONTROLS: "Сэнсарнае кіраванне чытаннем"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
@@ -237,7 +238,6 @@ STR_CHAPTER_PREFIX: "Раздзел:"
STR_PAGES_SEPARATOR: "стар. |"
STR_BOOK_PREFIX: "Кніга:"
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
STR_SYNCING_TIME: "Сінхранізацыя часу..."
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
@@ -297,7 +297,8 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
STR_LINK: "[спасылка]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
STR_SLEEP_NEVER: "Ніколі"
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Suprimir"
STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada"
STR_ORIENTATION: "Orientació de lectura"
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
@@ -273,7 +274,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
STR_PAGES_SEPARATOR: " pàgines | "
STR_BOOK_PREFIX: "Llibre: "
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
STR_CALC_HASH: "S'està calculant el hash del document..."
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
@@ -306,7 +306,8 @@ STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
STR_LINK: "[enllaç]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Mai"
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
STR_STEP_HINT_FRONT: "Botons frontals:"
STR_STEP_HINT_SIDE: "Botons laterals:"
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
+5 -4
View File
@@ -58,17 +58,18 @@ STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
STR_CAT_DISPLAY: "Displej"
STR_CAT_READER: "Čtečka"
STR_CAT_CONTROLS: "Ovládací prvky"
STR_CAT_CONTROLS: "Ovládání"
STR_CAT_SYSTEM: "Systém"
STR_SLEEP_SCREEN: "Obrazovka spánku"
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
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)"
STR_TOUCH_READER_CONTROLS: "Dotykové ovládání čtečky"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientovat přední tlačítka"
STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
@@ -244,7 +245,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
STR_PAGES_SEPARATOR: "stránek |"
STR_BOOK_PREFIX: "Kniha:"
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
STR_PERCENT_STEP_HINT: "Vlevo/Vpravo: 1 % Nahoru/Dolů: 10 %"
STR_SYNCING_TIME: "Čas synchronizace..."
STR_CALC_HASH: "Výpočet hashe dokumentu..."
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
@@ -274,5 +274,6 @@ STR_OPDS_SERVER_URL: "URL serveru OPDS"
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
STR_STEP_HINT_FRONT: "Přední tlačítka:"
STR_STEP_HINT_SIDE: "Boční tlačítka:"
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Skjul"
STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap"
STR_ORIENTATION: "Læseretning"
STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)"
STR_TOUCH_READER_CONTROLS: "Touch-betjening (læser)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientér forreste knapper"
STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
@@ -267,7 +268,6 @@ STR_CHAPTER_PREFIX: "Kapitel: "
STR_PAGES_SEPARATOR: " sider | "
STR_BOOK_PREFIX: "Bog: "
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
STR_PERCENT_STEP_HINT: "Venstre/Højre: 1% Op/Ned: 10%"
STR_SYNCING_TIME: "Synkroniserer tid..."
STR_CALC_HASH: "Beregner dokument-hash..."
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
@@ -299,7 +299,8 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Aldrig"
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
STR_STEP_HINT_FRONT: "Frontknapper:"
STR_STEP_HINT_SIDE: "Sideknapper:"
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Verbergen"
STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop"
STR_ORIENTATION: "Leesstand"
STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)"
STR_TOUCH_READER_CONTROLS: "Aanraakbediening lezer"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Richt voorste knoppen"
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
@@ -267,7 +268,6 @@ STR_CHAPTER_PREFIX: "Hoofdstuk: "
STR_PAGES_SEPARATOR: " pagina's | "
STR_BOOK_PREFIX: "Boek: "
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
STR_PERCENT_STEP_HINT: "Links/Rechts: 1% Omhoog/Omlaag: 10%"
STR_SYNCING_TIME: "Tijd synchroniseren..."
STR_CALC_HASH: "Document-hash berekenen..."
STR_HASH_FAILED: "Document-hash berekenen mislukt"
@@ -299,7 +299,8 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nooit"
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
STR_STEP_HINT_FRONT: "Voorknoppen:"
STR_STEP_HINT_SIDE: "Zijknoppen:"
STR_SCREENSHOT_BUTTON: "Screenshot maken"
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
+6 -3
View File
@@ -66,14 +66,17 @@ 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"
STR_IMAGES_SUPPRESS: "Suppress"
STR_EOB_HOME: "Home"
STR_EOB_CONTINUE_WITH: "Continue with"
STR_SHORT_PWR_BTN: "Short Power Button Click"
STR_ORIENTATION: "Reading Orientation"
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
STR_TOUCH_READER_CONTROLS: "Touch Reader Controls"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orient front buttons"
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
@@ -298,7 +301,6 @@ STR_CHAPTER_PREFIX: "Chapter: "
STR_PAGES_SEPARATOR: " pages | "
STR_BOOK_PREFIX: "Book: "
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
STR_PERCENT_STEP_HINT: "Left/Right: 1% Up/Down: 10%"
STR_SYNCING_TIME: "Syncing time..."
STR_CALC_HASH: "Calculating document hash..."
STR_HASH_FAILED: "Failed to calculate document hash"
@@ -333,7 +335,8 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Never"
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
STR_STEP_HINT_FRONT: "Front buttons:"
STR_STEP_HINT_SIDE: "Side buttons:"
STR_ADD_SERVER: "Add Server"
STR_SERVER_NAME: "Server Name"
STR_NO_SERVERS: "No OPDS servers configured"
+4 -3
View File
@@ -65,10 +65,11 @@ 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)"
STR_TOUCH_READER_CONTROLS: "Kosketusohjaus (lukija)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Suuntaa etupainikkeet"
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
@@ -242,7 +243,6 @@ STR_CHAPTER_PREFIX: "Luku: "
STR_PAGES_SEPARATOR: " sivua | "
STR_BOOK_PREFIX: "Kirja: "
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
STR_PERCENT_STEP_HINT: "Vasen/Oikea: 1% Ylös/Alas: 10%"
STR_SYNCING_TIME: "Synkronoidaan aikaa..."
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
@@ -272,5 +272,6 @@ STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Ei koskaan"
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
STR_STEP_HINT_FRONT: "Etupainikkeet:"
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
+4 -3
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é"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Masquer"
STR_SHORT_PWR_BTN: "Appui court alim."
STR_ORIENTATION: "Orientation de lecture"
STR_SIDE_BTN_LAYOUT: "Boutons latéraux"
STR_TOUCH_READER_CONTROLS: "Commandes tactiles (lecteur)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienter boutons avant"
STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
@@ -268,7 +269,6 @@ STR_CHAPTER_PREFIX: "Chapitre : "
STR_PAGES_SEPARATOR: " pages | "
STR_BOOK_PREFIX: "Livre : "
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à lURL"
STR_PERCENT_STEP_HINT: "Gauche/Droite : 1% Haut/Bas : 10%"
STR_SYNCING_TIME: "Synchro de lheure…"
STR_CALC_HASH: "Calcul hash doc…"
STR_HASH_FAILED: "Échec calcul hash"
@@ -300,7 +300,8 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
STR_LINK: "[lien]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Jamais"
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
STR_STEP_HINT_FRONT: "Boutons avant :"
STR_STEP_HINT_SIDE: "Boutons latéraux :"
STR_SCREENSHOT_BUTTON: "Capture d'écran"
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
+4 -3
View File
@@ -65,10 +65,11 @@ 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)"
STR_TOUCH_READER_CONTROLS: "Touch-Steuerung (Lesen)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
@@ -289,7 +290,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
STR_PAGES_SEPARATOR: " Seiten | "
STR_BOOK_PREFIX: "Buch: "
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
STR_PERCENT_STEP_HINT: "links/rechts: 1% hoch/runter: 10%"
STR_SYNCING_TIME: "Zeit synchronisieren…"
STR_CALC_HASH: "Dokument-Hash berechnen…"
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
@@ -323,7 +323,8 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
STR_LINK: "[Link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
STR_SLEEP_NEVER: "Nie"
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
STR_STEP_HINT_FRONT: "Vordere Tasten:"
STR_STEP_HINT_SIDE: "Seitentasten:"
STR_ADD_SERVER: "Server hinzufügen"
STR_SERVER_NAME: "Servername"
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
+4 -3
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: "סמן"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "הסתר תמונות"
STR_SHORT_PWR_BTN: "לחיצה קצרה על כפתור ההפעלה"
STR_ORIENTATION: "כיוון קריאה (מסך)"
STR_SIDE_BTN_LAYOUT: "פריסת כפתורי צד (בקריאה)"
STR_TOUCH_READER_CONTROLS: "פקדי מגע בקריאה"
STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
@@ -269,7 +270,6 @@ STR_CHAPTER_PREFIX: "פרק: "
STR_PAGES_SEPARATOR: " דפים | "
STR_BOOK_PREFIX: "ספר: "
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
STR_PERCENT_STEP_HINT: "שמאל/ימין: 1% למעלה/למטה: 10%"
STR_SYNCING_TIME: "מסנכרן שעון..."
STR_CALC_HASH: "מחשב האש..."
STR_HASH_FAILED: "חישוב האש נכשל"
@@ -391,4 +391,5 @@ STR_BOOKMARK_OPTION: "סימנייה"
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
STR_SLEEP_NEVER: "אף פעם"
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
STR_STEP_HINT_SIDE: "לחצני צד:"
+4 -3
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ő"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Elnyomás"
STR_SHORT_PWR_BTN: "Rövid bekapcsológomb nyomás"
STR_ORIENTATION: "Olvasási irány"
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
STR_TOUCH_READER_CONTROLS: "Érintőképernyős vezérlés (olvasó)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
@@ -264,7 +265,6 @@ STR_CHAPTER_PREFIX: "Fejezet: "
STR_PAGES_SEPARATOR: " oldal | "
STR_BOOK_PREFIX: "Könyv: "
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
STR_PERCENT_STEP_HINT: "Bal/Jobb: 1% Fel/Le: 10%"
STR_SYNCING_TIME: "Idő szinkronizálása..."
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
@@ -296,7 +296,8 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
STR_SLEEP_NEVER: "Soha"
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
STR_STEP_HINT_FRONT: "Elülső gombok:"
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
+4 -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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Nascondi"
STR_SHORT_PWR_BTN: "Press. breve pul. accensione"
STR_ORIENTATION: "Orientamento lettura"
STR_SIDE_BTN_LAYOUT: "Pul. laterali (lettore)"
STR_TOUCH_READER_CONTROLS: "Controlli touch (lettore)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienta pul. frontali"
STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
@@ -275,7 +276,6 @@ STR_CHAPTER_PREFIX: "Capitolo: "
STR_PAGES_SEPARATOR: " pagine | "
STR_BOOK_PREFIX: "Libro: "
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
STR_PERCENT_STEP_HINT: "Sinistra/Destra: 1% Su/Giù: 10%"
STR_SYNCING_TIME: "Sincronizzazione orario..."
STR_CALC_HASH: "Calcolo hash documento..."
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
@@ -308,7 +308,8 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Mai"
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
STR_SCREENSHOT_BUTTON: "Screenshot"
STR_ADD_SERVER: "Aggiungi server"
STR_SERVER_NAME: "Nome server"
+4 -3
View File
@@ -64,10 +64,11 @@ 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: "Бүйірлік түймелер орналасуы (оқырман)"
STR_TOUCH_READER_CONTROLS: "Сенсорлық басқару (оқырман)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
@@ -238,7 +239,6 @@ STR_CHAPTER_PREFIX: "Тарау: "
STR_PAGES_SEPARATOR: " бет | "
STR_BOOK_PREFIX: "Кітап: "
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
STR_PERCENT_STEP_HINT: "Сол/Оң: 1% Жоғары/Төмен: 10%"
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
STR_CALC_HASH: "Құжат хэші есептелуде..."
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
@@ -295,7 +295,8 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
STR_LINK: "[сілтеме]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
STR_SLEEP_NEVER: "Ешқашан"
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Slėpti"
STR_SHORT_PWR_BTN: "Trumpas įjungimo pasp."
STR_ORIENTATION: "Orientacija"
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
STR_TOUCH_READER_CONTROLS: "Liečiamasis valdymas (skaityklė)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
@@ -264,7 +265,6 @@ STR_CHAPTER_PREFIX: "Sk: "
STR_PAGES_SEPARATOR: " psl. | "
STR_BOOK_PREFIX: "Kn: "
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
STR_PERCENT_STEP_HINT: "K/D: 1% | V/A: 10%"
STR_SYNCING_TIME: "Sinchr. laikas..."
STR_CALC_HASH: "Skaičiuojama maiša..."
STR_HASH_FAILED: "Maišos klaida"
@@ -296,7 +296,8 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
STR_LINK: "[nuoroda]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
STR_SLEEP_NEVER: "Niekada"
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Pomijaj"
STR_SHORT_PWR_BTN: "Krótkie naciśnięcie zasilania"
STR_ORIENTATION: "Układ czytania"
STR_SIDE_BTN_LAYOUT: "Układ przycisków bocznych"
STR_TOUCH_READER_CONTROLS: "Sterowanie dotykowe (czytnik)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuj przednie przyciski"
STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
@@ -276,7 +277,6 @@ STR_CHAPTER_PREFIX: "Rozdział: "
STR_PAGES_SEPARATOR: " stron | "
STR_BOOK_PREFIX: "Książka: "
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
STR_PERCENT_STEP_HINT: "Lewo/Prawo: 1% Góra/Dół: 10%"
STR_SYNCING_TIME: "Synchronizacja czasu..."
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
@@ -309,7 +309,8 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nigdy"
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
STR_STEP_HINT_FRONT: "Przyciski przednie:"
STR_STEP_HINT_SIDE: "Przyciski boczne:"
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
STR_ADD_SERVER: "Dodaj serwer"
STR_SERVER_NAME: "Nazwa serwera"
+138 -29
View File
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
STR_EMPTY_CHAPTER: "Capítulo vazio"
STR_INDEXING: "Indexando"
STR_MEMORY_ERROR: "Erro de memória"
STR_PAGE_LOAD_ERROR: "Erro página"
STR_PAGE_LOAD_ERROR: "Erro ao carregar pág."
STR_EMPTY_FILE: "Arquivo vazio"
STR_OUT_OF_BOUNDS: "Fora dos limites"
STR_LOADING: "Carregando..."
@@ -32,8 +32,8 @@ STR_CONNECTING: "Conectando..."
STR_CONNECTED: "Conectado!"
STR_CONNECTION_FAILED: "Falha na conexão"
STR_FORGET_NETWORK: "Esquecer rede?"
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
STR_SAVE_PASSWORD: "Salvar senha na próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
STR_JOIN_NETWORK: "Entrar em uma rede"
STR_CREATE_HOTSPOT: "Criar hotspot"
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
@@ -41,7 +41,7 @@ STR_HOTSPOT_DESC: "Crie uma rede Wi-Fi para outras pessoas entrarem"
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
STR_HOTSPOT_MODE: "Modo hotspot"
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
STR_OPEN_URL_HINT: "Abra esta URL no seu navegador"
STR_OR_HTTP_PREFIX: "ou http://"
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
STR_CALIBRE_WIRELESS: "Calibre sem fio"
@@ -50,31 +50,38 @@ STR_MAC_ADDRESS: "Endereço MAC:"
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
STR_TO_PREFIX: "para"
STR_CALIBRE_RECEIVING: "Recebendo:"
STR_CALIBRE_RECEIVED: "Recebido:"
STR_CALIBRE_RECEIVING: "Recebendo: "
STR_CALIBRE_RECEIVED: "Recebido: "
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar ao dispositivo\""
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
STR_CAT_DISPLAY: "Tela"
STR_CAT_READER: "Leitor"
STR_CAT_CONTROLS: "Controles"
STR_CAT_SYSTEM: "Sistema"
STR_SLEEP_SCREEN: "Tela de repouso"
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
STR_HIDE_BATTERY: "Ocultar % da bateria"
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
STR_TEXT_AA: "Suavização de texto"
STR_TEXT_AA_FULL: "Completa"
STR_TEXT_AA_FAST: "Rápida"
STR_IMAGES: "Imagens"
STR_IMAGES_DISPLAY: "Exibição"
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
STR_IMAGES_SUPPRESS: "Ocultar"
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
STR_ORIENTATION: "Orientação de leitura"
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
STR_TOUCH_READER_CONTROLS: "Controlos táteis do leitor"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
STR_FONT_FAMILY: "Fonte do leitor"
STR_FONT_SIZE: "Tam. fonte UI"
STR_LINE_SPACING: "Espaçamento entre linhas"
@@ -82,6 +89,7 @@ STR_SCREEN_MARGIN: "Margens da tela"
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
STR_HYPHENATION: "Hifenização"
STR_TIME_TO_SLEEP: "Tempo para repousar"
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
STR_REFRESH_FREQ: "Frequência atualização"
@@ -129,6 +137,7 @@ STR_ALWAYS: "Sempre"
STR_IGNORE: "Ignorar"
STR_SLEEP: "Repouso"
STR_PAGE_TURN: "Virar página"
STR_FORCE_REFRESH: "Atualizar tela"
STR_PORTRAIT: "Retrato"
STR_LANDSCAPE_CW: "Paisagem H"
STR_INVERTED: "Invertido"
@@ -136,6 +145,9 @@ STR_ORIENTATION_INVERTED: "Retrato 180°"
STR_LANDSCAPE_CCW: "Paisagem AH"
STR_PREV_NEXT: "Ant/Próx"
STR_NEXT_PREV: "Próx/Ant"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Marcador"
STR_DISABLED: "Desabilitado"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
STR_SMALL: "Pequeno"
@@ -163,17 +175,21 @@ STR_UPDATING: "Atualizando..."
STR_NO_UPDATE: "Nenhuma atualização disponível"
STR_UPDATE_FAILED: "Falha na atualização"
STR_UPDATE_COMPLETE: "Atualização concluída"
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
STR_NO_ENTRIES: "Nenhum entries encontrado"
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
STR_RESTARTING_HINT: "Reiniciando... Se não reiniciar, pressione e segure o botão de energia por alguns segundos."
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
STR_DOWNLOADING: "Baixando..."
STR_DOWNLOAD_FAILED: "Falha no download"
STR_ERROR_MSG: "Erro:"
STR_UNNAMED: "Sem nome"
STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir"
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
STR_NETWORK_PREFIX: "Rede:"
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
STR_NEXT_PAGE: "Próxima Página »"
STR_PREV_PAGE: "« Página Anterior"
STR_NETWORK_PREFIX: "Rede: "
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
@@ -182,10 +198,9 @@ STR_BACK: "« Voltar"
STR_EXIT: "« Sair"
STR_HOME: "« Início"
STR_SELECT: "Escolher"
STR_SELECTED: "Selecionado"
STR_TOGGLE: "Alternar"
STR_TOGGLE_BOOKMARK: "Alternar marcador"
STR_BOOKMARK_REMOVED: "Marcador removido."
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
STR_CONFIRM: "Confirmar"
STR_CANCEL: "Cancelar"
STR_CONNECT: "Conectar"
@@ -194,6 +209,8 @@ STR_DOWNLOAD: "Baixar"
STR_RETRY: "Tentar novamente"
STR_YES: "Sim"
STR_NO: "Não"
STR_SHOW: "Mostrar"
STR_HIDE: "Ocultar"
STR_STATE_ON: "LIG."
STR_STATE_OFF: "DESL."
STR_NOT_SET: "Não definido"
@@ -204,15 +221,51 @@ STR_DIR_DOWN: "Baixo"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
STR_FILTER_CONTRAST: "Contraste"
STR_UI_THEME: "Tema da interface"
STR_CUSTOMISE_STATUS_BAR: "Customizar Barra de Status"
STR_CHAPTER_PAGE_COUNT: "Páginas do Capítulo"
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de Progresso"
STR_PROGRESS_BAR: "Barra de Progresso"
STR_PROGRESS_BAR_THICKNESS: "Espessura da Barra de Progresso"
STR_PROGRESS_BAR_THIN: "Fino"
STR_PROGRESS_BAR_MEDIUM: "Médio"
STR_PROGRESS_BAR_THICK: "Grosso"
STR_BOOK: "Livro"
STR_CHAPTER: "Capítulo"
STR_EXAMPLE_CHAPTER: "Capítulo 21"
STR_EXAMPLE_BOOK: "Título do Livro"
STR_PREVIEW: "Prévia"
STR_TITLE: "Título"
STR_BATTERY: "Bateria"
STR_XTC_STATUS_BAR: "Barra de Status XTC"
STR_BOTTOM: "Baixo"
STR_TOP: "Topo"
STR_CLOCK: "Relógio"
STR_CLOCK_UTC_OFFSET: "Offset UTC do Relógio"
STR_CLOCK_FORMAT: "Formato do Relógio"
STR_CLOCK_FORMAT_24H: "24 horas"
STR_CLOCK_FORMAT_12H: "12 horas"
STR_CURRENT_TIME: "Hora atual:"
STR_NEXT_FIELD: "Próximo"
STR_CLOCK_SYNC: "Sincronizar Relógio"
STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora"
STR_CLOCK_SYNCING: "Sincronizando via NTP..."
STR_CLOCK_SYNC_OK: "Relógio sincronizado"
STR_CLOCK_SYNC_FAIL: "Falha na sincronização"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi não conectado"
STR_CLOCK_SYNC_NO_WIFI_HINT: "Conecte ao Wi-Fi primeiro, depois tente novamente."
STR_CLOCK_SYNCED: "Relógio sincronizado"
STR_UI_THEME: "Tema"
STR_THEME_CLASSIC: "Clássico"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais"
STR_BOOKMARKS: "Marcadores"
STR_BOOKMARK_ADDED: "Marcador adicionado."
STR_BOOKMARK_REMOVED: "Marcador removido."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_SEARCH: "Busca"
STR_COVER_CUSTOM: "Capa + personalizado"
STR_QUICK_RESUME: "Retomada rápida"
STR_MENU_RECENT_BOOKS: "Livros recentes"
@@ -226,11 +279,11 @@ STR_CALIBRE_SETUP: "Configuração"
STR_CALIBRE_STATUS: "Status"
STR_CLEAR_BUTTON: "Limpar"
STR_DEFAULT_VALUE: "Padrão"
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
STR_UNASSIGNED: "Não atribuído"
STR_ALREADY_ASSIGNED: "Já atribuído"
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir layout padrão"
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar remapeamento"
STR_HW_BACK_LABEL: "Voltar (1º botão)"
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
@@ -240,14 +293,15 @@ STR_GO_HOME_BUTTON: "Ir para o início"
STR_SYNC_PROGRESS: "Sincronizar progresso"
STR_DELETE_CACHE: "Excluir cache do livro"
STR_DELETE: "Excluir"
STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?"
STR_DISPLAY_QR: "Mostrar página como QR"
STR_CHAPTER_PREFIX: "Capítulo:"
STR_PAGES_SEPARATOR: "páginas |"
STR_BOOK_PREFIX: "Livro:"
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
STR_SYNCING_TIME: "Sincronizando horário..."
STR_CALC_HASH: "Calculando hash documento..."
STR_HASH_FAILED: "Falha ao calcular o hash documento"
STR_CALC_HASH: "Calculando hash do documento..."
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
STR_UPLOAD_PROGRESS: "Enviando progresso..."
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
@@ -265,14 +319,69 @@ STR_UPLOAD_PROMPT: "Enviar posição atual?"
STR_UPLOAD_SUCCESS: "Progresso enviado!"
STR_SYNC_FAILED_MSG: "Falha na sincronização"
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
STR_SECTION_PREFIX: "Seção"
STR_SECTION_PREFIX: "Seção "
STR_UPLOAD: "Enviar"
STR_BOOK_S_STYLE: "Estilo do livro"
STR_EMBEDDED_STYLE: "Estilo embutido"
STR_FOCUS_READING: "Leitura focada"
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido de notas de rodapé"
STR_SET_SLEEP_COVER: "Definir capa"
STR_FOOTNOTES: "Notas de rodapé"
STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Capturar tela"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nunca"
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
STR_STEP_HINT_FRONT: "Botões frontais:"
STR_STEP_HINT_SIDE: "Botões laterais:"
STR_ADD_SERVER: "Adicionar Servidor"
STR_SERVER_NAME: "Nome do Servidor"
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
STR_DELETE_SERVER: "Excluir Servidor"
STR_OPDS_SERVERS: "Servidores OPDS"
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
STR_MANAGE_FONTS: "Gerenciar fontes"
STR_FONT_BROWSER: "Navegador de fontes"
STR_LOADING_FONT_LIST: "Carregando lista de fontes..."
STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível"
STR_FONT_INSTALLED: "Fonte instalada!"
STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte"
STR_INSTALLED: "Instalada"
STR_DOWNLOAD_ALL: "Baixar tudo"
STR_UPDATE_ALL: "Atualizar tudo"
STR_UPDATE_AVAILABLE: "Atualizar"
STR_CRASH_TITLE: "Falha do sistema"
STR_CRASH_DESCRIPTION: "Um relatório detalhado foi salvo em crash_report.txt. Por favor, inclua este arquivo no seu relatório de erro."
STR_CRASH_REASON: "Motivo da falha:"
STR_CRASH_NO_REASON: "(Nenhum motivo registrado)"
STR_TILT_PAGE_TURN: "Virar página por inclinação"
STR_KB_HINT_MOVE_CURSOR: "Pressione ESQ ou DIR para mover o cursor"
STR_KB_HINT_RETURN_CURSOR: "Pressione ESQ para retornar à posição do cursor"
STR_KB_HINT_HIDE_PASSWORD: "Segure DIR e pressione [***] para ocultar a senha"
STR_KB_HINT_SHOW_PASSWORD: "Segure DIR e pressione [abc] para exibir a senha"
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pressione [***] para ocultar a senha"
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pressione [abc] para exibir a senha"
STR_KB_HINT_EDIT_ENTRY: "Segure CIMA para editar a entrada"
STR_KB_TIPS: "Dicas:"
STR_KB_HINT_RETURN_KEYBOARD: "Pressione BAIXO para retornar ao teclado"
STR_KB_HINT_EXIT_URL_MODE: "Pressione ABC para sair do modo URL"
STR_KB_HINT_CLEAR_TEXT: "Segure DEL para limpar todo o texto"
STR_KB_HINT_SECONDARY_CHAR: "Segure SELECT para caractere secundário"
STR_KB_HINT_UPPER_SECONDARY: "Segure SELECT para MAIÚSCULO ou caractere secundário"
STR_KB_HINT_LOWER_SECONDARY: "Segure SELECT para minúsculo ou caractere secundário"
STR_KB_HINT_URL_SNIPPETS: "Pressione URL para atalhos"
STR_SD_FIRMWARE_UPDATE: "Atualização de firmware via cartão SD"
STR_SELECT_FIRMWARE_FILE: "Selecione o arquivo de firmware (.bin)"
STR_NO_BIN_FILES: "Nenhum arquivo .bin encontrado"
STR_VALIDATING_FIRMWARE: "Validando firmware..."
STR_INVALID_FIRMWARE: "Arquivo de firmware inválido"
STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição"
STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais"
STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?"
STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo"
STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
STR_RECOVERY_MODE: "Modo de Recuperação"
STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Suprimare"
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
STR_ORIENTATION: "Orientare lectură"
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
STR_TOUCH_READER_CONTROLS: "Control tactil (lectură)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientare butoane frontale"
STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
@@ -267,7 +268,6 @@ STR_CHAPTER_PREFIX: "Capitol: "
STR_PAGES_SEPARATOR: " pagini | "
STR_BOOK_PREFIX: "Carte: "
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
STR_SYNCING_TIME: "Timp de sincronizare..."
STR_CALC_HASH: "Calcularea hash-ului documentului..."
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
@@ -299,7 +299,8 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Niciodată"
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
STR_STEP_HINT_FRONT: "Butoane frontale:"
STR_STEP_HINT_SIDE: "Butoane laterale:"
STR_SCREENSHOT_BUTTON: "Captură ecran"
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
+4 -3
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: "Заглушки"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "Скрыть"
STR_SHORT_PWR_BTN: "Короткое нажатие PWR"
STR_ORIENTATION: "Ориентация чтения"
STR_SIDE_BTN_LAYOUT: "Боковые кнопки"
STR_TOUCH_READER_CONTROLS: "Сенсорное управление чтением"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ориентировать передние кнопки"
STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
@@ -299,7 +300,6 @@ STR_CHAPTER_PREFIX: "Глава: "
STR_PAGES_SEPARATOR: " стр. | "
STR_BOOK_PREFIX: "Книга: "
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%"
STR_SYNCING_TIME: "Синхронизация времени..."
STR_CALC_HASH: "Расчёт хэша документа..."
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
@@ -333,7 +333,8 @@ STR_LINK: "[ссылка]"
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
STR_SLEEP_NEVER: "Никогда"
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
STR_STEP_HINT_FRONT: "Передние кнопки:"
STR_STEP_HINT_SIDE: "Боковые кнопки:"
STR_ADD_SERVER: "Добавить сервер"
STR_SERVER_NAME: "Имя сервера"
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
+4 -3
View File
@@ -66,6 +66,8 @@ 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"
@@ -73,7 +75,6 @@ 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_TOUCH_READER_CONTROLS: "Dotykové ovládanie čítačky"
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"
@@ -295,7 +296,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
STR_PAGES_SEPARATOR: "strán |"
STR_BOOK_PREFIX: "Kniha:"
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
STR_PERCENT_STEP_HINT: "Vľavo/Vpravo: 1 % Hore/Dole: 10 %"
STR_SYNCING_TIME: "Čas synchronizácie..."
STR_CALC_HASH: "Výpočet hashu dokumentu..."
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
@@ -329,7 +329,8 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
STR_ADD_SERVER: "Pridať server"
STR_SERVER_NAME: "Názov servera"
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Zatdi"
STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop"
STR_ORIENTATION: "Orientacija branja"
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
STR_TOUCH_READER_CONTROLS: "Dotikalni nadzor (bralnik)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
@@ -264,7 +265,6 @@ STR_CHAPTER_PREFIX: "Poglavje: "
STR_PAGES_SEPARATOR: " strani | "
STR_BOOK_PREFIX: "Knjiga: "
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%"
STR_SYNCING_TIME: "Sinhronizacija časa..."
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
@@ -296,7 +296,8 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
STR_LINK: "[povezava]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikoli"
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
STR_STEP_HINT_SIDE: "Stranski gumbi:"
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
+4 -3
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"
@@ -72,7 +74,6 @@ STR_IMAGES_SUPPRESS: "Ocultar"
STR_SHORT_PWR_BTN: "Toque corto del encendido"
STR_ORIENTATION: "Orientación"
STR_SIDE_BTN_LAYOUT: "Función botones laterales (lector)"
STR_TOUCH_READER_CONTROLS: "Controles táctiles (lector)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botones frontales"
STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
@@ -298,7 +299,6 @@ STR_CHAPTER_PREFIX: "Cap.: "
STR_PAGES_SEPARATOR: " pág. | "
STR_BOOK_PREFIX: "Libro: "
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
STR_PERCENT_STEP_HINT: "Izq./Dcha.: 1% | Subir/Bajar: 10%"
STR_SYNCING_TIME: "Tiempo de sincronización..."
STR_CALC_HASH: "Calculando hash del documento..."
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
@@ -332,7 +332,8 @@ STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
STR_LINK: "[enlace]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
STR_SLEEP_NEVER: "Nunca"
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
STR_STEP_HINT_FRONT: "Botones frontales:"
STR_STEP_HINT_SIDE: "Botones laterales:"
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
STR_ADD_SERVER: "Agregar servidor"
STR_SERVER_NAME: "Nombre de servidor"
+4 -3
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"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "Dölj"
STR_SHORT_PWR_BTN: "Kort strömknappsklick"
STR_ORIENTATION: "Läsrikting"
STR_SIDE_BTN_LAYOUT: "Sidoknappslayout (Läsare)"
STR_TOUCH_READER_CONTROLS: "Pekkontroller (läsare)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Rikta främre knappar"
STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
@@ -296,7 +297,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
STR_PAGES_SEPARATOR: " sidor | "
STR_BOOK_PREFIX: "Bok:"
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
STR_PERCENT_STEP_HINT: "Vänster/Höger: 1% Upp/Ner 10%"
STR_SYNCING_TIME: "Synkroniserar tid…"
STR_CALC_HASH: "Beräknar dokumenthash"
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
@@ -329,7 +329,8 @@ STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
STR_LINK: "[länk]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Aldrig"
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
STR_STEP_HINT_FRONT: "Framknappar:"
STR_STEP_HINT_SIDE: "Sidoknappar:"
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
STR_ADD_SERVER: "Lägg till server"
STR_SERVER_NAME: "Servernamn"
+4 -3
View File
@@ -64,10 +64,11 @@ 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)"
STR_TOUCH_READER_CONTROLS: "Dokunmatik okuyucu kontrolleri"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ön düğmeleri yönlendir"
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
@@ -242,7 +243,6 @@ STR_CHAPTER_PREFIX: "Bölüm: "
STR_PAGES_SEPARATOR: " sayfa | "
STR_BOOK_PREFIX: "Kitap: "
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
STR_PERCENT_STEP_HINT: "Sol/Sağ: %1 Yukarı/Aşağı: %10"
STR_SYNCING_TIME: "Zaman senkronize ediliyor..."
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
@@ -290,7 +290,8 @@ STR_IMAGES_SUPPRESS: "Bastır"
STR_LINK: "[bağlantı]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
STR_SLEEP_NEVER: "Asla"
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
STR_STEP_HINT_FRONT: "Ön tuşlar:"
STR_STEP_HINT_SIDE: "Yan tuşlar:"
STR_NO_FILES_FOUND: "Dosya bulunamadı"
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
STR_PREVIEW: "Önizleme"
+4 -3
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: "Заглушка"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "Приховати"
STR_SHORT_PWR_BTN: "Короткий натиск кн. живл."
STR_ORIENTATION: "Орієнтація читання"
STR_SIDE_BTN_LAYOUT: "Схема бічних кнопок"
STR_TOUCH_READER_CONTROLS: "Сенсорне керування читанням"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Орієнтувати передні кнопки"
STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настику"
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
@@ -295,7 +296,6 @@ STR_CHAPTER_PREFIX: "Розділ: "
STR_PAGES_SEPARATOR: " сторінок | "
STR_BOOK_PREFIX: "Книга: "
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
STR_SYNCING_TIME: "Синхронізація часу..."
STR_CALC_HASH: "Обчислення хешу документа..."
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
@@ -330,7 +330,8 @@ STR_LINK: "[посилання]"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
STR_SLEEP_NEVER: "Ніколи"
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
STR_STEP_HINT_FRONT: "Передні кнопки:"
STR_STEP_HINT_SIDE: "Бічні кнопки:"
STR_ADD_SERVER: "Додати сервер"
STR_SERVER_NAME: "Назва сервера"
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
+4 -3
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"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "Eliminar"
STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada"
STR_ORIENTATION: "Orientació de lectura"
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
@@ -90,7 +91,8 @@ STR_HYPHENATION: "Partició de mots"
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Mai"
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
STR_STEP_HINT_FRONT: "Botons frontals:"
STR_STEP_HINT_SIDE: "Botons laterals:"
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
@@ -277,7 +279,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
STR_PAGES_SEPARATOR: " pàgines | "
STR_BOOK_PREFIX: "Llibre: "
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
STR_CALC_HASH: "S'està calculant el hash del document..."
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
+4 -3
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ế"
@@ -73,7 +75,6 @@ STR_IMAGES_SUPPRESS: "Ẩn đi"
STR_SHORT_PWR_BTN: "Nhấn nhanh nút nguồn"
STR_ORIENTATION: "Hướng đọc"
STR_SIDE_BTN_LAYOUT: "Bố trí nút bên (trình đọc)"
STR_TOUCH_READER_CONTROLS: "Điều khiển cảm ứng (trình đọc)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Xoay nút trước theo hướng"
STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
@@ -295,7 +296,6 @@ STR_CHAPTER_PREFIX: "Chương: "
STR_PAGES_SEPARATOR: " trang | "
STR_BOOK_PREFIX: "Sách: "
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
STR_PERCENT_STEP_HINT: "Trái/Phải: 1% Lên/Xuống: 10%"
STR_SYNCING_TIME: "Đang đồng bộ giờ..."
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
@@ -329,7 +329,8 @@ STR_LINK: "[liên kết]"
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
STR_SLEEP_NEVER: "Không bao giờ"
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
STR_STEP_HINT_FRONT: "Nút trước:"
STR_STEP_HINT_SIDE: "Nút bên:"
STR_ADD_SERVER: "Thêm máy chủ"
STR_SERVER_NAME: "Tên máy chủ"
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
+10 -5
View File
@@ -278,13 +278,18 @@ class XPathParagraphResolver final : public Print {
path.push_back({name, siblingIndex});
parentStates.emplace_back();
// Count both <p> and <li> as paragraph-like positions, matching how the section
// layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures
// KOReader progress in list items maps to the correct XPath.
if (name == "p") {
paragraphCount++;
if (paragraphCount == targetParagraph) {
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
stopped = true;
XML_StopParser(parser, XML_FALSE);
}
} else if (name == "li") {
paragraphCount++;
}
if (paragraphCount == targetParagraph) {
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
stopped = true;
XML_StopParser(parser, XML_FALSE);
}
depth++;
+5 -4
View File
@@ -709,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
float intra =
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
// Progress-based XPath correctly handles both <p> and <li> positions.
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
// Fall back to paragraph-index lookup when progress-based resolution fails.
if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) {
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
}
// Fall back to progress-based XPath, then synthetic progress mapping.
if (result.xpath.empty()) {
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
}
if (result.xpath.empty()) {
result.xpath = generateXPath(epub, pos.spineIndex, intra);
}
-13
View File
@@ -1,8 +1,5 @@
#include "Logging.h"
#include <BoardConfig.h>
#include <esp_rom_sys.h>
#include <string>
#define MAX_ENTRY_LEN 256
@@ -62,19 +59,9 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
}
}
va_end(args);
#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_USB_CDC_WRITE
// Native USB CDC can report false while PlatformIO monitor is attached on
// boards like LilyGo T5 S3, so write directly to the CDC object.
logSerial.write(reinterpret_cast<const uint8_t*>(buf), strnlen(buf, sizeof(buf)));
#elif FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
// IDF/ROM console path for boards whose monitor is attached there during
// bring-up, e.g. Sticky.
esp_rom_printf("%s", buf);
#else
if (logSerial) {
logSerial.print(buf);
}
#endif
addToLogRingBuffer(buf);
}
+1 -4
View File
@@ -27,10 +27,7 @@ won't trigger deprecation warnings.
#define LOG_LEVEL 0
#endif
// The concrete Serial type differs by MCU: HWCDC (native USB CDC on C3/S3) vs
// HardwareSerial (UART0 on the classic ESP32 / M5Paper). Bind to whatever the
// real Serial is here — this is before the `#define Serial` shim below.
static decltype(Serial)& logSerial = Serial;
static HWCDC& logSerial = Serial;
void logPrintf(const char* level, const char* origin, const char* format, ...);
+5 -4
View File
@@ -10,7 +10,11 @@ OpdsParser::OpdsParser() {
if (!parser) {
errorOccured = true;
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
return;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
}
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
@@ -20,10 +24,6 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
if (errorOccured) return length;
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
const char* currentPos = reinterpret_cast<const char*>(xmlData);
size_t remaining = length;
constexpr size_t chunkSize = 1024;
@@ -54,6 +54,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
}
void OpdsParser::flush() {
if (errorOccured || !parser) return;
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
errorOccured = true;
destroyXmlParser(parser);
+1 -1
View File
@@ -42,7 +42,7 @@ std::string Txt::getTitle() const {
// Remove .txt extension
if (FsHelpers::hasTxtExtension(filename)) {
filename = filename.substr(0, filename.length() - 4);
filename.resize(filename.length() - 4);
}
return filename;
+1 -62
View File
@@ -18,11 +18,8 @@ static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F);
static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
void HalClock::begin() {
_usesSdkRtc = false;
if (!gpio.deviceIsX3()) {
_available = _sdkRtc.begin();
_usesSdkRtc = _available;
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
_available = false;
return;
}
@@ -60,24 +57,6 @@ bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
return true;
}
if (_usesSdkRtc) {
Rtc::DateTime dt;
if (!_sdkRtc.now(dt)) {
if (!_hasCachedTime) return false;
_lastPollMs = now;
hour = _cachedHour;
minute = _cachedMinute;
return true;
}
_cachedHour = dt.hour;
_cachedMinute = dt.minute;
_lastPollMs = now;
_hasCachedTime = true;
hour = _cachedHour;
minute = _cachedMinute;
return true;
}
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
Wire.beginTransmission(I2C_ADDR_DS3231);
Wire.write(DS3231_SEC_REG);
@@ -152,25 +131,6 @@ bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
assert(hour < 24);
assert(minute < 60);
assert(second < 60);
if (_usesSdkRtc) {
Rtc::DateTime dt;
dt.hour = hour;
dt.minute = minute;
dt.second = second;
dt.year = 2000;
dt.month = 1;
dt.day = 1;
if (!_sdkRtc.set(dt)) {
LOG_ERR("CLK", "Failed to write time to SDK RTC");
return false;
}
_lastPollMs = 0;
_cachedHour = hour;
_cachedMinute = minute;
_hasCachedTime = true;
return true;
}
Wire.beginTransmission(I2C_ADDR_DS3231);
Wire.write(DS3231_SEC_REG); // Start at register 0x00
Wire.write(decToBcd(second)); // 0x00: Seconds
@@ -208,27 +168,6 @@ bool HalClock::syncFromNTP() {
struct tm timeinfo;
gmtime_r(&now, &timeinfo);
if (_usesSdkRtc) {
Rtc::DateTime dt;
dt.year = static_cast<uint16_t>(timeinfo.tm_year + 1900);
dt.month = static_cast<uint8_t>(timeinfo.tm_mon + 1);
dt.day = static_cast<uint8_t>(timeinfo.tm_mday);
dt.hour = static_cast<uint8_t>(timeinfo.tm_hour);
dt.minute = static_cast<uint8_t>(timeinfo.tm_min);
dt.second = static_cast<uint8_t>(timeinfo.tm_sec);
dt.weekday = static_cast<uint8_t>(timeinfo.tm_wday);
if (_sdkRtc.set(dt)) {
_lastPollMs = 0;
_cachedHour = dt.hour;
_cachedMinute = dt.minute;
_hasCachedTime = true;
LOG_INF("CLK", "RTC set to %04u-%02u-%02u %02u:%02u:%02u UTC", dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second);
return true;
}
return false;
}
if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) {
LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
return true;
-3
View File
@@ -1,7 +1,6 @@
#pragma once
#include <Arduino.h>
#include <Rtc.h>
#include <Wire.h>
#include "HalGPIO.h"
@@ -11,8 +10,6 @@ extern HalClock halClock; // Singleton
class HalClock {
bool _available = false;
bool _usesSdkRtc = false;
mutable Rtc _sdkRtc;
mutable uint8_t _cachedHour = 0;
mutable uint8_t _cachedMinute = 0;
mutable bool _hasCachedTime = false;
+17 -79
View File
@@ -1,6 +1,5 @@
#include <HalGPIO.h>
#include <Logging.h>
#include <PowerManager.h>
#include <Preferences.h>
#include <SPI.h>
#include <Wire.h>
@@ -193,44 +192,14 @@ HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
void HalGPIO::begin() {
inputMgr.begin();
#if FREEINK_MCU_C3
// Claim the shared SPI bus with the X4/X3 display+SD pins. These EPD_* pin
// macros are hardcoded C3/Xteink values, so this pre-claim is only valid on the
// C3 family. On other boards (M5Paper's IT8951, Sticky's SSD1677, ...) the SDK
// driver and SDCardManager bring up SPI from BoardConfig::ACTIVE pins; pre-
// claiming here would stick (SPIClass::begin early-returns once the bus is
// started) and leave the display/SD on the wrong pins.
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
#endif
#if FREEINK_MCU_C3
_deviceType = detectDeviceTypeWithFingerprint();
// Sync the runtime board profile to the detected device NOW, before any I2C
// consumer begins. powerManager/clock/tilt begin() run immediately after this
// in setup() and rely on BoardConfig::ACTIVE: HalPowerManager only calls
// Wire.begin() when the active profile has an I2C gauge (gaugeAddr != 0), and
// the X3 clock/tilt raw-Wire paths assume that begin happened. The dual X3+X4
// binary boots as X4 (gaugeAddr 0), so without this the X4 profile stays active
// through those begins, Wire is never (re)initialised after the detection probe
// end()s it, and every X3 read fails with lock==NULL ("could not acquire lock"
// / "NULL TX buffer pointer"). FreeInkDisplay also calls selectDevice() later,
// but that is after the I2C consumers — too late. selectDevice() is idempotent.
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
if (deviceIsX4()) {
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
#else
// Non-C3 boards (S3/ESP32) are single-device builds; their pins, panel, and
// battery backend all come from BoardConfig::ACTIVE. The X3/X4 fingerprint probe
// targets Xteink C3 hardware and drives Wire on X3_I2C_SDA/SCL (GPIO20/0) — which
// collides with this board's own I2C (e.g. Sticky's BQ27220 gauge shares GPIO0)
// and reconfigures strapping pins. Skip it; _deviceType stays X4 ("not X3"),
// the correct non-X3 branch for every deviceIsX3() consumer.
_deviceType = DeviceType::X4;
#endif
}
void HalGPIO::update() {
@@ -256,55 +225,30 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); }
bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); }
bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const {
return inputMgr.isTouchTapCandidate(nx, ny, heldMs);
}
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
}
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
bool HalGPIO::isXteinkDevice() const {
const auto board = BoardConfig::ACTIVE.board;
return board == BoardConfig::Board::XteinkX3 || board == BoardConfig::Board::XteinkX4;
}
void HalGPIO::startDeepSleep() {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
freeink::PowerManager::armPowerButtonWakeup();
freeink::PowerManager::deepSleep();
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
if (BoardConfig::ACTIVE.input.power < 0) {
// No readable power-button input pin: can't verify a hold, so don't sleep.
return;
}
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
// M5Paper: power-on is a hardware latch and the "power button" is the rotary
// push (G38), shared with Confirm. A USB/flash cold boot is indistinguishable
// from an intentional power-on hold here, so skip the X4-style anti-accidental-
// wake check and always boot. G38 still serves as the deep-sleep wake source.
return;
#endif
if (shortPressAllowed) {
// Fast path - no duration check needed
return;
}
// TODO: Intermittent edge case remains: a single tap followed by another single tap
// can still power on the device. Tighten wake debounce/state handling here.
// Calibrate: subtract boot time already elapsed, assuming button held since boot
const uint16_t calibration = millis();
const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
const auto start = millis();
inputMgr.update();
// inputMgr.isPressed() may take up to ~500ms to return correct state
@@ -313,12 +257,11 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
inputMgr.update();
}
if (inputMgr.isPressed(BTN_POWER)) {
const auto holdStart = millis();
do {
delay(10);
inputMgr.update();
} while (inputMgr.isPressed(BTN_POWER) && millis() - holdStart < requiredDurationMs);
if (millis() - holdStart < requiredDurationMs) {
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
startDeepSleep();
}
} else {
@@ -339,10 +282,8 @@ bool HalGPIO::isUsbConnected() const {
}
return false;
}
if (BoardConfig::ACTIVE.usbDetect < 0) {
return false;
}
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;
}
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
@@ -351,11 +292,8 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
if (resetReason == ESP_RST_DEEPSLEEP &&
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) {
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
-33
View File
@@ -59,11 +59,6 @@ class HalGPIO {
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
// True on the Xteink X3/X4 boards. Unlike deviceIsX3/X4 (which both stay "X4"
// on non-C3 boards), this keys off BoardConfig::ACTIVE.board, so it reliably
// gates Xteink-only features (sunlight fix) and non-Xteink-only ones (touch).
bool isXteinkDevice() const;
// Start button GPIO and setup SPI for screen and SD card
void begin();
@@ -77,34 +72,6 @@ class HalGPIO {
unsigned long getHeldTime() const;
unsigned long getPowerButtonHeldTime() const;
// Touch: one-shot tap with the release position normalized 0..1 in the panel's
// native orientation. Returns false on non-touch devices. (Reusable gesture
// primitive; see MappedInputManager for the top-left = Back mapping.)
bool wasTouchTap(float& nx, float& ny) const;
// Press-edge of a touch: true on touch-down with the down position normalized
// 0..1 (panel native). For showing the pressed/selected element before release.
bool wasTouchDown(float& nx, float& ny) const;
// True while a touch remains within tap slop; writes touch-down position and held time.
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
// Duration (ms) of the last touch contact, latched on release. Valid on the
// release frame (alongside wasTouchTap). For tap-vs-long-press decisions.
unsigned long lastTouchHeldMs() const;
// Swipe (flick) gesture on the release frame: writes the start/end positions
// normalized 0..1 in the panel's native frame (map via GfxRenderer::tapToLogical).
// A swipe also raises wasTouchTap(), so check this first. False on non-touch.
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
// True if a touch controller is present/active (runtime gate; false on the C3).
bool hasTouch() const;
// True if a touch press or release happened this frame (the touch analogue of
// wasAnyPressed/Released), for resetting idle/sleep timers and CPU frequency.
bool wasTouchActivity() const;
// Setup wake up GPIO and enter deep sleep
void startDeepSleep();
+25 -45
View File
@@ -1,7 +1,6 @@
#include "HalPowerManager.h"
#include <Logging.h>
#include <PowerManager.h>
#include <WiFi.h>
#include <esp_sleep.h>
@@ -11,32 +10,15 @@
HalPowerManager powerManager; // Singleton instance
namespace {
// The fuel gauge's I2C controller (Wire or Wire1) per the active board profile.
// On single-bus SoCs (ESP32-C3, SOC_I2C_NUM == 1) Wire1 doesn't exist, so always
// use Wire. On Sticky the gauge is on Wire1 so it doesn't fight the GT911 touch,
// which owns Wire.
TwoWire& gaugeWire() {
#if SOC_I2C_NUM > 1
if (BoardConfig::ACTIVE.batteryGauge.i2cBus == 1) return Wire1;
#endif
return Wire;
}
} // namespace
void HalPowerManager::begin() {
const auto& gauge = BoardConfig::ACTIVE.batteryGauge;
if (gauge.gaugeAddr != 0) {
// Board has an I2C fuel gauge (X3, LilyGo, Sticky, ...). Pins/freq come from
// the active board profile, not hardcoded X3 values. I2C init must come AFTER
// gpio.begin() so early hardware detection/probes are finished.
gaugeWire().begin(gauge.i2cSda, gauge.i2cScl, gauge.i2cHz);
gaugeWire().setTimeOut(4);
if (gpio.deviceIsX3()) {
// X3 uses an I2C fuel gauge for battery monitoring.
// I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished.
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
Wire.setTimeOut(4);
_batteryUseI2C = true;
} else if (BoardConfig::ACTIVE.batteryAdc >= 0) {
// ADC-sensed board (X4: GPIO0, M5Paper: GPIO35). Skip when the profile leaves
// batteryAdc unassigned (PIN_UNASSIGNED) — pinMode(255) faults the GPIO mux.
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
} else {
pinMode(BAT_GPIO0, INPUT);
}
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
@@ -94,19 +76,22 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
#endif
// Pre-sleep routines from the original firmware
#if !SOC_PM_SUPPORT_EXT1_WAKEUP // RISC-V (C3 / X4)
// GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep
// Note that this means the MCU will be completely powered off during sleep, including RTC
// (X4-specific: on classic ESP32/M5Paper GPIO13 is SD MISO, so this is skipped.)
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
gpio_set_level(GPIO_SPIWP, 0);
esp_sleep_config_gpio_isolate();
gpio_deep_sleep_hold_en();
gpio_hold_en(GPIO_SPIWP);
#endif
freeink::PowerManager::armPowerButtonWakeup();
freeink::PowerManager::deepSleep();
pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP);
// Arm the wakeup trigger *after* the button is released
// Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the
// power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source
// configuration
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
uint16_t HalPowerManager::getBatteryPercentage() const {
@@ -116,32 +101,27 @@ uint16_t HalPowerManager::getBatteryPercentage() const {
return _batteryCachedPercent;
}
// Read SOC directly from the I2C fuel gauge (16-bit LE register). Gauge
// address comes from the active board profile (all current gauges are
// BQ27220-class, SOC at 0x2C). On I2C error, keep last known value to avoid
// UI jitter/slowdowns.
const uint8_t gaugeAddr = BoardConfig::ACTIVE.batteryGauge.gaugeAddr;
TwoWire& w = gaugeWire();
w.beginTransmission(gaugeAddr);
w.write(BQ27220_SOC_REG);
if (w.endTransmission(false) != 0) {
// Read SOC directly from I2C fuel gauge (16-bit LE register).
// On I2C error, keep last known value to avoid UI jitter/slowdowns.
Wire.beginTransmission(I2C_ADDR_BQ27220);
Wire.write(BQ27220_SOC_REG);
if (Wire.endTransmission(false) != 0) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
w.requestFrom(gaugeAddr, (uint8_t)2);
if (w.available() < 2) {
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
if (Wire.available() < 2) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
const uint8_t lo = w.read();
const uint8_t hi = w.read();
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
const uint16_t soc = (hi << 8) | lo;
_batteryCachedPercent = soc > 100 ? 100 : soc;
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
// ADC pin from the active profile (X4 GPIO0 / M5Paper GPIO35); default 2:1 divider.
static const BatteryMonitor battery = BatteryMonitor(BoardConfig::ACTIVE.batteryAdc);
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
// smooth the battery %.
if (_batteryCachedPercent == 0) {
+1 -10
View File
@@ -28,16 +28,7 @@ class HalPowerManager {
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
public:
// On PSRAM boards (classic ESP32 / M5Paper) the APB clock follows the CPU below
// 80 MHz, and APB clocks both the SD SPI bus and the 80 MHz PSRAM (which holds
// the framebuffer). Dropping to 10 MHz breaks SD writes and PSRAM, so floor the
// low-power CPU at 80 MHz (APB stays pinned at 80 for CPU 80/160/240, so power
// still drops from the 240 MHz default while SD/PSRAM stay stable).
#if defined(BOARD_HAS_PSRAM)
static constexpr int LOW_POWER_FREQ = 80; // MHz (APB-safe floor for PSRAM/SD)
#else
static constexpr int LOW_POWER_FREQ = 10; // MHz
#endif
static constexpr int LOW_POWER_FREQ = 10; // MHz
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
-8
View File
@@ -38,13 +38,6 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
__real_panic_print_backtrace(frame, core);
return;
}
#if !__riscv
// RvExcFrame and the flat SP scan below are RISC-V-only (C3). On Xtensa
// (classic ESP32 / S3) the exception frame is XtExcFrame with windowed-register
// unwinding, so fall back to the default backtrace.
__real_panic_print_backtrace(frame, core);
return;
#else
for (size_t i = 0; i < MAX_PANIC_STACK_DEPTH; i++) {
panicStack[i].sp = 0;
}
@@ -72,7 +65,6 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
}
__real_panic_print_backtrace(frame, core);
#endif // __riscv
}
}
+1 -36
View File
@@ -26,15 +26,6 @@ bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const {
}
bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
if (_backend == Backend::SdkImu) {
Imu::Sample sample;
if (!_sdkImu.read(sample)) return false;
gx = sample.gx;
gy = sample.gy;
gz = sample.gz;
return true;
}
Wire.beginTransmission(_i2cAddr);
Wire.write(REG_GX_L); // Start reading at Gyro X Low
if (Wire.endTransmission(false) != 0) {
@@ -61,22 +52,11 @@ bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
}
void HalTiltSensor::begin() {
_backend = Backend::None;
if (!gpio.deviceIsX3()) {
_available = _sdkImu.begin();
if (!_available) {
LOG_ERR("GYR", "SDK IMU not found");
return;
}
_backend = Backend::SdkImu;
_initMs = millis();
_lastPollMs = millis();
LOG_INF("GYR", "SDK IMU initialized");
_available = false;
return;
}
_backend = Backend::Qmi8658;
// Try primary address, then alternate
uint8_t whoami = 0;
_i2cAddr = I2C_ADDR_QMI8658;
@@ -109,14 +89,6 @@ bool HalTiltSensor::wake() {
return false;
}
if (_backend == Backend::SdkImu) {
_lastPollMs = millis();
_lastTiltMs = millis();
_wakeMs = millis();
_isAwake = true;
return true;
}
// Wait for init to complete before waking
if ((millis() - _initMs) < SLEEP_STABILIZE_MS) {
return false;
@@ -139,13 +111,6 @@ bool HalTiltSensor::deepSleep() {
return false;
}
if (_backend == Backend::SdkImu) {
clearPendingEvents();
_inTilt = false;
_isAwake = false;
return true;
}
if ((millis() - _wakeMs) < SLEEP_STABILIZE_MS) {
return false;
}
-5
View File
@@ -1,7 +1,6 @@
#pragma once
#include <Arduino.h>
#include <Imu.h>
#include <Wire.h>
#include "HalGPIO.h"
@@ -19,12 +18,8 @@ class HalTiltSensor;
extern HalTiltSensor halTiltSensor; // Singleton
class HalTiltSensor {
enum class Backend : uint8_t { None, Qmi8658, SdkImu };
bool _available = false;
Backend _backend = Backend::None;
uint8_t _i2cAddr = 0;
mutable Imu _sdkImu;
// Tilt gesture state machine
bool _tiltForwardEvent = false; // Consumed by wasTiltedForward()
+10 -78
View File
@@ -35,6 +35,8 @@ build_flags =
# Increase PNG scanline buffer to support up to 2048px wide images
# Default is (320*4+1)*2=2562, we need more for larger images
-DPNG_MAX_BUFFERED_PIXELS=16416
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-Wno-bidi-chars
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
-fno-exceptions
@@ -55,10 +57,7 @@ extra_scripts =
pre:scripts/patch_jpegdec.py
post:scripts/register_unit_tests_target.py
; Libraries — FreeInk SDK (git submodule at ./freeink-sdk). The left-hand names
; are the include paths the firmware already uses (<EInkDisplay.h>, etc.); FreeInk's
; compat shim preserves them. BoardConfig + PowerManager are FreeInk-only and
; required (device profiles + the SoC-correct deep-sleep wakeup the HAL relies on).
; Libraries
lib_deps =
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
@@ -66,8 +65,6 @@ lib_deps =
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
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
bblanchon/ArduinoJson @ 7.4.2
@@ -76,100 +73,35 @@ lib_deps =
https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b
links2004/WebSockets @ 2.7.3
; ESP32-C3 device set: X3 + X4 compiled into one binary, runtime-selected after
; the I2C fingerprint. FreeInk requires an explicit device selection, so the C3
; envs extend this instead of [base] directly. (M5Paper is a different MCU family
; and selects its own device below.)
[c3]
[env:default]
extends = base
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_X3=1
-DFREEINK_DEVICE_X4=1
[env:default]
extends = c3
build_flags =
${c3.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
[env:gh_release]
extends = c3
extends = base
build_flags =
${c3.build_flags}
${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release builds
[env:gh_release_rc]
extends = c3
extends = base
build_flags =
${c3.build_flags}
${base.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
[env:slim]
extends = c3
extends = base
build_flags =
${c3.build_flags}
${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
; serial output is disabled in slim builds to save space
-UENABLE_SERIAL_LOG
; --- LilyGo T5 S3 (ESP32-S3 N16R8, 4.7" ED047TC1 / raw-parallel EPD) ---------
; Uses FreeInk's LilyGo board-support library for the PCA9535/TPS65185 display
; power hooks and expander button.
; pio run -e lilygo_t5s3 -t upload --upload-port /dev/tty.usbmodemXXXX
[env:lilygo_t5s3]
extends = base
board = esp32-s3-devkitc1-n16r8
board_build.mcu = esp32s3
board_build.flash_mode = qio
board_build.arduino.memory_type = qio_opi
build_flags =
${base.build_flags}
-DBOARD_HAS_PSRAM
-DFREEINK_DEVICE_LILYGO=1
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo_t5s3\"
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
-DTOUCH_PROBE_DEBUG=1
lib_deps =
${base.lib_deps}
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
m5stack/M5GFX @ 0.2.20
; --- M5Paper v1.1 (classic ESP32-D0WDQ6, 4.7" 540x960 / IT8951E) -------------
; Different MCU family than the C3 base: override board/mcu/flash, disable the
; C3-only native-USB CDC (logs over UART0), and select the M5Paper device.
; pio run -e m5paper_v11 -t upload --upload-port /dev/tty.usbserial-XXXX
[env:m5paper_v11]
extends = base
board = esp32dev
board_build.mcu = esp32
board_build.flash_mode = qio
; CP2104 bridge: 460800 is ~4x the 115200 fallback and reliable; drop if a flash
; stalls. (921600 usually works too.)
upload_speed = 460800
build_flags =
${base.build_flags}
-UARDUINO_USB_MODE
-UARDUINO_USB_CDC_ON_BOOT
-DBOARD_HAS_PSRAM
-DFREEINK_DEVICE_M5PAPER=1
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper_v11\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
; Touch + rotary: no physical button row, so hide on-screen button hints.
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
; Archive-order workarounds for the classic-ESP32 link (see freeink-sdk
; platformio.crosspoint.sample.ini): force Arduino startup + the I2C HAL members.
-Wl,.pio/build/m5paper_v11/FrameworkArduino/main.cpp.o
-Wl,-u,i2cInit
-Wl,-u,i2cSlaveInit
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# Regenerate src/components/icons/generated_icons.h from icons.manifest, using the
# FreeInk SDK icon generator and its vendored Lucide submodule. generated_icons.h is
# a committed build product of these inputs — re-run this after editing the manifest.
#
# Requires: rsvg-convert (librsvg) + Pillow, and the Lucide submodule fetched
# (git submodule update --init in the SDK).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$ROOT/freeink-sdk"
SVGDIR="$SDK/libs/assets/Icons/lucide/icons"
if [ ! -d "$SVGDIR" ]; then
echo "Lucide SVGs not found at $SVGDIR" >&2
echo "Fetch the submodule: git -C \"$SDK\" submodule update --init libs/assets/Icons/lucide" >&2
exit 1
fi
python3 "$SDK/libs/assets/Icons/tools/gen_icons.py" \
--manifest "$ROOT/src/components/icons/icons.manifest" \
--svgdir "$SVGDIR" \
--sizes 16,24,32,40,48 \
--out "$ROOT/src/components/icons/generated_icons.h"
echo "Regenerated src/components/icons/generated_icons.h"
+8 -1
View File
@@ -6,6 +6,7 @@
#include <Serialization.h>
#include <cstring>
#include <mutex>
#include <string>
#include "I18nKeys.h"
@@ -96,6 +97,7 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
}
bool CrossPointSettings::saveToFile() const {
std::lock_guard<std::mutex> lock(_mutex);
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
}
@@ -106,7 +108,11 @@ bool CrossPointSettings::loadFromFile() {
String json = Storage.readFile(SETTINGS_FILE_JSON);
if (!json.isEmpty()) {
bool resave = false;
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
bool result;
{
std::lock_guard<std::mutex> lock(_mutex);
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
}
if (result && resave) {
if (saveToFile()) {
LOG_DBG("CPS", "Resaved settings to update format");
@@ -166,6 +172,7 @@ bool CrossPointSettings::loadFromBinaryFile() {
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
return false;
}
std::lock_guard<std::mutex> lock(_mutex);
uint8_t version;
serialization::readPod(inputFile, version);
+15 -4
View File
@@ -3,9 +3,12 @@
#include <cstdint>
#include <iosfwd>
#include <mutex>
class CrossPointSettings {
private:
mutable std::mutex _mutex;
// Private constructor for singleton
CrossPointSettings() = default;
@@ -17,6 +20,10 @@ class CrossPointSettings {
CrossPointSettings(const CrossPointSettings&) = delete;
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
// Access the settings mutex for protecting multi-field reads/writes from other cores.
// Callers must not re-enter SETTINGS methods that lock _mutex while holding it.
std::mutex& getMutex() const { return _mutex; }
enum SLEEP_SCREEN_MODE {
DARK = 0,
LIGHT = 1,
@@ -166,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 {
@@ -202,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
@@ -246,9 +260,6 @@ class CrossPointSettings {
uint8_t uiTheme = LYRA;
// Sunlight fading compensation
uint8_t fadingFix = 0;
// Touch reader controls: tap zones for page back/forward + press-and-hold,
// mirroring the physical buttons. Touch devices only (hidden elsewhere).
uint8_t touchReaderControls = 1;
// Power button return from footnotes (1 = enabled, 0 = disabled)
uint8_t pwrBtnFootnoteBack = 1;
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
+4
View File
@@ -6,6 +6,7 @@
#include <Serialization.h>
#include <algorithm>
#include <mutex>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 4;
@@ -32,6 +33,7 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
}
bool CrossPointState::saveToFile() const {
std::lock_guard<std::mutex> lock(_mutex);
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
}
@@ -41,6 +43,7 @@ bool CrossPointState::loadFromFile() {
if (Storage.exists(STATE_FILE_JSON)) {
String json = Storage.readFile(STATE_FILE_JSON);
if (!json.isEmpty()) {
std::lock_guard<std::mutex> lock(_mutex);
return JsonSettingsIO::loadState(*this, json.c_str());
}
}
@@ -67,6 +70,7 @@ bool CrossPointState::loadFromBinaryFile() {
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
return false;
}
std::lock_guard<std::mutex> lock(_mutex);
uint8_t version;
serialization::readPod(inputFile, version);
+6
View File
@@ -1,12 +1,18 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <string>
class CrossPointState {
mutable std::mutex _mutex;
// Static instance
static CrossPointState instance;
public:
// Access the state mutex for protecting multi-field reads/writes from other cores.
std::mutex& getMutex() const { return _mutex; }
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
std::string openEpubPath;
+2 -4
View File
@@ -5,6 +5,7 @@
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <algorithm>
#include <cstring>
#include <string>
@@ -153,10 +154,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
@@ -345,6 +342,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
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;
+3 -187
View File
@@ -1,12 +1,8 @@
#include "MappedInputManager.h"
#include <FreeInkUI.h>
#include <GfxRenderer.h>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "components/TouchRegistry.h"
bool MappedInputManager::isNavDirectionSwapped() const {
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
@@ -78,182 +74,9 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
// Top-left corner fallback, as a fraction of the logical screen. Generous to hit.
static constexpr float BACK_GESTURE_FRAC_X = 0.22f;
static constexpr float BACK_GESTURE_FRAC_Y = 0.12f;
static constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
static constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
static constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
void MappedInputManager::rememberTouchHeldTime() const {
touchHeldOverrideValid = true;
touchHeldOverrideMs = gpio.lastTouchHeldMs();
touchHeldOverrideAt = millis();
}
bool MappedInputManager::wasBottomEdgeSwipeUp() const {
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
int sx = 0, sy = 0, ex = 0, ey = 0;
renderer.tapToLogical(nxs, nys, sx, sy);
renderer.tapToLogical(nxe, nye, ex, ey);
const int screenHeight = renderer.getScreenHeight();
const int bottomEdgeTop = screenHeight - static_cast<int>(screenHeight * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
const bool isBackSwipe = sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx);
if (isBackSwipe) rememberTouchHeldTime();
return isBackSwipe;
}
bool MappedInputManager::wasBackGesture() const {
if (wasBottomEdgeSwipeUp()) return true;
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(nx, ny, lx, ly);
// A tap on the theme's header Back target acts as Back.
int id = 0;
if (TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Back, id)) {
rememberTouchHeldTime();
return true;
}
// Else the top-left corner, for screens with no Back target (e.g. the reader).
const bool isTopLeftBack =
lx <= renderer.getScreenWidth() * BACK_GESTURE_FRAC_X && ly <= renderer.getScreenHeight() * BACK_GESTURE_FRAC_Y;
if (isTopLeftBack) rememberTouchHeldTime();
return isTopLeftBack;
}
bool MappedInputManager::wasItemTapped(int& id) const {
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(nx, ny, lx, ly);
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasItemTouchedDown(int& id) const {
float nx = 0.0f, ny = 0.0f;
unsigned long heldMs = 0;
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) {
touchSelectTracking = false;
touchSelectEmitted = false;
touchSelectId = -1;
return false;
}
if (!touchSelectTracking) {
float downNx = 0.0f, downNy = 0.0f;
if (!gpio.wasTouchDown(downNx, downNy)) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(downNx, downNy, lx, ly);
int candidateId = -1;
if (!TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, candidateId)) {
touchSelectTracking = false;
touchSelectEmitted = false;
touchSelectId = -1;
return false;
}
touchSelectTracking = true;
touchSelectEmitted = false;
touchSelectId = candidateId;
}
if (touchSelectEmitted || heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
touchSelectEmitted = true;
id = touchSelectId;
return true;
}
bool MappedInputManager::wasItemLongPressed(int& id) const {
static constexpr unsigned long TOUCH_LONG_PRESS_MS = 500;
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false; // release frame
if (gpio.lastTouchHeldMs() < TOUCH_LONG_PRESS_MS) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(nx, ny, lx, ly);
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasTabTapped(int& id) const {
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(nx, ny, lx, ly);
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Tab, id);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasCoverTapped(int& id) const {
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
int lx = 0, ly = 0;
renderer.tapToLogical(nx, ny, lx, ly);
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Cover, id);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
renderer.tapToLogical(nx, ny, x, y);
rememberTouchHeldTime();
return true;
}
bool MappedInputManager::wasListScroll(int& index, int count, int pageItems) const {
if (count <= 0) return false;
if (pageItems < 1) pageItems = 1;
if (wasBottomEdgeSwipeUp()) return false;
const SwipeDir swipe = wasSwipe();
if (swipe == SwipeDir::Up) {
return freeink::ui::listPageIndex(index, +1, count, pageItems);
}
if (swipe == SwipeDir::Down) {
return freeink::ui::listPageIndex(index, -1, count, pageItems);
}
return false;
}
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
if (wasBottomEdgeSwipeUp()) return SwipeDir::None;
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None;
// Map both endpoints into the logical frame so the direction follows what the
// user sees regardless of panel mount/orientation.
int sx = 0, sy = 0, ex = 0, ey = 0;
renderer.tapToLogical(nxs, nys, sx, sy);
renderer.tapToLogical(nxe, nye, ex, ey);
const int dx = ex - sx;
const int dy = ey - sy;
if (std::abs(dx) >= std::abs(dy)) {
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
}
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
}
bool MappedInputManager::wasPressed(const Button button) const {
// A top-left tap fires on the release frame; expose it on Back's press edge too
// so menus that act on wasPressed(Back) also respond. Deliberately NOT folded
// into isPressed, so a quick tap never satisfies the readers' long-press-home.
if (button == Button::Back && wasBackGesture()) return true;
return mapButton(button, &HalGPIO::wasPressed);
}
bool MappedInputManager::wasReleased(const Button button) const {
if (button == Button::Back && wasBackGesture()) return true;
return mapButton(button, &HalGPIO::wasReleased);
}
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
@@ -261,14 +84,7 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const {
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
return touchHeldOverrideMs;
}
touchHeldOverrideValid = false;
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 {
-37
View File
@@ -7,7 +7,6 @@ class GfxRenderer;
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
enum class SwipeDir { None, Left, Right, Up, Down };
struct Labels {
const char* btn1;
@@ -22,33 +21,6 @@ class MappedInputManager {
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;
// Touch "back" gesture: a tap on the theme's header Back target, or in the
// top-left corner, or a swipe up from the visible bottom edge. Folded into
// Back's edges, so every screen gets it for free.
bool wasBackGesture() const;
// True (and writes the id) if a tap this frame hit a TouchRegistry item.
// Activities treat the id as "select + activate". False on non-touch devices.
bool wasItemTapped(int& id) const;
// Stable touch-down candidate: fires once when a touch remains over an item
// briefly without crossing tap slop, so swipes do not show row selection.
bool wasItemTouchedDown(int& id) const;
// Subset of wasItemTapped's releases held past the long-press threshold (check
// this first). Distinguishes tap vs press-and-hold.
bool wasItemLongPressed(int& id) const;
// wasItemTapped for tab-bar tabs (id = tab index) and cover/card targets
// (id = item index). Distinct kinds so a screen with both doesn't confuse them.
bool wasTabTapped(int& id) const;
bool wasCoverTapped(int& id) const;
// True on a touch release anywhere on screen, with logical/oriented coords.
bool wasScreenTapped(int& x, int& y) const;
// Swipe direction in the current logical (oriented) frame, or None. A swipe also
// raises the tap helpers above, so check this first and consume it.
SwipeDir wasSwipe() const;
// Page-scroll a list selection by a vertical swipe (touch nav without buttons):
// swipe up advances down the list, swipe down moves up, by pageItems, clamped to
// [0, count-1]. Updates index and returns true when a vertical swipe occurred, so
// callers can requestUpdate() and return before the tap handlers run.
bool wasListScroll(int& index, int count, int pageItems) const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
@@ -72,13 +44,4 @@ class MappedInputManager {
const GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
void rememberTouchHeldTime() const;
bool wasBottomEdgeSwipeUp() const;
mutable bool touchSelectTracking = false;
mutable bool touchSelectEmitted = false;
mutable int touchSelectId = -1;
mutable bool touchHeldOverrideValid = false;
mutable unsigned long touchHeldOverrideMs = 0;
mutable unsigned long touchHeldOverrideAt = 0;
};
+5
View File
@@ -34,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
}
} else {
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
}
}
@@ -76,6 +78,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
manager_.unloadAll(renderer);
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
return;
}
const auto* selected = family->findClosestReaderSize(sizeEnum);
@@ -96,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
}
} else {
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
}
}
+7 -26
View File
@@ -1,8 +1,6 @@
#pragma once
#include <BoardConfig.h>
#include <HalClock.h>
#include <HalGPIO.h>
#include <HalTiltSensor.h>
#include <I18n.h>
#include <SdCardFontRegistry.h>
@@ -106,8 +104,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
std::vector<SettingInfo> v = {
// --- Display ---
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
"sleepScreen", StrId::STR_CAT_DISPLAY),
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
@@ -159,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),
@@ -260,26 +261,6 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
SettingInfo::Toggle(StrId::STR_CLOCK_SYNCED, &CrossPointSettings::clockHasBeenSynced, "clockHasBeenSynced",
StrId::STR_CUSTOMISE_STATUS_BAR),
};
// Sunlight fading fix is Xteink-only (transflective panel); other devices get
// the touch reader controls toggle instead. Same gate as the reader runtime.
if (!gpio.isXteinkDevice()) {
v.erase(std::remove_if(v.begin(), v.end(),
[](const SettingInfo& s) { return s.nameId == StrId::STR_SUNLIGHT_FADING_FIX; }),
v.end());
for (auto it = v.begin(); it != v.end(); ++it) {
if (it->nameId == StrId::STR_SIDE_BTN_LAYOUT) {
v.insert(it, SettingInfo::Toggle(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls,
"touchReaderControls", StrId::STR_CAT_CONTROLS));
break;
}
}
}
if (BoardConfig::hasTouch()) {
v.erase(std::remove_if(v.begin(), v.end(),
[](const SettingInfo& s) { return s.nameId == StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION; }),
v.end());
}
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
if (halTiltSensor.isAvailable()) {
// Insert after the short power button setting (end of Controls section)
+3
View File
@@ -6,6 +6,8 @@
#include <ObfuscationUtils.h>
#include <Serialization.h>
#include <algorithm>
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
@@ -89,6 +91,7 @@ bool WifiCredentialStore::loadFromBinaryFile() {
serialization::readPod(file, count);
credentials.clear();
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
WifiCredential cred;
serialization::readString(file, cred.ssid);
+2 -1
View File
@@ -56,7 +56,8 @@ class Activity {
// Finish this activity and return to the previous one on the stack (if any)
void finish();
// Convenience method to facilitate API transition to ActivityManager.
// Convenience method to facilitate API transition to ActivityManager
// TODO: remove this in near future
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
void onSelectBook(const std::string& path);
};
+12 -22
View File
@@ -9,7 +9,6 @@
#include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h"
#include "components/TouchRegistry.h"
#include "home/CrashActivity.h"
#include "home/FileBrowserActivity.h"
#include "home/HomeActivity.h"
@@ -20,19 +19,15 @@
#include "settings/SettingsActivity.h"
#include "util/FullScreenMessageActivity.h"
// taskENTER_CRITICAL needs a real spinlock on dual-core targets (classic ESP32,
// e.g. M5Paper). On the single-core ESP32-C3 a nullptr mux was tolerated, but
// the dual-core port acquires an inter-core spinlock and asserts on a null
// pointer (spinlock_acquire, spinlock.h:84). One shared spinlock guards the
// short waitingTaskHandle critical sections below.
static portMUX_TYPE activityMux = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
void ActivityManager::begin() {
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle // Task handle
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle, // Task handle
0 // Pin to core 0 (PRO_CPU)
);
assert(renderTaskHandle != nullptr && "Failed to create render task");
}
@@ -50,18 +45,14 @@ void ActivityManager::renderTaskLoop() {
RenderLock lock;
if (currentActivity) {
HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering
// Touch targets are rebuilt every frame: clear before the activity draws, then
// publish so the next loop() hit-tests against exactly what's on screen.
TouchRegistry::getInstance().beginFrame();
currentActivity->render(std::move(lock));
TouchRegistry::getInstance().publish();
}
// Notify any task blocked in requestUpdateAndWait() that the render is done.
TaskHandle_t waiter = nullptr;
taskENTER_CRITICAL(&activityMux);
taskENTER_CRITICAL(&activityManagerSpinlock);
waiter = waitingTaskHandle;
waitingTaskHandle = nullptr;
taskEXIT_CRITICAL(&activityMux);
taskEXIT_CRITICAL(&activityManagerSpinlock);
if (waiter) {
xTaskNotify(waiter, 1, eIncrement);
}
@@ -149,8 +140,7 @@ void ActivityManager::loop() {
}
}
if (requestedUpdate) {
requestedUpdate = false;
if (requestedUpdate.exchange(false)) {
// Using direct notification to signal the render task to update
// Increment counter so multiple rapid calls won't be lost
if (renderTaskHandle) {
@@ -292,7 +282,7 @@ void ActivityManager::requestUpdateAndWait() {
}
// Atomic section to perform checks
taskENTER_CRITICAL(&activityMux);
taskENTER_CRITICAL(&activityManagerSpinlock);
auto currTaskHandler = xTaskGetCurrentTaskHandle();
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
bool isRenderTask = (currTaskHandler == renderTaskHandle);
@@ -301,7 +291,7 @@ void ActivityManager::requestUpdateAndWait() {
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
waitingTaskHandle = currTaskHandler;
}
taskEXIT_CRITICAL(&activityMux);
taskEXIT_CRITICAL(&activityManagerSpinlock);
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
+2 -1
View File
@@ -4,6 +4,7 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <atomic>
#include <cassert>
#include <memory>
#include <string>
@@ -63,7 +64,7 @@ class ActivityManager {
// Whether to trigger a render after the current loop()
// This variable must only be set by the main loop, to avoid race conditions
bool requestedUpdate = false;
std::atomic<bool> requestedUpdate{false};
public:
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
+4
View File
@@ -43,6 +43,10 @@ struct PageResult {
struct ProgressChangeResult {
int spineIndex = 0;
int page = 0;
int totalPages = 0;
std::string xpath;
float percentage = 0.0f;
bool hasSavedProgress = false;
};
enum class NetworkMode;
+6 -8
View File
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen();
}
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
@@ -219,13 +219,11 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
}
if (hasGreyscale) {
// OEM grayscale pipeline base: on X3 this displays the frame with the
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
// in the calibrated state the gray nudge refresh expects; on X4 it is a
// plain HALF refresh (previous behavior).
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
} else {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
}
if (hasGreyscale) {
@@ -333,5 +331,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
}
@@ -1,6 +1,5 @@
#include "OpdsBookBrowserActivity.h"
#include <FreeInkUI.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
@@ -11,7 +10,6 @@
#include "SilentRestart.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/TouchRegistry.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
@@ -33,6 +31,7 @@ void OpdsBookBrowserActivity::onEnter() {
currentPath = "";
selectorIndex = 0;
consumeConfirm = false;
consumeBack = false;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
requestUpdate();
@@ -61,6 +60,10 @@ void OpdsBookBrowserActivity::loop() {
consumeConfirm = false;
return;
}
if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) {
consumeBack = false;
return;
}
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -88,28 +91,11 @@ void OpdsBookBrowserActivity::loop() {
if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::BROWSING) {
if (!entries.empty()) {
if (mappedInput.wasListScroll(selectorIndex, static_cast<int>(entries.size()), PAGE_ITEMS)) {
requestUpdate();
return;
}
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) &&
freeink::ui::listSelectIndex(selectorIndex, downId, static_cast<int>(entries.size()))) {
requestUpdate();
}
int tappedId = -1;
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < static_cast<int>(entries.size())) {
selectorIndex = tappedId;
activateSelectedEntry();
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelectedEntry();
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
@@ -192,20 +178,13 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title;
if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author;
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40);
const int itemY = 60 + (i % PAGE_ITEMS) * 30;
renderer.drawText(UI_10_FONT_ID, 20, itemY, item.c_str(), i != static_cast<size_t>(selectorIndex));
TouchRegistry::getInstance().add(Rect{0, itemY - 2, pageWidth, 30}, static_cast<int>(i), TouchRegistry::Item);
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
i != static_cast<size_t>(selectorIndex));
}
}
renderer.displayBuffer();
}
void OpdsBookBrowserActivity::activateSelectedEntry() {
if (entries.empty() || selectorIndex < 0 || selectorIndex >= static_cast<int>(entries.size())) return;
const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
}
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
if (server.url.empty()) {
state = BrowserState::ERROR;
@@ -33,6 +33,7 @@ class OpdsBookBrowserActivity final : public Activity {
std::string currentPath;
std::string searchTemplate;
bool consumeConfirm = false;
bool consumeBack = false; // Added missing member
int selectorIndex = 0;
std::string errorMessage;
std::string statusMessage;
@@ -50,6 +51,5 @@ class OpdsBookBrowserActivity final : public Activity {
void downloadBook(const OpdsEntry& book);
void launchSearch();
void performSearch(const std::string& query);
void activateSelectedEntry();
bool preventAutoSleep() override { return true; }
};
+1 -3
View File
@@ -20,9 +20,7 @@ void CrashActivity::onEnter() {
}
void CrashActivity::loop() {
int tapX = 0;
int tapY = 0;
if (mappedInput.isPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(tapX, tapY)) {
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
finish();
}
}
+2 -24
View File
@@ -206,29 +206,7 @@ void FileBrowserActivity::loop() {
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
int scrollIdx = static_cast<int>(selectorIndex);
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(files.size()), pageItems)) {
selectorIndex = scrollIdx;
requestUpdate();
return;
}
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(files.size())) {
selectorIndex = downId;
requestUpdate();
}
// A tap opens the tapped entry (held-time is 0 on a tap, so it takes the short-press
// open path below, never the long-press delete).
int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(files.size())) {
selectorIndex = tappedId;
}
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (lockNextConfirmRelease) {
lockNextConfirmRelease = false;
return;
@@ -358,7 +336,7 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos);
}
std::string getFileExtension(std::string filename) {
std::string getFileExtension(const std::string& filename) {
if (filename.back() == '/') {
return "";
}
+7 -30
View File
@@ -179,29 +179,7 @@ void HomeActivity::loop() {
requestUpdate();
});
// Tap a menu button to select + activate it. The button menu registers
// menu-local ids (drawn with selectorIndex offset by recentBooks.size()), so map
// back into the global selector space.
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId)) {
selectorIndex = static_cast<int>(recentBooks.size()) + downId;
requestUpdate();
}
int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped) {
selectorIndex = static_cast<int>(recentBooks.size()) + tappedId;
}
// A tap on the continue-reading cover selects that book (home selector index).
int coverId = -1;
const bool coverTapped = mappedInput.wasCoverTapped(coverId);
if (coverTapped && coverId >= 0 && coverId < static_cast<int>(recentBooks.size())) {
selectorIndex = coverId;
}
if (tapped || coverTapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex < recentBooks.size()) {
onSelectBook(recentBooks[selectorIndex].path);
} else {
@@ -255,7 +233,7 @@ void HomeActivity::render(RenderLock&&) {
// Build menu items dynamically
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
tr(STR_SETTINGS_TITLE)};
std::vector<UIIcon> menuIcons = {Folder, Book, Transfer, Settings};
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
if (hasOpdsServers) {
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
@@ -268,13 +246,12 @@ void HomeActivity::render(RenderLock&&) {
menuIcons.insert(menuIcons.begin(), Book);
}
// Menu fills the space between the cover and the bottom reserve (button hints
// when shown, else a margin). Sizing it to the real remaining height lets
// drawButtonMenu fit the items, so a scaled-up menu never runs off the bottom.
const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing;
GUI.drawButtonMenu(
renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, static_cast<int>(menuItems.size()),
renderer,
Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth,
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing +
metrics.homeMenuTopOffset + metrics.buttonHintsHeight)},
static_cast<int>(menuItems.size()),
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(),
[&menuItems](int index) { return std::string(menuItems[index]); },
[&menuIcons](int index) { return menuIcons[index]; });
+1 -18
View File
@@ -44,14 +44,6 @@ void RecentBooksActivity::onExit() {
void RecentBooksActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
int scrollIdx = static_cast<int>(selectorIndex);
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(recentBooks.size()), pageItems)) {
selectorIndex = scrollIdx;
requestUpdate();
return;
}
// After a long-press has fired, swallow input until Confirm is physically released
// (so the release doesn't also open the book; re-arm only once the button is up).
if (longPressFired) {
@@ -71,16 +63,7 @@ void RecentBooksActivity::loop() {
return;
}
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(recentBooks.size())) {
selectorIndex = downId;
requestUpdate();
}
int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(recentBooks.size())) selectorIndex = tappedId;
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
@@ -4,13 +4,13 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/TaskWatchdog.h"
namespace {
constexpr const char* HOSTNAME = "crosspoint";
@@ -110,12 +110,12 @@ void CalibreConnectActivity::loop() {
LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
}
feedTaskWatchdog();
esp_task_wdt_reset();
constexpr int MAX_ITERATIONS = 80;
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
if ((i & 0x07) == 0x07) {
feedTaskWatchdog();
esp_task_wdt_reset();
}
if ((i & 0x0F) == 0x0F) {
yield();
@@ -5,6 +5,7 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <cstddef>
@@ -15,7 +16,6 @@
#include "activities/network/CalibreConnectActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/TaskWatchdog.h"
#include "util/QrUtils.h"
namespace {
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
}
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
feedTaskWatchdog();
esp_task_wdt_reset();
// Process HTTP requests in tight loop for maximum throughput
// More iterations = more data processed per main loop cycle
@@ -337,7 +337,7 @@ void CrossPointWebServerActivity::loop() {
webServer->handleClient();
// Reset watchdog every 32 iterations
if ((i & 0x1F) == 0x1F) {
feedTaskWatchdog();
esp_task_wdt_reset();
}
// Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) {
@@ -30,17 +30,8 @@ void NetworkModeSelectionActivity::loop() {
return;
}
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(MENU_ITEM_COUNT)) {
selectedIndex = downId;
requestUpdate();
}
// Handle confirm button (or a tap) - select current option
int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(MENU_ITEM_COUNT)) selectedIndex = tappedId;
if (tapped || mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// Handle confirm button - select current option
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
NetworkMode mode = NetworkMode::JOIN_NETWORK;
if (selectedIndex == 1) {
mode = NetworkMode::CONNECT_CALIBRE;
@@ -51,6 +42,7 @@ void NetworkModeSelectionActivity::loop() {
return;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
requestUpdate();
@@ -85,6 +77,7 @@ void NetworkModeSelectionActivity::render(RenderLock&&) {
[](int index) { return std::string(I18N.get(menuItems[index])); },
[](int index) { return std::string(I18N.get(menuDescs[index])); }, [](int index) { return menuIcons[index]; });
// Draw help text at bottom
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@@ -409,27 +409,6 @@ void WifiSelectionActivity::loop() {
// Handle network list state
if (state == WifiSelectionState::NETWORK_LIST) {
// Vertical swipe page-scrolls the network list (touch nav without the side buttons).
int scrollIdx = static_cast<int>(selectedNetworkIndex);
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(networks.size()),
UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false))) {
selectedNetworkIndex = scrollIdx;
requestUpdate();
return;
}
// Touch: stable press highlights; tap selects it (like Confirm). Drag/scroll contacts do not preselect a row.
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(networks.size())) {
selectedNetworkIndex = downId;
requestUpdate();
}
int tappedId = -1;
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < static_cast<int>(networks.size())) {
selectedNetworkIndex = tappedId;
selectNetwork(selectedNetworkIndex);
return;
}
// Check for Back button to exit (cancel)
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete(false);
@@ -463,6 +442,7 @@ void WifiSelectionActivity::loop() {
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
requestUpdate();
+115
View File
@@ -0,0 +1,115 @@
#include "EndOfBookOptions.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include "CrossPointSettings.h"
#include "ReaderUtils.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ButtonNavigator.h"
#include "util/NextBookFinder.h"
namespace {
// Display name without the file extension, mirroring the file browser rows
std::string displayName(const std::string& filename) {
const auto pos = filename.rfind('.');
return filename.substr(0, pos);
}
} // namespace
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
if (isLoaded.load(std::memory_order_acquire)) {
return;
}
folder = FsHelpers::extractFolderPath(currentBookPath);
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
selector = 0;
// Release-publish so the main task, which gates all access on isLoaded, never
// observes a partially built list
isLoaded.store(true, std::memory_order_release);
}
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
std::string EndOfBookOptions::fullPath(const size_t index) const {
if (index >= names.size()) {
return {};
}
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
}
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
if (selector < static_cast<int>(names.size())) {
if (openPath) {
*openPath = fullPath(selector);
}
return Action::OpenBook;
}
return Action::GoHome; // "Home" entry selected
}
// Short-press Back returns to the last page; a long press falls through to the
// reader's own handler (file browser). Home is reached through the list's Home entry.
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
return Action::LastPage;
}
// Selection movement on the standard list navigation buttons (side Up/Down plus front
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
// (press-triggered by default, release-triggered when a long-press behavior is
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
// press-triggered turns, the press that turned the final page already fired in the
// reader, and its release must not double-fire into this menu.
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
const auto triggered = [&](const MappedInputManager::Button button) {
return usePress ? input.wasPressed(button) : input.wasReleased(button);
};
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
if (triggered(MappedInputManager::Button::NavPrevious)) {
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
return Action::Redraw;
}
if (triggered(MappedInputManager::Button::NavNext)) {
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
return Action::Redraw;
}
return Action::None;
}
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
const auto& metrics = UITheme::getInstance().getMetrics();
if (!menuActive()) {
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
// the previous fixed position on the 480x800 panel and scales to other resolutions.
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
EpdFontFamily::BOLD);
return;
}
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
// the physical front buttons, which is a logical side/top edge in the rotated
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
// positions derive from the safe-area height and font line heights so other panel
// resolutions scale (review request on #2532).
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int titleY = safe.y + safe.height / 8;
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
[this](const int index) {
return index < static_cast<int>(names.size()) ? displayName(names[index])
: std::string(tr(STR_EOB_HOME));
});
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <atomic>
#include <string>
#include <vector>
class GfxRenderer;
class MappedInputManager;
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
// draws the end screen. With no suggestions the end screen keeps its historical
// plain-title look and behavior.
class EndOfBookOptions {
public:
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
static constexpr size_t MAX_SUGGESTIONS = 3;
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
// is the release/acquire publication point that lets the main task read the finished
// list safely.
void loadOnce(const std::string& currentBookPath);
// True when the suggestion menu is showing and should own the reader's input.
bool menuActive() const;
// Menu input handling, following the standard list idiom: side Up/Down and front
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
// Back press returns to the last page of the book. Fills openPath when the result is
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
// their normal input path (keeping long-press Back to the file browser working).
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
private:
std::string folder;
// Written by the render task in loadOnce(), immutable afterwards; the main task only
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
std::vector<std::string> names;
int selector = 0;
std::atomic<bool> isLoaded{false};
std::string fullPath(size_t index) const;
};
+336 -119
View File
@@ -231,6 +231,30 @@ void EpubReaderActivity::onExit() {
}
}
void EpubReaderActivity::openReaderMenu() {
const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->estimatedTotalPages() : 0;
float bookProgress = 0.0f;
if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) {
const float chapterProgress =
static_cast<float>(section->currentPage) / static_cast<float>(section->estimatedTotalPages());
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
}
void EpubReaderActivity::loop() {
if (!epub) {
// Should never happen
@@ -238,6 +262,33 @@ void EpubReaderActivity::loop() {
return;
}
// Drive any in-progress incremental section build forward, off the page-turn critical path,
// but only within a small window ahead of the reader: an unbounded build monopolized the
// RenderLock and locked out page turns. The build follows the reader instead, and instant
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
// 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) {
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
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
// mutation, so it flags this as always true.
// cppcheck-suppress knownConditionTrueFalse
if (section->isBuilding()) {
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
LOG_ERR("ERS", "Background section build failed");
section.reset();
requestUpdate();
} else if (section->isBuildComplete() && applyDeferredReposition()) {
// The chapter re-paginated since the saved progress (settings changed): we now know the
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
requestUpdate();
}
}
}
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
// finished. Two independent finished-book features key off this same condition.
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
@@ -298,37 +349,43 @@ void EpubReaderActivity::loop() {
requestUpdate();
}
// Touch page nav (idempotent within a frame, so read here and again below). The
// top-left Back corner is consumed by wasReleased(Back) above, never reaching here.
const auto touch = ReaderUtils::detectTouchPageTurn(renderer);
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
// through to the regular handlers below; page turns are absorbed by the end-of-book
// block. A Confirm release after a long-press function (bookmark/sync) fired is left
// to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease.
if (atEndOfBook && endOfBookOptions.menuActive() &&
!(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) {
std::string openPath;
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
case EndOfBookOptions::Action::OpenBook:
activityManager.goToReader(openPath);
return;
case EndOfBookOptions::Action::GoHome:
onGoHome();
return;
case EndOfBookOptions::Action::LastPage:
currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0);
nextPageNumber = 0;
pendingPageJump = std::numeric_limits<uint16_t>::max();
requestUpdate();
return;
case EndOfBookOptions::Action::Redraw:
requestUpdate();
return;
case EndOfBookOptions::Action::None:
break;
}
}
// Enter reader menu activity on short-press Confirm release, or a center touch-and-hold. A
// long-press that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease
// so the release following the hold does not also open the menu.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(touch)) {
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
// following the hold does not also open the menu.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
const int currentPage = section ? section->currentPage + 1 : 0;
const int totalPages = section ? section->pageCount : 0;
float bookProgress = 0.0f;
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
openReaderMenu();
}
}
@@ -404,15 +461,19 @@ void EpubReaderActivity::loop() {
return;
}
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
// At end of the book, forward button goes home and back button returns to last page
// At end of the book with no suggestion menu, forward button goes home and back
// button returns to last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
if (endOfBookOptions.menuActive()) {
// Selection movement was handled above; absorb leftover page-turn triggers so
// e.g. "previous" at the top of the list doesn't jump back into the book
return;
}
if (nextTriggered) {
onGoHome();
} else {
@@ -424,8 +485,7 @@ void EpubReaderActivity::loop() {
return;
}
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
// Don't skip chapter after screenshot
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
@@ -544,11 +604,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
loadCachedBookmarks();
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
int targetSpineIndex = sync.spineIndex;
int targetPage = sync.page;
const int activeTotalPages = section ? section->estimatedTotalPages() : 0;
const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 &&
currentSpineIndex == sync.spineIndex && sync.page >= 0 &&
sync.page < sync.totalPages && activeTotalPages == sync.totalPages;
if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) {
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
CrossPointPosition fallback =
ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages);
targetSpineIndex = fallback.spineIndex;
targetPage = fallback.pageNumber;
}
if (currentSpineIndex != targetSpineIndex) {
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
currentSpineIndex = targetSpineIndex;
nextPageNumber = targetPage;
section.reset();
} else if (section && section->currentPage != targetPage) {
RenderLock lock(*this);
const int clampedTargetPage = std::max(0, targetPage);
section->currentPage = clampedTargetPage;
} else if (!section) {
nextPageNumber = targetPage;
}
}
};
@@ -668,7 +749,7 @@ bool EpubReaderActivity::launchKOReaderSync() {
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
@@ -766,7 +847,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
if (isForwardTurn) {
if (section->currentPage < section->pageCount - 1) {
// Advance within the section while there are (or may still be) more pages: either a built
// page ahead, or the section is still building (windowed), in which case more pages exist
// beyond the current watermark and render()'s ensure-built pump will lay them out. Only when
// the section is fully built AND we're on its last page do we move to the next spine -- using
// the live pageCount alone would mistake the build watermark for the end of a giant spine.
if (section->currentPage < section->pageCount - 1 || section->isBuilding()) {
section->currentPage++;
} else {
// We don't want to delete the section mid-render, so grab the semaphore
@@ -795,6 +881,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
requestUpdate();
}
// TODO: Failure handling
void EpubReaderActivity::render(RenderLock&& lock) {
if (!epub) {
return;
@@ -817,8 +904,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// Show end of book screen
if (currentSpineIndex == epub->getSpineItemsCount()) {
// Sole load site: runs on the render task (serialized by RenderLock); the main
// task only reads the suggestions once the loaded flag is published
endOfBookOptions.loadOnce(epub->getPath());
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
endOfBookOptions.render(renderer, mappedInput);
renderer.displayBuffer();
automaticPageTurnActive = false;
showPendingSyncSaveError();
@@ -853,48 +943,130 @@ void EpubReaderActivity::render(RenderLock&& lock) {
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_DBG("ERS", "Cache not found, building...");
// A finalized cache serves every page as-is. A partial cache (suspended build from a
// previous session) serves its pages instantly too, but a build must still run to lay
// out the rest -- it re-parses from the top in the background (HTML already cached,
// pages are deterministic) and finalizes, so the partial machinery retires itself.
const bool cacheLoaded = section->loadSectionFile(
SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
if (cacheLoaded) {
// Matching render params means identical pagination, so the saved page number is valid
// as-is: consume any pending settings-change reposition. Without this, a chapter total
// saved while the section was still building (i.e. a watermark, not the real count)
// would remap the resume page against the finalized count and teleport the reader.
cachedChapterTotalPageCount = 0;
}
const bool cacheComplete = cacheLoaded && !section->isPartial();
if (!cacheComplete) {
if (section->isPartial()) {
LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount);
} else {
LOG_DBG("ERS", "Cache not found, building...");
}
GUI.drawPopup(renderer, tr(STR_INDEXING));
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();
showPendingSyncSaveError();
return;
// 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
// (blocking) build with the indexing popup. Everything else -- plain forward reads, resume,
// and explicit page jumps -- only needs a specific page, so it builds incrementally to that
// page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*)
// is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page
// count is known, so it never blocks the first page.
// Only a percent jump truly needs the whole chapter up front (percent -> page needs the final
// page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below --
// the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0
// without indexing the whole chapter.
const bool needsFullBuild = pendingPercentJump;
if (needsFullBuild) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// 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]() { 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();
showPendingSyncSaveError();
return;
}
} else {
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
// indexing popup up front only when the build will actually be slow: a large spine (its
// whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
// build in a blink and stay popup-free.
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
// Popup only when the build will actually be slow: a big spine whose HTML still needs
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
// A partial cache that already covers the target page shows it instantly: never popup.
const bool willInflate = !section->hasHtmlCache();
const bool anchorJump = !pendingAnchor.empty();
bool showPopup;
if (anchorJump) {
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
} else {
const bool targetAvailable = target < static_cast<int>(section->pageCount);
showPopup = !targetAvailable &&
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
}
if (showPopup) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
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();
showPendingSyncSaveError();
return;
}
while (!section->isBuildComplete() &&
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showPendingSyncSaveError();
return;
}
}
}
} else {
LOG_DBG("ERS", "Cache found, skipping build...");
}
if (pendingPageJump.has_value()) {
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
section->currentPage = section->pageCount - 1;
} else {
section->currentPage = *pendingPageJump;
}
section->currentPage = *pendingPageJump;
pendingPageJump.reset();
} else {
section->currentPage = nextPageNumber;
if (section->currentPage < 0) {
section->currentPage = 0;
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
section->currentPage = section->pageCount - 1;
}
}
if (!pendingAnchor.empty()) {
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
// Resolve from the pages laid out so far and/or the on-disk map (finalized or partial).
const auto page = section->findAnchor(pendingAnchor);
if (page) {
section->currentPage = *page;
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
} else {
@@ -903,17 +1075,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
pendingAnchor.clear();
}
// handles changes in reader settings and reset to approximate position based on cached progress
if (cachedChapterTotalPageCount > 0) {
// only goes to relative position if spine index matches cached value
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
int newPage = static_cast<int>(progress * section->pageCount);
section->currentPage = newPage;
}
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
}
if (pendingPercentJump && section->pageCount > 0) {
// Apply the pending percent jump now that we know the new section's page count.
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
@@ -925,6 +1086,57 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
}
// Extend the build to the requested page if needed (for partials and in-progress builds).
// This runs every render, so it covers both the first page and any forward turn that gets
// 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.
if (!section->isBuilding() &&
!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 partial extension build");
section.reset();
showPendingSyncSaveError();
return;
}
// Extend until either the target page exists or the build completes.
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();
showPendingSyncSaveError();
return;
}
}
}
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
if (section->isBuilding()) {
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();
showPendingSyncSaveError();
return;
}
}
}
// 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.
// Guarded on !isBuilding() because a still-building section's pageCount is only the current
// watermark (not the final count) and has already been driven far enough by the loops above.
if (!section->isBuilding() && section->pageCount > 0 &&
section->currentPage >= static_cast<int>(section->pageCount)) {
section->currentPage = section->pageCount - 1;
}
// Apply a deferred settings-change reposition now that the real page count is known (a no-op for
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
applyDeferredReposition();
renderer.clearScreen();
if (section->pageCount == 0) {
@@ -950,9 +1162,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
updateBookmarkFlag();
{
auto p = section->loadPageFromSectionFile();
// Unified page read: the in-progress build's in-RAM table if it has reached the page,
// otherwise the on-disk file (finalized section, or a partial from a previous session).
auto p = section->loadPage(section->currentPage);
if (!p) {
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
// 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();
requestUpdate(); // Try again after clearing cache
@@ -969,8 +1186,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
}
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
showPendingSyncSaveError();
@@ -984,36 +1200,28 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
}
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
if (!epub || !section || section->pageCount < 2) {
return;
bool EpubReaderActivity::applyDeferredReposition() {
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
return false;
}
// Build the next chapter cache while the penultimate page is on screen.
if (section->currentPage != section->pageCount - 2) {
return;
}
const int nextSpineIndex = currentSpineIndex + 1;
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
return;
}
Section nextSection(epub, nextSpineIndex, renderer);
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
return;
}
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
bool changed = false;
// Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain
// resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and
// nothing moves.
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
const float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
int newPage = static_cast<int>(progress * static_cast<float>(section->pageCount));
if (newPage < 0) newPage = 0;
if (section->pageCount > 0 && newPage >= static_cast<int>(section->pageCount)) {
newPage = section->pageCount - 1;
}
if (newPage != section->currentPage) {
section->currentPage = newPage;
changed = true;
}
}
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
return changed;
}
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
@@ -1033,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) {
@@ -1043,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();
@@ -1060,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);
@@ -1186,9 +1401,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
void EpubReaderActivity::renderStatusBar() const {
// Calculate progress in book
// Calculate progress in book. Use the estimated total while a giant spine is still building so
// "page X of Y" and the progress bar don't read off the small build watermark.
const int currentPage = section->currentPage + 1;
const float pageCount = section->pageCount;
const float pageCount = section->estimatedTotalPages();
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
@@ -1219,7 +1435,8 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
section->isBuilding());
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -1310,7 +1527,7 @@ void EpubReaderActivity::addBookmark() {
int pageCount;
{
RenderLock lock(*this);
pageCount = section->pageCount;
pageCount = section->estimatedTotalPages();
currentPage = section->currentPage;
}
@@ -1359,10 +1576,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
currentPageBookmarked = false;
return;
}
const ProgressRange pageRange =
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
const int pageCount = section->estimatedTotalPages();
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount);
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange);
});
}
@@ -1375,9 +1592,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
}
if (section) {
info.currentPage = section->currentPage + 1;
info.totalPages = section->pageCount;
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
info.totalPages = section->estimatedTotalPages();
if (epub && epub->getBookSize() > 0 && info.totalPages > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(info.totalPages);
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
@@ -1389,7 +1606,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
+30 -1
View File
@@ -6,6 +6,7 @@
#include <optional>
#include "BookmarkEntry.h"
#include "EndOfBookOptions.h"
#include "EpubReaderMenuActivity.h"
#include "ProgressMapper.h"
#include "activities/Activity.h"
@@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity {
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
// Consumed in onExit() to relocate the finished book into /Read/.
bool pendingReadFolderMove = false;
// Next-book suggestion menu for the End-of-Book screen
EndOfBookOptions endOfBookOptions;
// Footnote support
std::vector<FootnoteEntry> currentPageFootnotes;
@@ -59,11 +62,37 @@ class EpubReaderActivity final : public Activity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
// Pages laid out per incremental-build pump: on the render path (catching up to the page
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
// 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;
// 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
// ahead and resumes as the reader advances; building unbounded instead locked up input by
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
// already laid out as a partial file on exit/sleep.
static constexpr int BUILD_WINDOW_AHEAD = 5;
// Show the indexing popup when an initial build must lay out more than this many pages up front
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
// of the small look-ahead window so ordinary landings stay popup-free.
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
// a multi-second wait. Normal chapters are well under this and stay popup-free.
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
// Remap the cached relative reading position once the section's real page count is known
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
// No-op while the section is still building or when the pagination is unchanged (plain resume).
bool applyDeferredReposition();
bool saveProgress(int spineIndex, int currentPage, int pageCount);
// Jump to a percentage of the book (0-100), mapping it to spine and page.
void jumpToPercent(int percent);
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
// Opens the reader menu for the current position (short-press Confirm)
void openReaderMenu();
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
// because no KOReader credentials are stored.
bool launchKOReaderSync();

Some files were not shown because too many files have changed in this diff Show More