Compare commits

..
Author SHA1 Message Date
Justin Mitchell 757c0b6d9e Add 16px icon size and remove hardcoded bookmark icon
Generate 16px variants for all icons and remove the old hardcoded bookmark icon in favor of using the generated icon system. This provides better consistency across icon sizes and simplifies icon management.
2026-06-28 02:34:10 -04:00
Justin Mitchell 2c8ef01894 Merge remote-tracking branch 'origin/develop' into feat-touch
# Conflicts:
#	freeink-sdk
#	lib/I18n/translations/slovak.yaml
#	platformio.ini
#	src/components/icons/bookmark.h
2026-06-28 02:20:24 -04:00
Justin Mitchell 1bd718fad9 Update freeink-sdk submodule
Update freeink-sdk submodule from 4b30a86 to c3b3ab3.
2026-06-21 18:29:48 -04:00
Justin Mitchell 61cb4946d6 Fix I2C initialization for dual X3+X4 binary
Sync the runtime board profile to the detected device before I2C consumers initialize. This ensures Wire.begin() is called when needed for X3 devices. Previously, the X4 profile remained active through initialization, causing Wire to never reinitialize after detection probe, leading to X3 I2C read failures with 'could not acquire lock' errors.
2026-06-21 02:06:01 -04:00
Justin Mitchell 4e352b9894 Fix code formatting and include order
Break long line in BookMetadataCache.cpp for better readability and move include directive to top of file in Logging.cpp to follow style guidelines.
2026-06-21 01:24:22 -04:00
Justin Mitchell 4e156e1617 Scale preview padding in UITheme metrics
Apply scaling to previewPadding metric while keeping previewHeightPercent intentionally unscaled. Adds clarifying comment about the different scaling behavior between these two preview-related metrics.
2026-06-21 01:23:56 -04:00
Justin Mitchell 3e0ba4488b Merge remote-tracking branch 'origin/master' into feat-touch 2026-06-21 01:18:18 -04:00
Justin Mitchell e42503f1a4 Preserve touch held time across gesture detection
Touch held time was being lost when gestures were detected because the gesture detection happens before the touch release event. Now remembers the held time at the moment of gesture detection and returns it within a 250ms window, allowing UI elements to properly respond to long-press gestures. Also refactors deep sleep code to use PowerManager methods.
2026-06-21 01:14:40 -04:00
Justin Mitchell c812091a4a Make USB detection pin configurable
Replace hardcoded UART0_RXD pin with configurable BoardConfig::ACTIVE.usbDetect. Also simplify wakeup reason detection logic to prioritize GPIO/EXT1 wakeup from deep sleep as PowerButton event.
2026-06-20 12:15:45 -04:00
Justin Mitchell beb4e82c9c Add extra bar indicator for near-complete progress
Draws a small additional bar segment when progress reaches 95% or higher, positioned at x+14 within the progress bar cavity. The extra bar is capped at 3 pixels wide to fit within the available space.
2026-06-20 00:14:48 -04:00
Justin Mitchell a242397fa0 Update freeink-sdk submodule
Updates freeink-sdk submodule from 5ea178c to 4b30a86.
2026-06-19 16:15:24 -04:00
Justin Mitchell 1511ab35d5 Remove obsolete TODO comments and clarify code
Clean up outdated TODO comments that are no longer relevant and improve comment clarity in activity classes and parsers.
2026-06-19 15:54:44 -04:00
Justin Mitchell 57c389c0b6 Add touch-down visual feedback to settings menus
Handle touch-down events separately from tap events in settings activities to provide immediate visual feedback when menu items are touched, before the tap is completed. This improves UI responsiveness by updating the selected index on touch-down.
2026-06-19 14:41:21 -04:00
Justin Mitchell 06ff5aa44a Add SDK RTC and IMU hardware abstraction support
Integrate SDK-based RTC and IMU backends as alternatives to direct hardware access. HalClock now attempts SDK RTC initialization and caches time values for reliability. HalTiltSensor adds SDK IMU backend with fallback logic. ClockOffsetActivity gains touch and swipe gesture support for field navigation.
2026-06-19 14:25:51 -04:00
Justin Mitchell 526cf1b6f9 Add touch gesture support and LilyGo T5 S3 board
Implements bottom-edge swipe-up gesture detection and delayed touch-select with tracking state. Adds isTouchTapCandidate() to distinguish taps from swipes. Includes LilyGo T5 S3 board configuration with USB CDC logging transport for boards where Serial operator bool reports false incorrectly.
2026-06-19 13:39:10 -04:00
Justin Mitchell 6648a980cb Center row icons between title and subtitle
When a row has a subtitle, position the icon at the midpoint between the title and subtitle text centers instead of aligning only with the title. This prevents icons from appearing too high when subtitles are present.
2026-06-17 01:41:10 -04:00
Justin Mitchell e567620003 Change icon library reference from library-big to library
Updates the icon manifest to use the standard library instead of library-big
2026-06-17 01:37:08 -04:00
Justin Mitchell 0b575d1965 Updates icons to match original Lyra
Adds a unique icon for firmware bins in the file browser
2026-06-17 01:31:07 -04:00
Justin Mitchell 4f55444f67 Add 2-bit compressed rendering for large UI fonts
Refactor UI font generation to support different rendering modes based on size. Small UI fonts (12pt) use 1-bit rendering for crisp display and reduced flash usage. Large UI fonts (14pt) now use 2-bit compressed rendering to stay smooth when scaled up on touch/high-density boards. Extract font generation logic into reusable generate_ui_font() function.
2026-06-17 01:18:17 -04:00
Justin Mitchell 4a17d21d80 Add icon generation script and list scroll helper
Introduce gen_icons.sh to regenerate UI icons from a manifest file using the FreeInk SDK's Lucide icon set. Add icons.manifest mapping UI icons to Lucide names. Refactor vertical swipe list scrolling into a reusable wasListScroll() helper method used by both reader chapter selection and settings activities. Add static assertion to ensure ThemeMetrics scaling stays in sync with struct changes.
2026-06-16 00:15:18 -04:00
Justin Mitchell 1c63847631 Cleanup pass
Removed the getFontXHeight method from GfxRenderer interface. Updated documentation to clarify that getFontCapHeight returns the 'H' glyph height for optical alignment, and improved the comment for getTextVisualCenterOffset to be more concise.
2026-06-15 23:55:19 -04:00
Justin Mitchell 4a5b45409f Add icon rendering and list scroll gesture support
Implement orientation-aware icon rendering using freeink::Icon format that applies rotation transforms per-pixel. Add wasListScroll helper for touch-based list navigation with swipe gestures. Switch vertical centering from x-height to cap-height for better visual alignment with capital-led UI labels.
2026-06-15 23:37:49 -04:00
Justin Mitchell e103cdfd11 Add UI scaling support for touch vs button devices
Introduces uiScale() method to apply per-board UI scaling based on device type (1.0 for button devices, >1 for touch devices to make UI elements finger-sized). Adds scaledMetrics member to store scaled theme metrics that are returned by getMetrics().
2026-06-15 22:42:33 -04:00
Justin Mitchell cef0d267b7 Clarify touch gesture and input API comments
Simplify and clarify documentation for touch gesture detection (back gesture, item taps, long-press) and board detection flags. No functional changes, just improved comment readability.
2026-06-15 19:42:09 -04:00
Justin Mitchell 9f3dddabe6 Add touch gesture to open reader menu
Implements a press-and-hold gesture in the center touch zone (400ms threshold) that opens the reader menu, mirroring the Confirm button behavior. The center third of the screen is now reserved for this menu gesture, while left and right thirds continue to handle page turns. Applied to both EPUB and XTC readers.
2026-06-15 17:16:59 -04:00
Justin Mitchell 350fc8472a Add touch reader controls translation string
Adds STR_TOUCH_READER_CONTROLS translation key across all supported languages.
2026-06-15 17:02:48 -04:00
Justin Mitchell 79ef9d6a35 Add touch reader controls for page navigation
Implements tap zones on touch devices for page back/forward navigation and press-and-hold actions, mirroring physical button behavior. Adds isXteinkDevice() helper to distinguish Xteink X3/X4 boards from touch devices. The sunlight fading fix setting is now exclusive to Xteink devices, while touch devices get the new touch reader controls toggle instead.
2026-06-15 17:01:42 -04:00
Justin Mitchell 0036a220be Split Rect constructor into default and explicit ctors
Separate the default constructor from the parameterized constructor to allow value-initialization of Rect arrays without routing through an explicit constructor. The parameterized constructor remains explicit to prevent implicit int-to-Rect conversions. Member variables now use in-class initializers.
2026-06-15 16:39:32 -04:00
Justin Mitchell 8ad538c98b Fix back gesture to use logical screen coordinates
Changed the fallback corner gesture detection to use logical (orientation-mapped) coordinates instead of native panel coordinates. This ensures the gesture area remains in the visual top-left corner across all screen orientations, particularly for screens without a header or Back button target like the reader.
2026-06-15 16:28:23 -04:00
Justin Mitchell f1b5da25b0 Expand continue-reading card clickable area
Make the entire continue-reading card (cover, title, and gray area) clickable instead of just the cover photo. The clickable area now spans the full tile width rather than just the cover width plus padding.
2026-06-15 16:16:25 -04:00
Justin Mitchell d739827db2 Add touch-down and long-press input detection
Implement touch-down event detection to provide immediate visual feedback when touching list items, mirroring button navigation behavior. Add long-press detection (500ms threshold) to distinguish between tap and hold gestures. Apply touch-down selection updates to file browser, home menu, and recent books activities.
2026-06-15 16:12:07 -04:00
Justin Mitchell 9176e76f5a Add Tab and Cover touch kinds to TouchRegistry
Extends the Kind enum with two new touch interaction types: Tab (2) and Cover (3), enabling support for additional UI element touch handling.
2026-06-15 15:47:33 -04:00
Justin Mitchell 7294610894 Add touch-to-logical coordinate mapping for UI
Implements tapToLogical() to convert normalized touch coordinates to orientation-aware logical screen coordinates. Adds TouchRegistry hit testing for interactive UI elements and header back button. Touch gestures now work correctly across all screen orientations (Portrait, Landscape, etc.) by inverting the rotateCoordinates transform.
2026-06-15 15:32:22 -04:00
Justin Mitchell a51098725d Support multi-I2C and fix ESP32-S3 USB logging
Add dynamic I2C bus selection for battery gauge to support boards with multiple I2C controllers (e.g., Sticky uses Wire1 to avoid conflicts with GT911 touch). Fix logging on ESP32-S3 USB-Serial-JTAG by using esp_rom_printf instead of Serial, which incorrectly reports disconnected state.
2026-06-15 13:52:16 -04:00
Justin Mitchell 6acecd8ea6 Use board config for battery monitoring setup
Replace hardcoded X3-specific I2C pins and frequencies with values from the active board profile. Add support for boards with I2C fuel gauges (X3, LilyGo, Sticky) and ADC-based battery sensing (X4, M5Paper). Skip ADC setup when batteryAdc is unassigned to prevent GPIO mux faults.
2026-06-15 12:29:24 -04:00
Justin Mitchell 9b7b9e9094 Skip X3 fingerprint probe on non-C3 boards
Prevent I2C pin collision on S3/ESP32 boards by skipping the X3/X4 fingerprint probe. The probe's I2C pins (GPIO20/0) conflict with onboard peripherals like the BQ27220 gauge and reconfigure strapping pins. Force deviceType to X4 for non-C3 builds.
2026-06-15 12:26:21 -04:00
Justin Mitchell 204aff6be6 Merge remote-tracking branch 'origin/master' into feat-touch 2026-06-15 12:16:39 -04:00
Justin Mitchell 28d8ad565f Fix SPI bus initialization for non-C3 boards
Change SPI pre-claim logic to only apply on C3/Xteink hardware where EPD_* pins are hardcoded. Other boards (M5Paper, Sticky) need to initialize SPI from BoardConfig::ACTIVE pins to avoid incorrect pin assignments for display and SD card.
2026-06-15 12:14:19 -04:00
Justin Mitchell ed6690c0cc Suppress cppcheck warnings for build-time switch
Add cppcheck-suppress comments for knownConditionTrueFalse warnings related to CROSSPOINT_SHOW_BUTTON_HINTS, which is a build-time configuration switch that may appear as a constant condition during static analysis.
2026-06-08 14:31:55 -04:00
Justin Mitchell 1bc154da8b swap SDK submodule from open-x4 to freeink + add m5paper_v11 env
Adds driver support for the m5paper via the new freeink sdk
2026-06-08 14:18:08 -04:00
Justin Mitchell c308520f9c Add touch tap gesture for back navigation
Implements a reusable touch tap gesture in the top-left corner that maps to the Back button. The gesture is automatically integrated into wasPressed/wasReleased for Back button, making it available across all screens without per-activity code. Also fixes low-power CPU frequency floor to 80 MHz on PSRAM boards to prevent SD write and PSRAM instability caused by APB clock dropping below safe threshold.
2026-06-08 14:01:30 -04:00
Justin Mitchell 38ad4f9036 Battery, EPD, and SD support for m5paper
Also hide button hints
2026-06-08 12:47:43 -04:00
176 changed files with 10362 additions and 3237 deletions
+5 -1
View File
@@ -67,6 +67,10 @@ 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
@@ -245,7 +249,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.~~ (Unmaintained)
- [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.
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
+12 -21
View File
@@ -5,7 +5,6 @@ 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)
@@ -15,11 +14,7 @@ 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)
- [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.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
- [3.6 Settings](#36-settings)
- [3.6.1 Display](#361-display)
- [3.6.2 Reader](#362-reader)
@@ -28,25 +23,21 @@ 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
+3 -5
View File
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 28
### Version 25
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 28 includes:
Version 25 includes:
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
image rendering mode, and Focus Reading
@@ -105,8 +105,6 @@ Version 28 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:
@@ -115,7 +113,7 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 28
#define EXPECTED_VERSION 25
#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; render-time overlay bits do not affect font selection.
// Extract font style bits (ignore UNDERLINE bit for font selection)
const bool hasBold = (style & BOLD) != 0;
const bool hasItalic = (style & ITALIC) != 0;
-4
View File
@@ -17,7 +17,6 @@ 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)
@@ -28,9 +27,6 @@ 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,3 +37,5 @@
#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,6 +94,13 @@ 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",
+7 -35
View File
@@ -33,7 +33,6 @@ import sys
import tempfile
import threading
import time
import socket
import urllib.request
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
@@ -50,44 +49,17 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
_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.
"""
def download_font(url: str, dest: Path) -> Path:
"""Download a font file if not already cached. Returns the local path."""
if dest.exists():
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
print(f" Downloading {dest.name}...")
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
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
size_kb = dest.stat().st_size / 1024
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
return dest
+31 -12
View File
@@ -28,23 +28,42 @@ 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
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"
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"
done
done
+1 -10
View File
@@ -132,16 +132,7 @@ 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 ─────────────────────────────────────────────────────────
+71 -13
View File
@@ -14,8 +14,47 @@ 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() {
@@ -375,6 +414,7 @@ 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;
}
@@ -392,11 +432,13 @@ bool BookMetadataCache::load() {
serialization::readPod(bookFile, spineCount);
serialization::readPod(bookFile, tocCount);
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);
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;
}
loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
@@ -404,6 +446,7 @@ 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 {};
@@ -418,11 +461,16 @@ 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 {};
@@ -437,24 +485,34 @@ 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;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
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 {};
}
return entry;
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
TocEntry entry;
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);
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 {};
}
return entry;
}
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include <HalStorage.h>
#include <freertos/semphr.h>
#include <algorithm>
#include <deque>
@@ -54,6 +55,7 @@ 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 {
@@ -85,8 +87,14 @@ class BookMetadataCache {
BookMetadata coreMetadata;
explicit BookMetadataCache(std::string cachePath)
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
~BookMetadataCache() = default;
: cachePath(std::move(cachePath)),
lutOffset(0),
spineCount(0),
tocCount(0),
loaded(false),
buildMode(false),
ioMutex(xSemaphoreCreateRecursiveMutex()) {}
~BookMetadataCache();
// Building phase (stream to disk immediately)
bool beginWrite();
+108 -512
View File
@@ -2,7 +2,6 @@
#include <HalStorage.h>
#include <Logging.h>
#include <Memory.h>
#include <Serialization.h>
#include "Epub/css/CssParser.h"
@@ -11,59 +10,34 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// 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;
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
constexpr uint8_t SECTION_FILE_VERSION = 27;
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", builtPageCount_);
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
return 0;
}
const uint32_t position = file.position();
if (!page->serialize(file)) {
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
return 0;
}
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
LOG_DBG("SCT", "Page %d processed", 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_;
}
pageCount++;
return position;
}
@@ -82,9 +56,7 @@ 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");
// 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, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
serialization::writePod(file, lineCompression);
serialization::writePod(file, extraParagraphSpacing);
@@ -111,18 +83,16 @@ 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 && version != SECTION_FILE_PARTIAL_VERSION) {
if (version != SECTION_FILE_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;
@@ -157,42 +127,14 @@ 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%s", pageCount, filePartial ? " (partial)" : "");
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
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;
@@ -212,43 +154,8 @@ 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 htmlDir = epub->getCachePath() + "/html";
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
// Create cache directory if it doesn't exist
{
@@ -256,101 +163,62 @@ bool Section::startBuild(const int fontId, const float lineCompression, const bo
Storage.mkdir(sectionsDir.c_str());
}
// 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");
}
// 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
}
if (!streamed) {
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
return false;
// Remove any incomplete file from previous attempt before retrying
if (Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
}
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
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();
// 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 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");
}
}
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
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)) {
return false;
}
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
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;
std::vector<PageLutEntry> lut = {};
// Derive the content base directory and image cache path prefix for the parser
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) + "_";
size_t lastSlash = localPath.find_last_of('/');
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
CssParser* cssParser = nullptr;
if (embeddedStyle) {
ctx->cssParser = epub->getCssParser();
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
cssParser = epub->getCssParser();
if (cssParser) {
if (!cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
}
}
}
@@ -367,367 +235,104 @@ bool Section::startBuild(const int fontId, const float lineCompression, const bo
}
}
// 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});
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});
},
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(binTmpPath().c_str());
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
return false;
}
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
build_ = std::move(ctx);
success = visitor.parseAndBuildPages();
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).
Storage.remove(tmpHtmlPath.c_str());
if (!success) {
LOG_ERR("SCT", "Failed to parse XML and build pages");
// Explicitly close() file before calling Storage.remove()
file.close();
Storage.remove(binTmpPath().c_str());
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return false;
};
}
const uint32_t lutOffset = file.position();
for (const auto& entry : build_->lut) {
bool hasFailedLutRecords = false;
// Write LUT
for (const auto& entry : lut) {
if (entry.fileOffset == 0) {
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
return failCommit();
hasFailedLutRecords = true;
break;
}
serialization::writePod(file, entry.fileOffset);
}
// 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 = build_->parser->getAnchors();
uint16_t anchorCount = 0;
for (const auto& [anchor, page] : anchors) {
if (!asPartial || page < builtPageCount_) anchorCount++;
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;
}
serialization::writePod(file, anchorCount);
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
const uint32_t anchorMapOffset = file.position();
const auto& anchors = visitor.getAnchors();
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
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>(build_->lut.size()));
for (const auto& entry : build_->lut) {
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
for (const auto& entry : lut) {
serialization::writePod(file, entry.paragraphIndex);
}
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
for (const auto& entry : build_->lut) {
for (const auto& entry : lut) {
serialization::writePod(file, entry.listItemIndex);
}
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_);
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
serialization::writePod(file, pageCount);
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();
// 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;
if (cssParser) {
cssParser->clear();
}
return true;
}
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)) {
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return nullptr;
}
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
uint32_t lutOffset;
serialization::readPod(f, lutOffset);
f.seek(lutOffset + sizeof(uint32_t) * page);
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
uint32_t pagePos;
serialization::readPod(f, pagePos);
f.seek(pagePos);
serialization::readPod(file, pagePos);
file.seek(pagePos);
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);
auto page = Page::deserialize(file);
// Explicit close() required: member variable persists beyond function scope
file.close();
return page;
}
std::string Section::getTextFromSectionFile() {
std::string fullText;
auto p = loadPage(currentPage);
auto p = this->loadPageFromSectionFile();
if (p) {
for (const auto& el : p->elements) {
if (el->getTag() == TAG_PageLine) {
@@ -756,15 +361,6 @@ 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);
+7 -105
View File
@@ -3,14 +3,11 @@
#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;
@@ -24,67 +21,16 @@ 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;
// 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();
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;
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);
@@ -93,56 +39,12 @@ class Section {
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, bool focusReadingEnabled,
const std::function<void()>& popupFn = nullptr);
// 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::unique_ptr<Page> loadPageFromSectionFile();
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;
+5 -71
View File
@@ -21,39 +21,6 @@ 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];
@@ -92,51 +59,18 @@ 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) {
continue;
}
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = words[i];
int lineStartX = wordX;
int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
const int underlineY = wordY + ascender + 2;
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
lineWidth = (lineWidth + 1) / 2;
underlineWidth = (underlineWidth + 1) / 2;
}
// 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();
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
}
}
flushDecorations();
}
bool TextBlock::serialize(HalFile& file) const {
+13 -15
View File
@@ -67,6 +67,13 @@ 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
@@ -245,20 +252,11 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
}
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
// 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;
// text-decoration can have multiple space-separated values
if (icontainsAscii(val, "underline")) {
return CssTextDecoration::Underline;
}
return CssTextDecoration::None;
}
CssLength CssParser::interpretLength(std::string_view val) {
@@ -870,7 +868,7 @@ bool CssParser::loadFromCache() {
rulesBySelector_.clear();
return false;
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
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 = 7;
static constexpr uint8_t CSS_CACHE_VERSION = 6;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
+2 -13
View File
@@ -52,19 +52,8 @@ 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. 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);
// Text decoration options
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Display options - only None and Block are relevant for e-ink rendering
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
+93 -137
View File
@@ -34,7 +34,6 @@ 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"};
@@ -90,50 +89,13 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
}
}
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
// Update effective bold/italic/underline 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;
effectiveTextDecoration =
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
effectiveUnderline =
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
effectiveDirectionDefined = currentCssStyle.hasDirection();
effectiveDirection = currentCssStyle.direction;
effectiveSup = false;
@@ -147,10 +109,8 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
if (entry.hasItalic) {
effectiveItalic = entry.italic;
}
// 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.hasUnderline) {
effectiveUnderline = entry.underline;
}
if (entry.hasDirection) {
effectiveDirectionDefined = true;
@@ -204,6 +164,7 @@ 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;
@@ -213,7 +174,9 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (isItalic) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
}
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
if (isUnderline) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
}
if (effectiveSup) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
} else if (effectiveSub) {
@@ -457,15 +420,14 @@ 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();
@@ -798,11 +760,12 @@ 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.
// Apply underline style to visually indicate the link
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
StyleStackEntry entry;
entry.depth = self->depth;
entry.hasTextDecoration = true;
entry.textDecoration = CssTextDecoration::Underline;
entry.hasUnderline = true;
entry.underline = true;
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -874,14 +837,23 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
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;
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::LineThrough, cssStyle);
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} 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) {
@@ -898,7 +870,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyTextDecorationToEntry(entry, cssStyle);
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -918,7 +893,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
applyTextDecorationToEntry(entry, cssStyle);
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -957,7 +935,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyTextDecorationToEntry(entry, cssStyle);
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
if (cssStyle.hasVerticalAlign()) {
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
@@ -1169,8 +1150,9 @@ 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;
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
const bool headerOrBlockTag = isHeaderOrBlock(name);
const bool tableStructuralTag = isTableStructuralTag(name);
@@ -1189,8 +1171,7 @@ 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)) ||
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
if (shouldFlush) {
@@ -1249,6 +1230,11 @@ 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) {
@@ -1275,9 +1261,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
}
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
bool ChapterHtmlSlimParser::beginParse() {
bool ChapterHtmlSlimParser::parseAndBuildPages() {
// 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.
@@ -1295,78 +1279,67 @@ bool ChapterHtmlSlimParser::beginParse() {
paragraphAlignmentBlockStyle.alignment = align;
startNewTextBlock(paragraphAlignmentBlockStyle);
xmlParser_ = XML_ParserCreate(nullptr);
if (!xmlParser_) {
XML_Parser parser = XML_ParserCreate(nullptr);
int done;
if (!parser) {
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(xmlParser_, defaultHandlerExpand);
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
destroyXmlParser(xmlParser_);
xmlParser_ = nullptr;
HalFile file;
if (!Storage.openFileForRead("EHP", filepath, file)) {
destroyXmlParser(parser);
return false;
}
// Get file size to decide whether to show indexing popup.
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
popupFn();
}
XML_SetUserData(xmlParser_, this);
XML_SetElementHandler(xmlParser_, startElement, endElement);
XML_SetCharacterDataHandler(xmlParser_, characterData);
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
parseStartTime_ = millis();
return true;
}
// 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;
}
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;
}
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
if (len == 0 && file.available() > 0) {
LOG_ERR("EHP", "File read error");
destroyXmlParser(parser);
file.close();
return false;
}
if (len == 0 && parseFile_.available() > 0) {
LOG_ERR("EHP", "File read error");
return ParseStatus::Error;
}
done = file.available() == 0;
const int done = parseFile_.available() == 0;
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);
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();
destroyXmlParser(parser);
file.close();
// Process last page if there is still text
if (currentTextBlock) {
@@ -1384,23 +1357,6 @@ bool ChapterHtmlSlimParser::finishParse() {
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;
+4 -38
View File
@@ -1,6 +1,5 @@
#pragma once
#include <HalStorage.h>
#include <expat.h>
#include <climits>
@@ -32,6 +31,7 @@ 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,8 +60,7 @@ class ChapterHtmlSlimParser {
int depth = 0;
bool hasBold = false, bold = false;
bool hasItalic = false, italic = false;
bool hasTextDecoration = false;
CssTextDecoration textDecoration = CssTextDecoration::None;
bool hasUnderline = false, underline = false;
bool hasDirection = false;
CssTextDirection direction = CssTextDirection::Ltr;
bool hasSup = false, sup = false;
@@ -72,7 +71,7 @@ class ChapterHtmlSlimParser {
CssStyle currentCssStyle;
bool effectiveBold = false;
bool effectiveItalic = false;
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
bool effectiveUnderline = false;
bool effectiveDirectionDefined = false;
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
bool effectiveSup = false;
@@ -97,25 +96,12 @@ 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);
@@ -155,28 +141,8 @@ class ChapterHtmlSlimParser {
imageBasePath(imageBasePath),
tocAnchors(std::move(tocAnchors)) {}
~ChapterHtmlSlimParser();
// One-shot parse: builds every page before returning (begin + step* + finish).
~ChapterHtmlSlimParser() = default;
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,7 +150,6 @@ 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.");
+40 -48
View File
@@ -79,53 +79,6 @@ 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
@@ -133,7 +86,46 @@ void sortFileList(std::vector<std::string>& strs) {
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
return naturalLess(str1, str2);
// 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';
});
}
-4
View File
@@ -11,10 +11,6 @@ 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);
/**
+97 -36
View File
@@ -3,6 +3,7 @@
#include <BidiUtils.h>
#include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Icon.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <Utf8.h>
@@ -100,6 +101,23 @@ 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,
@@ -282,17 +300,8 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
// 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);
}
// Black (also paints over the grays in BW mode)
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
@@ -374,7 +383,7 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF
return 0;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -416,7 +425,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
@@ -1063,21 +1072,20 @@ 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 size) const {
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
// icon's position to 8px (one byte) along the rotated axis, which prevents it
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
// only called by the UI themes, which all render in forced Portrait.
const int rowBytes = (size + 7) / 8;
for (int row = 0; row < size; row++) {
for (int col = 0; col < size; col++) {
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
if (ink) {
drawPixel(x + (size - 1 - row), y + col, true);
void GfxRenderer::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);
}
}
}
@@ -1491,6 +1499,38 @@ 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.
@@ -1582,7 +1622,7 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1603,7 +1643,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
@@ -1617,7 +1657,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(fontId);
const auto fontIt = fontMap.find(remapUiFont(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
@@ -1649,7 +1689,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
return fp4::toPixel(widthFP);
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1685,7 +1725,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1695,7 +1735,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
}
int GfxRenderer::getLineHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1705,7 +1745,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
}
int GfxRenderer::getTextHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1713,6 +1753,27 @@ 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
@@ -1720,7 +1781,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
return;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
+30 -10
View File
@@ -13,6 +13,9 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 };
class FontCacheManager;
class SdCardFont;
namespace freeink {
struct Icon;
}
#include <cstring>
#include <map>
@@ -44,13 +47,6 @@ 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;
@@ -58,6 +54,13 @@ 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
@@ -108,6 +111,14 @@ 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.
@@ -148,6 +159,11 @@ 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
@@ -190,7 +206,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 size) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) 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;
@@ -215,6 +231,12 @@ 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
@@ -231,8 +253,6 @@ 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
+3 -4
View File
@@ -65,11 +65,10 @@ 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: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
@@ -238,6 +237,7 @@ 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,8 +297,7 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
STR_LINK: "[спасылка]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
STR_SLEEP_NEVER: "Ніколі"
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -274,6 +273,7 @@ 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,8 +306,7 @@ 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_STEP_HINT_FRONT: "Botons frontals:"
STR_STEP_HINT_SIDE: "Botons laterals:"
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
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)"
+4 -5
View File
@@ -58,18 +58,17 @@ 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ádání"
STR_CAT_CONTROLS: "Ovládací prvky"
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"
@@ -245,6 +244,7 @@ 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,6 +274,5 @@ 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_STEP_HINT_FRONT: "Přední tlačítka:"
STR_STEP_HINT_SIDE: "Boční tlačítka:"
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -268,6 +267,7 @@ 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,8 +299,7 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Aldrig"
STR_STEP_HINT_FRONT: "Frontknapper:"
STR_STEP_HINT_SIDE: "Sideknapper:"
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -268,6 +267,7 @@ 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,8 +299,7 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nooit"
STR_STEP_HINT_FRONT: "Voorknoppen:"
STR_STEP_HINT_SIDE: "Zijknoppen:"
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
STR_SCREENSHOT_BUTTON: "Screenshot maken"
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
+3 -6
View File
@@ -66,17 +66,14 @@ 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"
@@ -301,6 +298,7 @@ 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"
@@ -335,8 +333,7 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Never"
STR_STEP_HINT_FRONT: "Front buttons:"
STR_STEP_HINT_SIDE: "Side buttons:"
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
STR_ADD_SERVER: "Add Server"
STR_SERVER_NAME: "Server Name"
STR_NO_SERVERS: "No OPDS servers configured"
+3 -4
View File
@@ -65,11 +65,10 @@ 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"
@@ -243,6 +242,7 @@ 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,6 +272,5 @@ 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_STEP_HINT_FRONT: "Etupainikkeet:"
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
+3 -4
View File
@@ -65,8 +65,6 @@ 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é"
@@ -74,6 +72,7 @@ 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é"
@@ -269,6 +268,7 @@ 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,8 +300,7 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
STR_LINK: "[lien]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Jamais"
STR_STEP_HINT_FRONT: "Boutons avant :"
STR_STEP_HINT_SIDE: "Boutons latéraux :"
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
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)"
+3 -4
View File
@@ -65,11 +65,10 @@ 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"
@@ -290,6 +289,7 @@ 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,8 +323,7 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
STR_LINK: "[Link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
STR_SLEEP_NEVER: "Nie"
STR_STEP_HINT_FRONT: "Vordere Tasten:"
STR_STEP_HINT_SIDE: "Seitentasten:"
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
STR_ADD_SERVER: "Server hinzufügen"
STR_SERVER_NAME: "Servername"
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
+3 -4
View File
@@ -66,8 +66,6 @@ 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: "סמן"
@@ -75,6 +73,7 @@ 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: "דלג פרק"
@@ -270,6 +269,7 @@ 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,5 +391,4 @@ STR_BOOKMARK_OPTION: "סימנייה"
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
STR_SLEEP_NEVER: "אף פעם"
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
STR_STEP_HINT_SIDE: "לחצני צד:"
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
+3 -4
View File
@@ -65,8 +65,6 @@ 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ő"
@@ -74,6 +72,7 @@ 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"
@@ -265,6 +264,7 @@ 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,8 +296,7 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
STR_SLEEP_NEVER: "Soha"
STR_STEP_HINT_FRONT: "Elülső gombok:"
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
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)"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -276,6 +275,7 @@ 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,8 +308,7 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Mai"
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
STR_SCREENSHOT_BUTTON: "Screenshot"
STR_ADD_SERVER: "Aggiungi server"
STR_SERVER_NAME: "Nome server"
+3 -4
View File
@@ -64,11 +64,10 @@ 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: "Канагаттандырылмагандыктарыныздан"
@@ -239,6 +238,7 @@ 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,8 +295,7 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
STR_LINK: "[сілтеме]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
STR_SLEEP_NEVER: "Ешқашан"
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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ą"
@@ -265,6 +264,7 @@ 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,8 +296,7 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
STR_LINK: "[nuoroda]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
STR_SLEEP_NEVER: "Niekada"
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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ł."
@@ -277,6 +276,7 @@ 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,8 +309,7 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nigdy"
STR_STEP_HINT_FRONT: "Przyciski przednie:"
STR_STEP_HINT_SIDE: "Przyciski boczne:"
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
STR_ADD_SERVER: "Dodaj serwer"
STR_SERVER_NAME: "Nazwa serwera"
+29 -138
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 ao carregar pág."
STR_PAGE_LOAD_ERROR: "Erro página"
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 na próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK procurar 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 esta URL no seu navegador"
STR_OPEN_URL_HINT: "Abra este URL 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,38 +50,31 @@ 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 ao dispositivo\""
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o 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 de Pressionar e segurar"
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
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_ORIENTATION: "Alterar orientação"
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
STR_FONT_FAMILY: "Fonte do leitor"
STR_FONT_SIZE: "Tam. fonte UI"
STR_LINE_SPACING: "Espaçamento entre linhas"
@@ -89,7 +82,6 @@ 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"
@@ -137,7 +129,6 @@ 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"
@@ -145,9 +136,6 @@ 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"
@@ -175,21 +163,17 @@ 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 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_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
STR_NO_ENTRIES: "Nenhum entries encontrado"
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_NEXT_PAGE: "Próxima Página »"
STR_PREV_PAGE: "« Página Anterior"
STR_NETWORK_PREFIX: "Rede: "
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
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"
@@ -198,9 +182,10 @@ 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"
@@ -209,8 +194,6 @@ 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"
@@ -221,51 +204,15 @@ STR_DIR_DOWN: "Baixo"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
STR_FILTER_CONTRAST: "Contraste"
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_UI_THEME: "Tema da interface"
STR_THEME_CLASSIC: "Clássico"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
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"
@@ -279,11 +226,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 para cada função"
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
STR_UNASSIGNED: "Não atribuído"
STR_ALREADY_ASSIGNED: "Já atribuído"
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_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
STR_REMAP_CANCEL_HINT: "Botão lateral 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)"
@@ -293,15 +240,14 @@ 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 do documento..."
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
STR_CALC_HASH: "Calculando hash documento..."
STR_HASH_FAILED: "Falha ao calcular o hash documento"
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
STR_UPLOAD_PROGRESS: "Enviando progresso..."
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
@@ -319,69 +265,14 @@ 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_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_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
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"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -268,6 +267,7 @@ 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,8 +299,7 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
STR_LINK: "[link]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Niciodată"
STR_STEP_HINT_FRONT: "Butoane frontale:"
STR_STEP_HINT_SIDE: "Butoane laterale:"
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
STR_SCREENSHOT_BUTTON: "Captură ecran"
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
+3 -4
View File
@@ -66,8 +66,6 @@ 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: "Заглушки"
@@ -75,6 +73,7 @@ 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: "Ничего"
@@ -300,6 +299,7 @@ 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,8 +333,7 @@ STR_LINK: "[ссылка]"
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
STR_SLEEP_NEVER: "Никогда"
STR_STEP_HINT_FRONT: "Передние кнопки:"
STR_STEP_HINT_SIDE: "Боковые кнопки:"
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
STR_ADD_SERVER: "Добавить сервер"
STR_SERVER_NAME: "Имя сервера"
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
+3 -4
View File
@@ -66,8 +66,6 @@ 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"
@@ -75,6 +73,7 @@ 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"
@@ -296,6 +295,7 @@ 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,8 +329,7 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
STR_ADD_SERVER: "Pridať server"
STR_SERVER_NAME: "Názov servera"
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -265,6 +264,7 @@ 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,8 +296,7 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
STR_LINK: "[povezava]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikoli"
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
STR_STEP_HINT_SIDE: "Stranski gumbi:"
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
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)"
+3 -4
View File
@@ -65,8 +65,6 @@ 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"
@@ -74,6 +72,7 @@ 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"
@@ -299,6 +298,7 @@ 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,8 +332,7 @@ 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_STEP_HINT_FRONT: "Botones frontales:"
STR_STEP_HINT_SIDE: "Botones laterales:"
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
STR_ADD_SERVER: "Agregar servidor"
STR_SERVER_NAME: "Nombre de servidor"
+3 -4
View File
@@ -66,8 +66,6 @@ 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"
@@ -75,6 +73,7 @@ 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"
@@ -297,6 +296,7 @@ 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,8 +329,7 @@ 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_STEP_HINT_FRONT: "Framknappar:"
STR_STEP_HINT_SIDE: "Sidoknappar:"
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
STR_ADD_SERVER: "Lägg till server"
STR_SERVER_NAME: "Servernamn"
+3 -4
View File
@@ -64,11 +64,10 @@ 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"
@@ -243,6 +242,7 @@ 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,8 +290,7 @@ STR_IMAGES_SUPPRESS: "Bastır"
STR_LINK: "[bağlantı]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
STR_SLEEP_NEVER: "Asla"
STR_STEP_HINT_FRONT: "Ön tuşlar:"
STR_STEP_HINT_SIDE: "Yan tuşlar:"
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
STR_NO_FILES_FOUND: "Dosya bulunamadı"
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
STR_PREVIEW: "Önizleme"
+3 -4
View File
@@ -66,8 +66,6 @@ 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: "Заглушка"
@@ -75,6 +73,7 @@ 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: "Немає"
@@ -296,6 +295,7 @@ 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,8 +330,7 @@ STR_LINK: "[посилання]"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
STR_SLEEP_NEVER: "Ніколи"
STR_STEP_HINT_FRONT: "Передні кнопки:"
STR_STEP_HINT_SIDE: "Бічні кнопки:"
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
STR_ADD_SERVER: "Додати сервер"
STR_SERVER_NAME: "Назва сервера"
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
+3 -4
View File
@@ -66,8 +66,6 @@ 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"
@@ -75,6 +73,7 @@ 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"
@@ -91,8 +90,7 @@ 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_STEP_HINT_FRONT: "Botons frontals:"
STR_STEP_HINT_SIDE: "Botons laterals:"
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
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"
@@ -279,6 +277,7 @@ 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"
+3 -4
View File
@@ -66,8 +66,6 @@ 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ế"
@@ -75,6 +73,7 @@ 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"
@@ -296,6 +295,7 @@ 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,8 +329,7 @@ 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_STEP_HINT_FRONT: "Nút trước:"
STR_STEP_HINT_SIDE: "Nút bên:"
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
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"
+5 -10
View File
@@ -278,18 +278,13 @@ 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++;
} else if (name == "li") {
paragraphCount++;
}
if (paragraphCount == targetParagraph) {
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
stopped = true;
XML_StopParser(parser, XML_FALSE);
if (paragraphCount == targetParagraph) {
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
stopped = true;
XML_StopParser(parser, XML_FALSE);
}
}
depth++;
+4 -5
View File
@@ -709,13 +709,12 @@ 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);
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
// 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) {
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,5 +1,8 @@
#include "Logging.h"
#include <BoardConfig.h>
#include <esp_rom_sys.h>
#include <string>
#define MAX_ENTRY_LEN 256
@@ -59,9 +62,19 @@ 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);
}
+4 -1
View File
@@ -27,7 +27,10 @@ won't trigger deprecation warnings.
#define LOG_LEVEL 0
#endif
static HWCDC& logSerial = Serial;
// 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;
void logPrintf(const char* level, const char* origin, const char* format, ...);
+4 -5
View File
@@ -10,11 +10,7 @@ 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); }
@@ -24,6 +20,10 @@ 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,7 +54,6 @@ 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.resize(filename.length() - 4);
filename = filename.substr(0, filename.length() - 4);
}
return filename;
+62 -1
View File
@@ -18,8 +18,11 @@ 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 = false;
_available = _sdkRtc.begin();
_usesSdkRtc = _available;
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
return;
}
@@ -57,6 +60,24 @@ 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);
@@ -131,6 +152,25 @@ 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
@@ -168,6 +208,27 @@ 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,6 +1,7 @@
#pragma once
#include <Arduino.h>
#include <Rtc.h>
#include <Wire.h>
#include "HalGPIO.h"
@@ -10,6 +11,8 @@ 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;
+79 -17
View File
@@ -1,5 +1,6 @@
#include <HalGPIO.h>
#include <Logging.h>
#include <PowerManager.h>
#include <Preferences.h>
#include <SPI.h>
#include <Wire.h>
@@ -192,14 +193,44 @@ 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() {
@@ -225,30 +256,55 @@ 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();
}
// 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();
freeink::PowerManager::armPowerButtonWakeup();
freeink::PowerManager::deepSleep();
}
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
@@ -257,11 +313,12 @@ 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) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
} while (inputMgr.isPressed(BTN_POWER) && millis() - holdStart < requiredDurationMs);
if (millis() - holdStart < requiredDurationMs) {
startDeepSleep();
}
} else {
@@ -282,8 +339,10 @@ bool HalGPIO::isUsbConnected() const {
}
return false;
}
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;
if (BoardConfig::ACTIVE.usbDetect < 0) {
return false;
}
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
}
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
@@ -292,8 +351,11 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
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) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
+33
View File
@@ -59,6 +59,11 @@ 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();
@@ -72,6 +77,34 @@ 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();
+45 -25
View File
@@ -1,6 +1,7 @@
#include "HalPowerManager.h"
#include <Logging.h>
#include <PowerManager.h>
#include <WiFi.h>
#include <esp_sleep.h>
@@ -10,15 +11,32 @@
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() {
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);
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);
_batteryUseI2C = true;
} else {
pinMode(BAT_GPIO0, INPUT);
} 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);
}
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
@@ -76,22 +94,19 @@ 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);
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();
#endif
freeink::PowerManager::armPowerButtonWakeup();
freeink::PowerManager::deepSleep();
}
uint16_t HalPowerManager::getBatteryPercentage() const {
@@ -101,27 +116,32 @@ uint16_t HalPowerManager::getBatteryPercentage() const {
return _batteryCachedPercent;
}
// 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) {
// 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) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
if (Wire.available() < 2) {
w.requestFrom(gaugeAddr, (uint8_t)2);
if (w.available() < 2) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
const uint8_t lo = w.read();
const uint8_t hi = w.read();
const uint16_t soc = (hi << 8) | lo;
_batteryCachedPercent = soc > 100 ? 100 : soc;
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
// ADC pin from the active profile (X4 GPIO0 / M5Paper GPIO35); default 2:1 divider.
static const BatteryMonitor battery = BatteryMonitor(BoardConfig::ACTIVE.batteryAdc);
// smooth the battery %.
if (_batteryCachedPercent == 0) {
+10 -1
View File
@@ -28,7 +28,16 @@ class HalPowerManager {
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
public:
static constexpr int LOW_POWER_FREQ = 10; // MHz
// 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 unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
+8
View File
@@ -38,6 +38,13 @@ 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;
}
@@ -65,6 +72,7 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
}
__real_panic_print_backtrace(frame, core);
#endif // __riscv
}
}
+36 -1
View File
@@ -26,6 +26,15 @@ 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) {
@@ -52,11 +61,22 @@ bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
}
void HalTiltSensor::begin() {
_backend = Backend::None;
if (!gpio.deviceIsX3()) {
_available = false;
_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");
return;
}
_backend = Backend::Qmi8658;
// Try primary address, then alternate
uint8_t whoami = 0;
_i2cAddr = I2C_ADDR_QMI8658;
@@ -89,6 +109,14 @@ 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;
@@ -111,6 +139,13 @@ 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,6 +1,7 @@
#pragma once
#include <Arduino.h>
#include <Imu.h>
#include <Wire.h>
#include "HalGPIO.h"
@@ -18,8 +19,12 @@ 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()
+78 -10
View File
@@ -35,8 +35,6 @@ 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
@@ -57,7 +55,10 @@ extra_scripts =
pre:scripts/patch_jpegdec.py
post:scripts/register_unit_tests_target.py
; Libraries
; 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).
lib_deps =
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
@@ -65,6 +66,8 @@ 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
@@ -73,35 +76,100 @@ lib_deps =
https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b
links2004/WebSockets @ 2.7.3
[env:default]
; 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]
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 = base
extends = c3
build_flags =
${base.build_flags}
${c3.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 = base
extends = c3
build_flags =
${base.build_flags}
${c3.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 = base
extends = c3
build_flags =
${base.build_flags}
${c3.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
@@ -0,0 +1,26 @@
#!/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"
+1 -8
View File
@@ -6,7 +6,6 @@
#include <Serialization.h>
#include <cstring>
#include <mutex>
#include <string>
#include "I18nKeys.h"
@@ -97,7 +96,6 @@ 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);
}
@@ -108,11 +106,7 @@ bool CrossPointSettings::loadFromFile() {
String json = Storage.readFile(SETTINGS_FILE_JSON);
if (!json.isEmpty()) {
bool resave = false;
bool result;
{
std::lock_guard<std::mutex> lock(_mutex);
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
}
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
if (result && resave) {
if (saveToFile()) {
LOG_DBG("CPS", "Resaved settings to update format");
@@ -172,7 +166,6 @@ 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);
+4 -15
View File
@@ -3,12 +3,9 @@
#include <cstdint>
#include <iosfwd>
#include <mutex>
class CrossPointSettings {
private:
mutable std::mutex _mutex;
// Private constructor for singleton
CrossPointSettings() = default;
@@ -20,10 +17,6 @@ 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,
@@ -173,13 +166,6 @@ 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 {
@@ -216,7 +202,7 @@ class CrossPointSettings {
uint8_t clockHasBeenSynced = 0;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = TEXT_AA_FULL;
uint8_t textAntiAliasing = 1;
// Short power button click behaviour
uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings
@@ -260,6 +246,9 @@ 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,7 +6,6 @@
#include <Serialization.h>
#include <algorithm>
#include <mutex>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 4;
@@ -33,7 +32,6 @@ 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);
}
@@ -43,7 +41,6 @@ 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());
}
}
@@ -70,7 +67,6 @@ 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,18 +1,12 @@
#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;
+4 -2
View File
@@ -5,7 +5,6 @@
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <algorithm>
#include <cstring>
#include <string>
@@ -154,6 +153,10 @@ 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);
@@ -342,7 +345,6 @@ 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;
+187 -3
View File
@@ -1,8 +1,12 @@
#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
@@ -74,9 +78,182 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
// 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::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
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::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
@@ -84,7 +261,14 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
unsigned long MappedInputManager::getHeldTime() const {
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
return touchHeldOverrideMs;
}
touchHeldOverrideValid = false;
return gpio.getHeldTime();
}
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
+37
View File
@@ -7,6 +7,7 @@ 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;
@@ -21,6 +22,33 @@ 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;
@@ -44,4 +72,13 @@ 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,12 +34,10 @@ 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();
}
}
@@ -78,7 +76,6 @@ 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);
@@ -99,12 +96,10 @@ 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();
}
}
+26 -7
View File
@@ -1,6 +1,8 @@
#pragma once
#include <BoardConfig.h>
#include <HalClock.h>
#include <HalGPIO.h>
#include <HalTiltSensor.h>
#include <I18n.h>
#include <SdCardFontRegistry.h>
@@ -104,8 +106,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_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
StrId::STR_COVER_CUSTOM, 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),
@@ -157,11 +159,8 @@ 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),
// 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::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "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),
@@ -261,6 +260,26 @@ 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,8 +6,6 @@
#include <ObfuscationUtils.h>
#include <Serialization.h>
#include <algorithm>
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
@@ -91,7 +89,6 @@ 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);
+1 -2
View File
@@ -56,8 +56,7 @@ 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
// TODO: remove this in near future
// Convenience method to facilitate API transition to ActivityManager.
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
void onSelectBook(const std::string& path);
};
+22 -12
View File
@@ -9,6 +9,7 @@
#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"
@@ -19,15 +20,19 @@
#include "settings/SettingsActivity.h"
#include "util/FullScreenMessageActivity.h"
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
// 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;
void ActivityManager::begin() {
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle, // Task handle
0 // Pin to core 0 (PRO_CPU)
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle // Task handle
);
assert(renderTaskHandle != nullptr && "Failed to create render task");
}
@@ -45,14 +50,18 @@ 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(&activityManagerSpinlock);
taskENTER_CRITICAL(&activityMux);
waiter = waitingTaskHandle;
waitingTaskHandle = nullptr;
taskEXIT_CRITICAL(&activityManagerSpinlock);
taskEXIT_CRITICAL(&activityMux);
if (waiter) {
xTaskNotify(waiter, 1, eIncrement);
}
@@ -140,7 +149,8 @@ void ActivityManager::loop() {
}
}
if (requestedUpdate.exchange(false)) {
if (requestedUpdate) {
requestedUpdate = false;
// Using direct notification to signal the render task to update
// Increment counter so multiple rapid calls won't be lost
if (renderTaskHandle) {
@@ -282,7 +292,7 @@ void ActivityManager::requestUpdateAndWait() {
}
// Atomic section to perform checks
taskENTER_CRITICAL(&activityManagerSpinlock);
taskENTER_CRITICAL(&activityMux);
auto currTaskHandler = xTaskGetCurrentTaskHandle();
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
bool isRenderTask = (currTaskHandler == renderTaskHandle);
@@ -291,7 +301,7 @@ void ActivityManager::requestUpdateAndWait() {
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
waitingTaskHandle = currTaskHandler;
}
taskEXIT_CRITICAL(&activityManagerSpinlock);
taskEXIT_CRITICAL(&activityMux);
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
+1 -2
View File
@@ -4,7 +4,6 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <atomic>
#include <cassert>
#include <memory>
#include <string>
@@ -64,7 +63,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
std::atomic<bool> requestedUpdate{false};
bool requestedUpdate = false;
public:
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
-4
View File
@@ -43,10 +43,6 @@ 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;
+8 -6
View File
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen();
}
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
@@ -219,11 +219,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
}
if (hasGreyscale) {
// 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);
// 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);
} else {
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
if (hasGreyscale) {
@@ -331,5 +333,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
@@ -1,5 +1,6 @@
#include "OpdsBookBrowserActivity.h"
#include <FreeInkUI.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
@@ -10,6 +11,7 @@
#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"
@@ -31,7 +33,6 @@ void OpdsBookBrowserActivity::onEnter() {
currentPath = "";
selectorIndex = 0;
consumeConfirm = false;
consumeBack = false;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
requestUpdate();
@@ -60,10 +61,6 @@ 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)) {
@@ -91,11 +88,28 @@ void OpdsBookBrowserActivity::loop() {
if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
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();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
@@ -178,13 +192,20 @@ 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);
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
i != static_cast<size_t>(selectorIndex));
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.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,7 +33,6 @@ 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;
@@ -51,5 +50,6 @@ 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; }
};
+3 -1
View File
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
}
void CrashActivity::loop() {
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
int tapX = 0;
int tapY = 0;
if (mappedInput.isPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(tapX, tapY)) {
finish();
}
}
+24 -2
View File
@@ -206,7 +206,29 @@ 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);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// 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 (lockNextConfirmRelease) {
lockNextConfirmRelease = false;
return;
@@ -336,7 +358,7 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos);
}
std::string getFileExtension(const std::string& filename) {
std::string getFileExtension(std::string filename) {
if (filename.back() == '/') {
return "";
}
+30 -7
View File
@@ -179,7 +179,29 @@ void HomeActivity::loop() {
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// 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 (selectorIndex < recentBooks.size()) {
onSelectBook(recentBooks[selectorIndex].path);
} else {
@@ -233,7 +255,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, Recent, Transfer, Settings};
std::vector<UIIcon> menuIcons = {Folder, Book, Transfer, Settings};
if (hasOpdsServers) {
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
@@ -246,12 +268,13 @@ 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, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth,
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing +
metrics.homeMenuTopOffset + metrics.buttonHintsHeight)},
static_cast<int>(menuItems.size()),
renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, 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]; });
+18 -1
View File
@@ -44,6 +44,14 @@ 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) {
@@ -63,7 +71,16 @@ void RecentBooksActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
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 (!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);
}
esp_task_wdt_reset();
feedTaskWatchdog();
constexpr int MAX_ITERATIONS = 80;
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
if ((i & 0x07) == 0x07) {
esp_task_wdt_reset();
feedTaskWatchdog();
}
if ((i & 0x0F) == 0x0F) {
yield();
@@ -5,7 +5,6 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <cstddef>
@@ -16,6 +15,7 @@
#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
esp_task_wdt_reset();
feedTaskWatchdog();
// 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) {
esp_task_wdt_reset();
feedTaskWatchdog();
}
// Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) {
@@ -30,8 +30,17 @@ void NetworkModeSelectionActivity::loop() {
return;
}
// Handle confirm button - select current option
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
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)) {
NetworkMode mode = NetworkMode::JOIN_NETWORK;
if (selectedIndex == 1) {
mode = NetworkMode::CONNECT_CALIBRE;
@@ -42,7 +51,6 @@ void NetworkModeSelectionActivity::loop() {
return;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
requestUpdate();
@@ -77,7 +85,6 @@ 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,6 +409,27 @@ 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);
@@ -442,7 +463,6 @@ void WifiSelectionActivity::loop() {
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
requestUpdate();
-115
View File
@@ -1,115 +0,0 @@
#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
@@ -1,48 +0,0 @@
#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;
};
+119 -336
View File
@@ -231,30 +231,6 @@ 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
@@ -262,33 +238,6 @@ 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();
@@ -349,43 +298,37 @@ void EpubReaderActivity::loop() {
requestUpdate();
}
// 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;
}
}
// 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);
// 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)) {
// 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)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
openReaderMenu();
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));
}
});
}
}
@@ -461,19 +404,15 @@ void EpubReaderActivity::loop() {
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
if (!prevTriggered && !nextTriggered) {
return;
}
// At end of the book with no suggestion menu, forward button goes home and back
// button returns to last page
// At end of the book, 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 {
@@ -485,7 +424,8 @@ void EpubReaderActivity::loop() {
return;
}
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
// Don't skip chapter after screenshot
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
@@ -604,32 +544,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
loadCachedBookmarks();
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
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) {
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
RenderLock lock(*this);
currentSpineIndex = targetSpineIndex;
nextPageNumber = targetPage;
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
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;
}
}
};
@@ -749,7 +668,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->estimatedTotalPages() : cachedChapterTotalPageCount;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
@@ -847,12 +766,7 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
if (isForwardTurn) {
// 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()) {
if (section->currentPage < section->pageCount - 1) {
section->currentPage++;
} else {
// We don't want to delete the section mid-render, so grab the semaphore
@@ -881,7 +795,6 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
requestUpdate();
}
// TODO: Failure handling
void EpubReaderActivity::render(RenderLock&& lock) {
if (!epub) {
return;
@@ -904,11 +817,8 @@ 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();
endOfBookOptions.render(renderer, mappedInput);
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
automaticPageTurnActive = false;
showPendingSyncSaveError();
@@ -943,130 +853,48 @@ 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));
// 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...");
}
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...");
// 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;
}
}
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;
}
} else {
LOG_DBG("ERS", "Cache found, skipping build...");
}
if (pendingPageJump.has_value()) {
section->currentPage = *pendingPageJump;
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
section->currentPage = section->pageCount - 1;
} else {
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()) {
// 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) {
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
section->currentPage = *page;
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
} else {
@@ -1075,6 +903,17 @@ 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));
@@ -1086,57 +925,6 @@ 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) {
@@ -1162,14 +950,9 @@ void EpubReaderActivity::render(RenderLock&& lock) {
updateBookmarkFlag();
{
// 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);
auto p = section->loadPageFromSectionFile();
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
@@ -1186,7 +969,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
}
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
showPendingSyncSaveError();
@@ -1200,28 +984,36 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
}
bool EpubReaderActivity::applyDeferredReposition() {
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
return false;
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
if (!epub || !section || section->pageCount < 2) {
return;
}
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;
}
// 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);
}
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
return changed;
}
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
@@ -1241,8 +1033,7 @@ 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 == CrossPointSettings::TEXT_AA_FULL;
const bool fastTextAA = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST;
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) {
@@ -1252,11 +1043,7 @@ 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();
@@ -1273,9 +1060,7 @@ 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);
@@ -1401,10 +1186,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
void EpubReaderActivity::renderStatusBar() const {
// 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.
// Calculate progress in book
const int currentPage = section->currentPage + 1;
const float pageCount = section->estimatedTotalPages();
const float pageCount = section->pageCount;
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
@@ -1435,8 +1219,7 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
section->isBuilding());
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -1527,7 +1310,7 @@ void EpubReaderActivity::addBookmark() {
int pageCount;
{
RenderLock lock(*this);
pageCount = section->estimatedTotalPages();
pageCount = section->pageCount;
currentPage = section->currentPage;
}
@@ -1576,10 +1359,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
currentPageBookmarked = false;
return;
}
const int pageCount = section->estimatedTotalPages();
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount);
const ProgressRange pageRange =
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange);
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
});
}
@@ -1592,9 +1375,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
}
if (section) {
info.currentPage = section->currentPage + 1;
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);
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);
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
@@ -1606,7 +1389,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
+1 -30
View File
@@ -6,7 +6,6 @@
#include <optional>
#include "BookmarkEntry.h"
#include "EndOfBookOptions.h"
#include "EpubReaderMenuActivity.h"
#include "ProgressMapper.h"
#include "activities/Activity.h"
@@ -46,8 +45,6 @@ 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;
@@ -62,37 +59,11 @@ class EpubReaderActivity final : public Activity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
// 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();
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
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