Compare commits
54
Commits
master
...
cecdbefa0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecdbefa0e | ||
|
|
4c5fd653c0 | ||
|
|
05c1e9aa46 | ||
|
|
f3da5e4f06 | ||
|
|
a43061305a | ||
|
|
7ec07b4d9d | ||
|
|
c2c1badc7e | ||
|
|
5776911af0 | ||
|
|
3c5c5e8aa7 | ||
|
|
6f5c5a0900 | ||
|
|
f2b00f6b93 | ||
|
|
44ff313740 | ||
|
|
6f0707e83c | ||
|
|
7b6df60a54 | ||
|
|
1c13913713 | ||
|
|
613371b716 | ||
|
|
0d7a84e3c5 | ||
|
|
4c93950771 | ||
|
|
1d01eebf55 | ||
|
|
9bf6e5f43c | ||
|
|
ca1b833126 | ||
|
|
3e1dd31e53 | ||
|
|
685d4e88f9 | ||
|
|
79b4d63ee2 | ||
|
|
ad3138983b | ||
|
|
793685e76b | ||
|
|
551d29744a | ||
|
|
74a0969cc6 | ||
|
|
06aa1ac2f7 | ||
|
|
57447a56a6 | ||
|
|
5c86bfe1fd | ||
|
|
4cdefb137c | ||
|
|
e7f3406704 | ||
|
|
6f08a88b12 | ||
|
|
c463da994e | ||
|
|
e4d3981469 | ||
|
|
c9d27826f9 | ||
|
|
13bd00bf9d | ||
|
|
fef39da23c | ||
|
|
fd43ca2fe1 | ||
|
|
28255061fb | ||
|
|
43ff1b6644 | ||
|
|
6edeed73c5 | ||
|
|
8ac302018c | ||
|
|
a11517a62d | ||
|
|
cbaa498ccc | ||
|
|
ebebc6f202 | ||
|
|
988652513a | ||
|
|
28d0d6f5d2 | ||
|
|
568831f232 | ||
|
|
d6f5be6b7a | ||
|
|
d30fde2f4d | ||
|
|
6305777b22 | ||
|
|
9ab0b0bfb7 |
@@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/
|
|||||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/skills/
|
!.claude/skills/
|
||||||
|
/managed_components
|
||||||
|
/.dummy
|
||||||
|
dependencies.lock
|
||||||
|
sdkconfig.default
|
||||||
|
sdkconfig.defaults
|
||||||
|
CMakeLists.txt
|
||||||
|
|||||||
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
[submodule "open-x4-sdk"]
|
[submodule "freeink-sdk"]
|
||||||
path = open-x4-sdk
|
path = freeink-sdk
|
||||||
url = https://github.com/crosspoint-reader/community-sdk.git
|
url = https://github.com/Free-Ink/freeink-sdk.git
|
||||||
|
branch = main
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ Mission: Provide a lightweight, high-performance reading experience focused on E
|
|||||||
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
|
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
|
||||||
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
|
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
|
||||||
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
|
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
|
||||||
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the open-x4-sdk or official docs first.
|
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first.
|
||||||
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
|
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
|
||||||
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
|
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
|
||||||
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
|
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
|
||||||
@@ -127,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
|||||||
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
||||||
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
||||||
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
|
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
|
||||||
* open-x4-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
* freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
||||||
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
|
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
|
||||||
|
|
||||||
### Hardware Abstraction Layer (HAL)
|
### Hardware Abstraction Layer (HAL)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
> If you're planning to buy an Xteink device, consider purchasing an **X3/X4 Developer Edition** through https://crosspointreader.com. CrossPoint receives a small share of each sale, helping fund development costs.
|
||||||
|
|
||||||
## What can CrossPoint do?
|
## What can CrossPoint do?
|
||||||
|
|
||||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||||
@@ -67,10 +69,6 @@ USB port or browser before assuming the device is locked. Only reach for the unl
|
|||||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
> 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
|
> 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 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
|
## Install firmware
|
||||||
|
|
||||||
@@ -249,7 +247,7 @@ One of the best things about open source is that anyone can take the code in a d
|
|||||||
|
|
||||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||||
|
|
||||||
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
|
||||||
|
|
||||||
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -5,6 +5,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [CrossPoint User Guide](#crosspoint-user-guide)
|
- [CrossPoint User Guide](#crosspoint-user-guide)
|
||||||
- [1. Hardware Overview](#1-hardware-overview)
|
- [1. Hardware Overview](#1-hardware-overview)
|
||||||
- [Button Layout](#button-layout)
|
- [Button Layout](#button-layout)
|
||||||
|
- [Taking a Screenshot](#taking-a-screenshot)
|
||||||
- [2. Power \& Startup](#2-power--startup)
|
- [2. Power \& Startup](#2-power--startup)
|
||||||
- [Power On / Off](#power-on--off)
|
- [Power On / Off](#power-on--off)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
@@ -14,7 +15,11 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
||||||
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
||||||
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
||||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||||
|
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
|
||||||
|
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
|
||||||
|
- [Uploading Books](#uploading-books)
|
||||||
|
- [Removing a Book](#removing-a-book)
|
||||||
- [3.6 Settings](#36-settings)
|
- [3.6 Settings](#36-settings)
|
||||||
- [3.6.1 Display](#361-display)
|
- [3.6.1 Display](#361-display)
|
||||||
- [3.6.2 Reader](#362-reader)
|
- [3.6.2 Reader](#362-reader)
|
||||||
@@ -23,21 +28,25 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
- [3.6.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.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
- [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)
|
- [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)
|
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
||||||
- [4. Reading Mode](#4-reading-mode)
|
- [4. Reading Mode](#4-reading-mode)
|
||||||
- [Page Turning](#page-turning)
|
- [Page Turning](#page-turning)
|
||||||
- [Chapter Navigation](#chapter-navigation)
|
- [Chapter Navigation](#chapter-navigation)
|
||||||
- [Auto Page Turn](#auto-page-turn)
|
- [Auto Page Turn](#auto-page-turn)
|
||||||
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
||||||
- [Footnote Navigation](#footnote-navigation)
|
- [Footnote Navigation](#footnote-navigation)
|
||||||
- [System Navigation](#system-navigation)
|
- [System Navigation](#system-navigation)
|
||||||
- [Supported Languages](#supported-languages)
|
- [Supported Languages](#supported-languages)
|
||||||
- [5. Reader Menu](#5-reader-menu)
|
- [5. Reader Menu](#5-reader-menu)
|
||||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||||
- [5.2 Bookmarks](#52-bookmarks)
|
- [5.2 Bookmarks](#52-bookmarks)
|
||||||
- [6. Current Limitations & Roadmap](#6-current-limitations--roadmap)
|
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||||
- [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||||
|
|
||||||
## 1. Hardware Overview
|
## 1. Hardware Overview
|
||||||
|
|
||||||
@@ -174,6 +183,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
||||||
- "None" - A blank screen
|
- "None" - A blank screen
|
||||||
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
||||||
|
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
|
||||||
|
|
||||||
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
||||||
|
|
||||||
@@ -186,6 +196,8 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
||||||
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
|
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
|
||||||
|
|
||||||
|
- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled.
|
||||||
|
|
||||||
- **Status Bar**: Configure the status bar displayed while reading:
|
- **Status Bar**: Configure the status bar displayed while reading:
|
||||||
|
|
||||||
- "None" - No status bar
|
- "None" - No status bar
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Formats all C/C++ source and header files in the repository, excluding
|
Formats all C/C++ source and header files in the repository, excluding
|
||||||
generated, vendored, and build directories (open-x4-sdk, builtinFonts,
|
generated, vendored, and build directories (freeink-sdk, builtinFonts,
|
||||||
hyphenation tries, uzlib, .pio, *.generated.h).
|
hyphenation tries, uzlib, .pio, *.generated.h).
|
||||||
|
|
||||||
The clang-format binary path is resolved once and cached in
|
The clang-format binary path is resolved once and cached in
|
||||||
@@ -92,7 +92,7 @@ function Resolve-ClangFormat {
|
|||||||
$clangFormat = Resolve-ClangFormat
|
$clangFormat = Resolve-ClangFormat
|
||||||
|
|
||||||
$exclude = @(
|
$exclude = @(
|
||||||
'open-x4-sdk'
|
'freeink-sdk'
|
||||||
'lib\EpdFont\builtinFonts'
|
'lib\EpdFont\builtinFonts'
|
||||||
'lib\Epub\Epub\hyphenation\generated'
|
'lib\Epub\Epub\hyphenation\generated'
|
||||||
'lib\uzlib'
|
'lib\uzlib'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk]
|
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk]
|
||||||
B --> C[lib/hal wrappers]
|
B --> C[lib/hal wrappers]
|
||||||
C --> D[src/main.cpp runtime loop]
|
C --> D[src/main.cpp runtime loop]
|
||||||
D --> E[Activities layer]
|
D --> E[Activities layer]
|
||||||
@@ -195,10 +195,10 @@ When editing related source assets, regenerate via normal build steps/scripts.
|
|||||||
- `src/`: app orchestration, settings/state, and activity implementations
|
- `src/`: app orchestration, settings/state, and activity implementations
|
||||||
- `src/network/`: web server and OTA/update networking
|
- `src/network/`: web server and OTA/update networking
|
||||||
- `src/components/`: theming and shared UI components
|
- `src/components/`: theming and shared UI components
|
||||||
- `lib/hal/`: hardware abstraction wrappers around open-x4-sdk
|
- `lib/hal/`: hardware abstraction wrappers around freeink-sdk
|
||||||
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
|
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
|
||||||
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
|
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
|
||||||
- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery)
|
- `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs
|
||||||
- `docs/`: user and technical documentation
|
- `docs/`: user and technical documentation
|
||||||
|
|
||||||
## Embedded constraints that shape design
|
## Embedded constraints that shape design
|
||||||
|
|||||||
+22
-10
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
|
|||||||
|
|
||||||
## `section.bin`
|
## `section.bin`
|
||||||
|
|
||||||
### Version 25
|
### Version 29
|
||||||
|
|
||||||
Each file in `sections/*.bin` stores one laid-out spine section. The header is
|
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
|
also the cache-busting key: if any layout-affecting setting differs from the
|
||||||
current reader settings, the section is discarded and rebuilt.
|
current reader settings, the section is discarded and rebuilt.
|
||||||
|
|
||||||
Version 25 includes:
|
Version 29 includes:
|
||||||
|
|
||||||
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
||||||
image rendering mode, and Focus Reading
|
image rendering mode, and Focus Reading
|
||||||
@@ -105,6 +105,12 @@ Version 25 includes:
|
|||||||
- paragraph and list-item LUTs used by KOReader sync page refinement
|
- paragraph and list-item LUTs used by KOReader sync page refinement
|
||||||
- optional per-word Focus Reading split metadata
|
- optional per-word Focus Reading split metadata
|
||||||
- per-page footnote entries
|
- per-page footnote entries
|
||||||
|
- serialized word style bits for underline, strikethrough, superscript, and
|
||||||
|
subscript
|
||||||
|
- flat TextBlock word storage (v29): per-word arrays plus one shared
|
||||||
|
NUL-terminated text blob, replacing v28's length-prefixed word strings. The
|
||||||
|
on-disk order mirrors the in-RAM arena so the firmware reads a whole block
|
||||||
|
payload with a single allocation and a single SD read
|
||||||
|
|
||||||
ImHex pattern:
|
ImHex pattern:
|
||||||
|
|
||||||
@@ -113,7 +119,7 @@ import std.mem;
|
|||||||
import std.string;
|
import std.string;
|
||||||
import std.core;
|
import std.core;
|
||||||
|
|
||||||
#define EXPECTED_VERSION 25
|
#define EXPECTED_VERSION 29
|
||||||
#define MAX_STRING_LENGTH 65535
|
#define MAX_STRING_LENGTH 65535
|
||||||
#define FOOTNOTE_NUMBER_LEN 32
|
#define FOOTNOTE_NUMBER_LEN 32
|
||||||
#define FOOTNOTE_HREF_LEN 96
|
#define FOOTNOTE_HREF_LEN 96
|
||||||
@@ -174,14 +180,20 @@ struct BlockStyle {
|
|||||||
|
|
||||||
struct TextBlock {
|
struct TextBlock {
|
||||||
u16 wordCount;
|
u16 wordCount;
|
||||||
String words[wordCount];
|
|
||||||
s16 wordXPos[wordCount];
|
|
||||||
WordStyle wordStyle[wordCount];
|
|
||||||
|
|
||||||
u8 hasFocus;
|
u8 hasFocus;
|
||||||
if (hasFocus != 0) {
|
u16 textBytes [[comment("Total size of text[], including one NUL per word")]];
|
||||||
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
|
|
||||||
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
|
if (wordCount > 0) {
|
||||||
|
u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]];
|
||||||
|
s16 wordXPos[wordCount];
|
||||||
|
if (hasFocus != 0) {
|
||||||
|
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
|
||||||
|
}
|
||||||
|
WordStyle wordStyle[wordCount];
|
||||||
|
if (hasFocus != 0) {
|
||||||
|
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
|
||||||
|
}
|
||||||
|
char text[textBytes] [[comment("All words back to back, each NUL-terminated")]];
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
|||||||
Submodule
+1
Submodule freeink-sdk added at 2442e1d672
@@ -1,7 +1,7 @@
|
|||||||
#include "EpdFontFamily.h"
|
#include "EpdFontFamily.h"
|
||||||
|
|
||||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
// Extract font style bits; render-time overlay bits do not affect font selection.
|
||||||
const bool hasBold = (style & BOLD) != 0;
|
const bool hasBold = (style & BOLD) != 0;
|
||||||
const bool hasItalic = (style & ITALIC) != 0;
|
const bool hasItalic = (style & ITALIC) != 0;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class EpdFontFamily {
|
|||||||
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
||||||
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% 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,
|
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||||
const EpdFont* boldItalic = nullptr)
|
const EpdFont* boldItalic = nullptr)
|
||||||
@@ -27,6 +28,9 @@ class EpdFontFamily {
|
|||||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, 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;
|
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:
|
private:
|
||||||
const EpdFont* regular;
|
const EpdFont* regular;
|
||||||
|
|||||||
@@ -33,12 +33,24 @@ void FontDecompressor::freePageBuffer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FontDecompressor::freeHotGroup() {
|
void FontDecompressor::freeHotGroup() {
|
||||||
hotGroup.clear();
|
free(hotGroup);
|
||||||
hotGroup.shrink_to_fit();
|
hotGroup = nullptr;
|
||||||
|
hotGroupCapacity = 0;
|
||||||
hotGroupFont = nullptr;
|
hotGroupFont = nullptr;
|
||||||
hotGroupIndex = UINT16_MAX;
|
hotGroupIndex = UINT16_MAX;
|
||||||
hotGlyphBuf.clear();
|
free(hotGlyphBuf);
|
||||||
hotGlyphBuf.shrink_to_fit();
|
hotGlyphBuf = nullptr;
|
||||||
|
hotGlyphBufCapacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FontDecompressor::ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed) {
|
||||||
|
if (capacity >= needed) return true;
|
||||||
|
// Grow-only, free-then-malloc: every caller fully rewrites the buffer after a grow, so the
|
||||||
|
// old contents are dead -- freeing first gives the allocator its best shot on a tight heap.
|
||||||
|
free(buf);
|
||||||
|
buf = static_cast<uint8_t*>(malloc(needed)); // owned by FontDecompressor, freed in freeHotGroup()
|
||||||
|
capacity = buf ? needed : 0;
|
||||||
|
return buf != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
|
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
|
||||||
@@ -170,24 +182,20 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if hot group already has this group decompressed — if not, decompress it
|
// Check if hot group already has this group decompressed — if not, decompress it
|
||||||
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
if (!(hotGroup != nullptr && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
||||||
stats.cacheMisses++;
|
stats.cacheMisses++;
|
||||||
const EpdFontGroup& group = fontData->groups[groupIndex];
|
const EpdFontGroup& group = fontData->groups[groupIndex];
|
||||||
|
|
||||||
hotGroup.resize(group.uncompressedSize);
|
// ensureCapacity may free the buffer, so the cached-group identity dies with it either way.
|
||||||
if (hotGroup.empty()) {
|
hotGroupFont = nullptr;
|
||||||
|
hotGroupIndex = UINT16_MAX;
|
||||||
|
if (!ensureCapacity(hotGroup, hotGroupCapacity, group.uncompressedSize)) {
|
||||||
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
|
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
|
||||||
hotGroupFont = nullptr;
|
|
||||||
hotGroupIndex = UINT16_MAX;
|
|
||||||
stats.getBitmapTimeUs += micros() - tStart;
|
stats.getBitmapTimeUs += micros() - tStart;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
|
if (!decompressGroup(fontData, groupIndex, hotGroup, group.uncompressedSize)) {
|
||||||
hotGroup.clear();
|
|
||||||
hotGroup.shrink_to_fit();
|
|
||||||
hotGroupFont = nullptr;
|
|
||||||
hotGroupIndex = UINT16_MAX;
|
|
||||||
stats.getBitmapTimeUs += micros() - tStart;
|
stats.getBitmapTimeUs += micros() - tStart;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
@@ -200,18 +208,16 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compact just the requested glyph from byte-aligned data into scratch buffer
|
// Compact just the requested glyph from byte-aligned data into scratch buffer
|
||||||
if (glyph->dataLength > hotGlyphBuf.size()) {
|
if (!ensureCapacity(hotGlyphBuf, hotGlyphBufCapacity, glyph->dataLength)) {
|
||||||
hotGlyphBuf.resize(glyph->dataLength);
|
LOG_ERR("FDC", "Failed to allocate %u bytes for glyph scratch", (unsigned)glyph->dataLength);
|
||||||
}
|
|
||||||
if (hotGlyphBuf.empty()) {
|
|
||||||
stats.getBitmapTimeUs += micros() - tStart;
|
stats.getBitmapTimeUs += micros() - tStart;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
|
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
|
||||||
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
|
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf, glyph->width, glyph->height);
|
||||||
stats.getBitmapTimeUs += micros() - tStart;
|
stats.getBitmapTimeUs += micros() - tStart;
|
||||||
return hotGlyphBuf.data();
|
return hotGlyphBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
|
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
#include <InflateReader.h>
|
#include <InflateReader.h>
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|
||||||
class FontDecompressor {
|
class FontDecompressor {
|
||||||
@@ -67,13 +65,22 @@ class FontDecompressor {
|
|||||||
|
|
||||||
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
|
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
|
||||||
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
|
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
|
||||||
|
// Nothrow high-water malloc buffers, NOT std::vector: getBitmap() runs on the render path,
|
||||||
|
// and under -fno-exceptions a vector resize that hits OOM abort()s the firmware instead of
|
||||||
|
// failing (field crash: hotGroup.resize() -> std::bad_alloc -> abort with ~11 KB free).
|
||||||
|
// ensureCapacity() returns false on OOM so the caller can skip the glyph gracefully.
|
||||||
const EpdFontData* hotGroupFont = nullptr;
|
const EpdFontData* hotGroupFont = nullptr;
|
||||||
uint16_t hotGroupIndex = UINT16_MAX;
|
uint16_t hotGroupIndex = UINT16_MAX;
|
||||||
std::vector<uint8_t> hotGroup;
|
uint8_t* hotGroup = nullptr; // owned; freed in freeHotGroup()/dtor
|
||||||
|
uint32_t hotGroupCapacity = 0;
|
||||||
|
|
||||||
// Scratch buffer for compacting a single glyph from the hot group.
|
// Scratch buffer for compacting a single glyph from the hot group.
|
||||||
// Valid until the next getBitmap() call.
|
// Valid until the next getBitmap() call. Same ownership/OOM contract as hotGroup.
|
||||||
std::vector<uint8_t> hotGlyphBuf;
|
uint8_t* hotGlyphBuf = nullptr;
|
||||||
|
uint32_t hotGlyphBufCapacity = 0;
|
||||||
|
|
||||||
|
// Grow (never shrink) an owned buffer to at least `needed` bytes; false on OOM, buffer freed.
|
||||||
|
static bool ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed);
|
||||||
|
|
||||||
void freePageBuffer();
|
void freePageBuffer();
|
||||||
void freeHotGroup();
|
void freeHotGroup();
|
||||||
|
|||||||
+44
-17
@@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
|||||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||||
const char* asCStr(const char* s) { return s; }
|
const char* asCStr(const char* s) { return s; }
|
||||||
|
|
||||||
|
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||||
|
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||||
|
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||||
|
// next page's need), eroding the largest contiguous block all session. With
|
||||||
|
// reuse, capacities converge on the book's max page after a few turns and page
|
||||||
|
// turns stop touching the allocator. Only three small instantiations exist
|
||||||
|
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||||
|
template <typename T, typename CapT>
|
||||||
|
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||||
|
if (buf && capacity >= needed) return true;
|
||||||
|
delete[] buf;
|
||||||
|
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||||
|
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||||
|
return buf != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
SdCardFont::~SdCardFont() { freeAll(); }
|
SdCardFont::~SdCardFont() { freeAll(); }
|
||||||
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
|||||||
s.miniBitmap = nullptr;
|
s.miniBitmap = nullptr;
|
||||||
s.miniIntervalCount = 0;
|
s.miniIntervalCount = 0;
|
||||||
s.miniGlyphCount = 0;
|
s.miniGlyphCount = 0;
|
||||||
|
s.miniIntervalCapacity = 0;
|
||||||
|
s.miniGlyphCapacity = 0;
|
||||||
|
s.miniBitmapCapacity = 0;
|
||||||
freeStyleMiniKern(s);
|
freeStyleMiniKern(s);
|
||||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||||
s.epdFont.data = &s.stubData;
|
s.epdFont.data = &s.stubData;
|
||||||
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
|||||||
s.miniKernRightEntryCount = 0;
|
s.miniKernRightEntryCount = 0;
|
||||||
s.miniKernLeftClassCount = 0;
|
s.miniKernLeftClassCount = 0;
|
||||||
s.miniKernRightClassCount = 0;
|
s.miniKernRightClassCount = 0;
|
||||||
|
s.miniKernLeftCapacity = 0;
|
||||||
|
s.miniKernRightCapacity = 0;
|
||||||
|
s.miniKernMatrixCapacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||||
@@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
|||||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||||
|
// non-coalescing holes in the heap every page turn).
|
||||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||||
matrixBytes);
|
matrixBytes);
|
||||||
freeStyleMiniKern(s);
|
freeStyleMiniKern(s);
|
||||||
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
return missed;
|
return missed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mini intervals from sorted codepoints
|
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||||
freeStyleMiniData(s);
|
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||||
|
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||||
|
s.miniIntervalCount = 0;
|
||||||
|
s.miniGlyphCount = 0;
|
||||||
|
s.miniKernLeftEntryCount = 0;
|
||||||
|
s.miniKernRightEntryCount = 0;
|
||||||
|
s.miniKernLeftClassCount = 0;
|
||||||
|
s.miniKernRightClassCount = 0;
|
||||||
|
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||||
|
s.epdFont.data = &s.stubData;
|
||||||
|
|
||||||
uint32_t intervalCapacity = validCount;
|
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
|
||||||
if (!s.miniIntervals) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
return static_cast<int>(cpCount);
|
return static_cast<int>(cpCount);
|
||||||
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate mini glyph array
|
// Mini glyph array (reused across pages when it fits)
|
||||||
s.miniGlyphCount = validCount;
|
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
|
||||||
if (!s.miniGlyphs) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
freeStyleMiniData(s);
|
freeStyleMiniData(s);
|
||||||
return static_cast<int>(cpCount);
|
return static_cast<int>(cpCount);
|
||||||
}
|
}
|
||||||
|
s.miniGlyphCount = validCount;
|
||||||
|
|
||||||
// Build sorted read order for sequential I/O
|
// Build sorted read order for sequential I/O
|
||||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||||
@@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||||
if (!s.miniBitmap) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||||
delete[] readOrder;
|
delete[] readOrder;
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
|
|||||||
@@ -163,13 +163,22 @@ class SdCardFont {
|
|||||||
// Stub EpdFontData returned when not prewarmed
|
// Stub EpdFontData returned when not prewarmed
|
||||||
EpdFontData stubData{};
|
EpdFontData stubData{};
|
||||||
|
|
||||||
// Mini EpdFontData built during prewarm
|
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||||
|
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||||
|
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||||
|
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||||
|
// After a few pages the capacities converge on the book's max and page turns
|
||||||
|
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||||
|
// zeroes capacities) for style eviction / font unload.
|
||||||
EpdFontData miniData{};
|
EpdFontData miniData{};
|
||||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||||
EpdGlyph* miniGlyphs = nullptr;
|
EpdGlyph* miniGlyphs = nullptr;
|
||||||
uint8_t* miniBitmap = nullptr;
|
uint8_t* miniBitmap = nullptr;
|
||||||
uint32_t miniIntervalCount = 0;
|
uint32_t miniIntervalCount = 0;
|
||||||
uint32_t miniGlyphCount = 0;
|
uint32_t miniGlyphCount = 0;
|
||||||
|
uint32_t miniIntervalCapacity = 0;
|
||||||
|
uint32_t miniGlyphCapacity = 0;
|
||||||
|
uint32_t miniBitmapCapacity = 0;
|
||||||
|
|
||||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||||
@@ -184,6 +193,10 @@ class SdCardFont {
|
|||||||
uint8_t miniKernLeftClassCount = 0;
|
uint8_t miniKernLeftClassCount = 0;
|
||||||
uint8_t miniKernRightClassCount = 0;
|
uint8_t miniKernRightClassCount = 0;
|
||||||
int8_t* miniKernMatrix = nullptr;
|
int8_t* miniKernMatrix = nullptr;
|
||||||
|
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||||
|
uint16_t miniKernLeftCapacity = 0;
|
||||||
|
uint16_t miniKernRightCapacity = 0;
|
||||||
|
uint32_t miniKernMatrixCapacity = 0;
|
||||||
|
|
||||||
// The EpdFont whose data pointer we manage
|
// The EpdFont whose data pointer we manage
|
||||||
EpdFont epdFont{&stubData};
|
EpdFont epdFont{&stubData};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import socket
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -49,17 +50,44 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
|
|||||||
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
||||||
|
|
||||||
|
|
||||||
def download_font(url: str, dest: Path) -> Path:
|
_orig_getaddrinfo = socket.getaddrinfo
|
||||||
"""Download a font file if not already cached. Returns the local path."""
|
|
||||||
|
|
||||||
|
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
||||||
|
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
|
||||||
|
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
|
||||||
|
|
||||||
|
|
||||||
|
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
|
||||||
|
"""Download a font file if not already cached. Returns the local path.
|
||||||
|
|
||||||
|
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
|
||||||
|
on a different mirror each request; a mirror may advertise an IPv6 address a
|
||||||
|
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
|
||||||
|
Retry on failure, forcing IPv4 resolution after the first attempt.
|
||||||
|
"""
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
return dest
|
return dest
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
print(f" Downloading {dest.name}...")
|
print(f" Downloading {dest.name}...")
|
||||||
try:
|
last_err = None
|
||||||
urllib.request.urlretrieve(url, dest)
|
for attempt in range(1, retries + 1):
|
||||||
except Exception as e:
|
force_ipv4 = attempt > 1
|
||||||
dest.unlink(missing_ok=True)
|
if force_ipv4:
|
||||||
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, dest)
|
||||||
|
break
|
||||||
|
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
|
||||||
|
last_err = e
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
if attempt < retries:
|
||||||
|
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
|
||||||
|
finally:
|
||||||
|
if force_ipv4:
|
||||||
|
socket.getaddrinfo = _orig_getaddrinfo
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
|
||||||
size_kb = dest.stat().st_size / 1024
|
size_kb = dest.stat().st_size / 1024
|
||||||
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
||||||
return dest
|
return dest
|
||||||
|
|||||||
+21
-1
@@ -39,7 +39,17 @@ std::unique_ptr<PageLine> PageLine::deserialize(HalFile& file) {
|
|||||||
serialization::readPod(file, yPos);
|
serialization::readPod(file, yPos);
|
||||||
|
|
||||||
auto tb = TextBlock::deserialize(file);
|
auto tb = TextBlock::deserialize(file);
|
||||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
if (!tb) {
|
||||||
|
LOG_ERR("PGE", "Deserialization failed: null TextBlock");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* line = new (std::nothrow) PageLine(std::move(tb), xPos, yPos);
|
||||||
|
if (!line) {
|
||||||
|
LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return std::unique_ptr<PageLine>(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||||
@@ -148,6 +158,10 @@ std::unique_ptr<Page> Page::deserialize(HalFile& file) {
|
|||||||
|
|
||||||
uint16_t count;
|
uint16_t count;
|
||||||
serialization::readPod(file, count);
|
serialization::readPod(file, count);
|
||||||
|
// Reserve up front: growth-by-doubling needs old + new capacity live at once and
|
||||||
|
// reallocates repeatedly — a field crash (bad_alloc -> abort under -fno-exceptions)
|
||||||
|
// hit exactly this append path on a heavily fragmented heap.
|
||||||
|
page->elements.reserve(count);
|
||||||
|
|
||||||
for (uint16_t i = 0; i < count; i++) {
|
for (uint16_t i = 0; i < count; i++) {
|
||||||
uint8_t tag;
|
uint8_t tag;
|
||||||
@@ -155,9 +169,15 @@ std::unique_ptr<Page> Page::deserialize(HalFile& file) {
|
|||||||
|
|
||||||
if (tag == TAG_PageLine) {
|
if (tag == TAG_PageLine) {
|
||||||
auto pl = PageLine::deserialize(file);
|
auto pl = PageLine::deserialize(file);
|
||||||
|
if (!pl) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
page->elements.push_back(std::move(pl));
|
page->elements.push_back(std::move(pl));
|
||||||
} else if (tag == TAG_PageImage) {
|
} else if (tag == TAG_PageImage) {
|
||||||
auto pi = PageImage::deserialize(file);
|
auto pi = PageImage::deserialize(file);
|
||||||
|
if (!pi) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
page->elements.push_back(std::move(pi));
|
page->elements.push_back(std::move(pi));
|
||||||
} else if (tag == TAG_PageHorizontalRule) {
|
} else if (tag == TAG_PageHorizontalRule) {
|
||||||
auto rule = PageHorizontalRule::deserialize(file);
|
auto rule = PageHorizontalRule::deserialize(file);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <BidiUtils.h>
|
#include <BidiUtils.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <Logging.h>
|
||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!lineHasFocusSplit) {
|
if (!lineHasFocusSplit) {
|
||||||
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
|
// TextBlock flattens the vectors into its arena; they stay owned here and die at return.
|
||||||
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
|
auto block = std::make_shared<TextBlock>(lineWords, lineXPos, lineWordStyles, std::vector<uint8_t>{},
|
||||||
|
std::vector<uint16_t>{}, blockStyle);
|
||||||
|
if (!block->valid()) {
|
||||||
|
LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processLine(std::move(block));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,6 +1186,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLine(std::make_shared<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
|
auto block = std::make_shared<TextBlock>(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle);
|
||||||
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
|
if (!block->valid()) {
|
||||||
|
LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processLine(std::move(block));
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-112
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <Memory.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include "Epub/css/CssParser.h"
|
#include "Epub/css/CssParser.h"
|
||||||
@@ -10,34 +11,64 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
|
// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 27;
|
// text blob) instead of length-prefixed strings and per-field arrays.
|
||||||
|
constexpr uint8_t SECTION_FILE_VERSION = 29;
|
||||||
|
// Written into the version field while a build is in progress; patched to
|
||||||
|
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||||
|
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||||
|
// as unknown and clears -- so an incomplete file is never mistaken for a valid one.
|
||||||
|
constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
|
||||||
|
// Written when a build is suspended partway (reader exited or device slept mid-build).
|
||||||
|
// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse
|
||||||
|
// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile
|
||||||
|
// accepts it so a resume shows those pages instantly; the reader extends it by
|
||||||
|
// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION,
|
||||||
|
// so finalized files are untouched by this feature; older firmware treats the sentinel
|
||||||
|
// as an unknown version and rebuilds, which is a safe downgrade.
|
||||||
|
// MUST change in lockstep with SECTION_FILE_VERSION: the sentinel IS the partial's
|
||||||
|
// format version, so a stale-format partial otherwise passes the header check and
|
||||||
|
// only fails (noisily, via the block-decode error path) when a page is loaded.
|
||||||
|
// Derived so the pairing can't be forgotten: 0xFE for v28, 0xFD for v29, ...
|
||||||
|
constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - 28);
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
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(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||||
sizeof(uint32_t) + sizeof(uint32_t);
|
sizeof(uint32_t) + sizeof(uint32_t);
|
||||||
|
|
||||||
struct PageLutEntry {
|
|
||||||
uint32_t fileOffset;
|
|
||||||
uint16_t paragraphIndex;
|
|
||||||
uint16_t listItemIndex;
|
|
||||||
};
|
|
||||||
} // namespace
|
} // 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) {
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t position = file.position();
|
const uint32_t position = file.position();
|
||||||
if (!page->serialize(file)) {
|
if (!page->serialize(file)) {
|
||||||
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
LOG_DBG("SCT", "Page %d processed", pageCount);
|
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
|
||||||
|
|
||||||
pageCount++;
|
builtPageCount_++;
|
||||||
|
// pageCount is the pages available to read: a rebuild over a partial only raises it
|
||||||
|
// once it has laid out more pages than the partial already covers.
|
||||||
|
if (builtPageCount_ > pageCount) {
|
||||||
|
pageCount = builtPageCount_;
|
||||||
|
}
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +87,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
||||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||||
"Header size mismatch");
|
"Header size mismatch");
|
||||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
// Written as the incomplete sentinel; finalizeBuild() patches it to
|
||||||
|
// SECTION_FILE_VERSION as the last step, committing the file.
|
||||||
|
serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION);
|
||||||
serialization::writePod(file, fontId);
|
serialization::writePod(file, fontId);
|
||||||
serialization::writePod(file, lineCompression);
|
serialization::writePod(file, lineCompression);
|
||||||
serialization::writePod(file, extraParagraphSpacing);
|
serialization::writePod(file, extraParagraphSpacing);
|
||||||
@@ -83,16 +116,18 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Match parameters
|
// Match parameters
|
||||||
|
bool filePartial = false;
|
||||||
{
|
{
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(file, version);
|
serialization::readPod(file, version);
|
||||||
if (version != SECTION_FILE_VERSION) {
|
if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) {
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||||
clearCache();
|
clearCache();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||||
|
|
||||||
int fileFontId;
|
int fileFontId;
|
||||||
uint16_t fileViewportWidth, fileViewportHeight;
|
uint16_t fileViewportWidth, fileViewportHeight;
|
||||||
@@ -127,14 +162,42 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialization::readPod(file, pageCount);
|
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
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : "");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||||
bool Section::clearCache() const {
|
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())) {
|
if (!Storage.exists(filePath.c_str())) {
|
||||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||||
return true;
|
return true;
|
||||||
@@ -154,8 +217,43 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||||
const uint8_t imageRendering, const bool focusReadingEnabled,
|
const uint8_t imageRendering, const bool focusReadingEnabled,
|
||||||
const std::function<void()>& popupFn) {
|
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 localPath = epub->getSpineItem(spineIndex).href;
|
||||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
const auto htmlDir = epub->getCachePath() + "/html";
|
||||||
|
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
|
||||||
|
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
// Create cache directory if it doesn't exist
|
||||||
{
|
{
|
||||||
@@ -163,62 +261,101 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
Storage.mkdir(sectionsDir.c_str());
|
Storage.mkdir(sectionsDir.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry logic for SD card timing issues
|
// Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the
|
||||||
bool success = false;
|
// book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation
|
||||||
uint32_t fileSize = 0;
|
// that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip
|
||||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
// inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so
|
||||||
if (attempt > 0) {
|
// even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a
|
||||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
// reopen skip the multi-second inflate. If htmlPath exists it is known-complete.
|
||||||
delay(50); // Brief delay before retry
|
const bool reusedHtml = Storage.exists(htmlPath.c_str());
|
||||||
|
bool htmlCached = reusedHtml;
|
||||||
|
if (reusedHtml) {
|
||||||
|
LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str());
|
||||||
|
} else {
|
||||||
|
Storage.mkdir(htmlDir.c_str());
|
||||||
|
|
||||||
|
// Retry logic for SD card timing issues
|
||||||
|
bool streamed = false;
|
||||||
|
uint32_t fileSize = 0;
|
||||||
|
for (int attempt = 0; attempt < 3 && !streamed; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||||
|
delay(50); // Brief delay before retry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any incomplete file from previous attempt before retrying
|
||||||
|
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
HalFile tmpHtml;
|
||||||
|
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Larger chunks mean far fewer SD writes inflating the HTML; a 1KB chunk turned a 584KB
|
||||||
|
// single-spine novel into ~570 tiny writes (multi-second). 8KB keeps the transient buffers
|
||||||
|
// small while cutting the write count 8x.
|
||||||
|
streamed = epub->readItemContentsToStream(localPath, tmpHtml, 8192);
|
||||||
|
fileSize = tmpHtml.size();
|
||||||
|
// Explicitly close() file before calling Storage.remove()
|
||||||
|
tmpHtml.close();
|
||||||
|
|
||||||
|
// If streaming failed, remove the incomplete file immediately
|
||||||
|
if (!streamed && Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
|
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any incomplete file from previous attempt before retrying
|
if (!streamed) {
|
||||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||||
Storage.remove(tmpHtmlPath.c_str());
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
HalFile tmpHtml;
|
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||||
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();
|
|
||||||
|
|
||||||
// If streaming failed, remove the incomplete file immediately
|
// Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are
|
||||||
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
// valid regardless of whether the layout build finishes, so reopening (even a window-only spine
|
||||||
Storage.remove(tmpHtmlPath.c_str());
|
// that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp.
|
||||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) {
|
||||||
|
htmlCached = true;
|
||||||
|
} else {
|
||||||
|
LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!success) {
|
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
|
||||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
|
||||||
|
|
||||||
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
|
||||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
||||||
std::vector<PageLutEntry> lut = {};
|
|
||||||
|
auto ctx = makeUniqueNoThrow<BuildContext>();
|
||||||
|
if (!ctx) {
|
||||||
|
LOG_ERR("SCT", "OOM: BuildContext");
|
||||||
|
file.close();
|
||||||
|
Storage.remove(binTmpPath().c_str());
|
||||||
|
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild
|
||||||
|
// then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up.
|
||||||
|
ctx->reusedHtml = htmlCached;
|
||||||
|
ctx->htmlPath = htmlPath;
|
||||||
|
ctx->tmpHtmlPath = tmpHtmlPath;
|
||||||
|
ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath;
|
||||||
|
|
||||||
// Derive the content base directory and image cache path prefix for the parser
|
// Derive the content base directory and image cache path prefix for the parser
|
||||||
size_t lastSlash = localPath.find_last_of('/');
|
const size_t lastSlash = localPath.find_last_of('/');
|
||||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||||
|
|
||||||
CssParser* cssParser = nullptr;
|
|
||||||
if (embeddedStyle) {
|
if (embeddedStyle) {
|
||||||
cssParser = epub->getCssParser();
|
ctx->cssParser = epub->getCssParser();
|
||||||
if (cssParser) {
|
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
|
||||||
if (!cssParser->loadFromCache()) {
|
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,113 +372,376 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChapterHtmlSlimParser visitor(
|
// The parser stores the path/contentBase/imageBasePath by reference, so they must
|
||||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
// live in the BuildContext (which outlives the parser). The page-complete callback
|
||||||
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
// captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the
|
||||||
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
// context for the parser's whole lifetime.
|
||||||
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
BuildContext* ctxPtr = ctx.get();
|
||||||
|
ctx->parser = makeUniqueNoThrow<ChapterHtmlSlimParser>(
|
||||||
|
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
||||||
|
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||||
|
[this, ctxPtr](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||||
|
ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||||
},
|
},
|
||||||
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
|
embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn,
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
ctxPtr->cssParser);
|
||||||
success = visitor.parseAndBuildPages();
|
if (!ctx->parser) {
|
||||||
|
LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser");
|
||||||
Storage.remove(tmpHtmlPath.c_str());
|
if (ctx->cssParser) ctx->cssParser->clear();
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
|
||||||
// Explicitly close() file before calling Storage.remove()
|
|
||||||
file.close();
|
file.close();
|
||||||
Storage.remove(filePath.c_str());
|
Storage.remove(binTmpPath().c_str());
|
||||||
if (cssParser) {
|
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||||
cssParser->clear();
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
|
build_ = std::move(ctx);
|
||||||
|
|
||||||
|
if (!build_->parser->beginParse()) {
|
||||||
|
LOG_ERR("SCT", "Failed to begin parse");
|
||||||
|
abandonBuild();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
build_->totalBytes = build_->parser->parseTotalBytes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Section::buildSomeMore(const int maxPages) {
|
||||||
|
if (!build_ || !build_->parser) {
|
||||||
|
LOG_ERR("SCT", "buildSomeMore with no active build");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial,
|
||||||
|
// pageCount stays pinned at the partial's watermark until the build passes it, which
|
||||||
|
// would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark.
|
||||||
|
const int startCount = builtPageCount_;
|
||||||
|
for (;;) {
|
||||||
|
const auto status = build_->parser->parseStep();
|
||||||
|
if (status == ChapterHtmlSlimParser::ParseStatus::Error) {
|
||||||
|
LOG_ERR("SCT", "Parse error during incremental build");
|
||||||
|
abandonBuild();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status == ChapterHtmlSlimParser::ParseStatus::Done) {
|
||||||
|
return finalizeBuild();
|
||||||
|
}
|
||||||
|
// ParseStatus::More: yield once we've laid out the requested number of pages.
|
||||||
|
if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) {
|
||||||
|
build_->bytesConsumed = build_->parser->parseBytesConsumed();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Section::hasHtmlCache() const {
|
||||||
|
const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html";
|
||||||
|
return Storage.exists(htmlPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint16_t> Section::findAnchorDuringBuild(const std::string& anchor) const {
|
||||||
|
if (!build_ || !build_->parser) return std::nullopt;
|
||||||
|
for (const auto& [key, page] : build_->parser->getAnchors()) {
|
||||||
|
if (key == anchor) return page;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint16_t> Section::findAnchor(const std::string& anchor) const {
|
||||||
|
if (const auto page = findAnchorDuringBuild(anchor)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
// Fall back to the on-disk anchor map: a finalized section, or a partial whose map
|
||||||
|
// covers everything up to its watermark (nullopt past it -- build further and retry).
|
||||||
|
return getPageForAnchor(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t Section::estimatedTotalPages() const {
|
||||||
|
// Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA
|
||||||
|
// damping is needed. Also the best guess while a rebuild is running but hasn't laid out
|
||||||
|
// enough pages yet to extrapolate from its own progress.
|
||||||
|
const auto partialEstimate = [this]() -> uint16_t {
|
||||||
|
if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) {
|
||||||
|
return pageCount;
|
||||||
|
}
|
||||||
|
const uint64_t est = static_cast<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
|
||||||
|
if (est <= pageCount) return pageCount;
|
||||||
|
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!build_) {
|
||||||
|
return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact
|
||||||
|
}
|
||||||
|
const uint32_t consumed = build_->bytesConsumed;
|
||||||
|
const uint32_t total = build_->totalBytes;
|
||||||
|
if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate();
|
||||||
|
|
||||||
|
// Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This
|
||||||
|
// re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses
|
||||||
|
// dense vs sparse regions of the chapter.
|
||||||
|
const uint64_t raw = static_cast<uint64_t>(builtPageCount_) * total / consumed;
|
||||||
|
|
||||||
|
// Damp that jitter with an exponential moving average. Step it once per build advance (keyed on
|
||||||
|
// bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how
|
||||||
|
// often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so
|
||||||
|
// the average settles onto the true count (and finalizeBuild then returns the exact pageCount).
|
||||||
|
constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle
|
||||||
|
if (build_->smoothedEstimate <= 0) {
|
||||||
|
build_->smoothedEstimate = static_cast<float>(raw); // seed on the first estimate
|
||||||
|
} else if (consumed != build_->smoothedAtConsumed) {
|
||||||
|
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
|
||||||
|
}
|
||||||
|
build_->smoothedAtConsumed = consumed;
|
||||||
|
|
||||||
|
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
|
||||||
|
if (est <= pageCount) return pageCount; // never fewer than the pages already available
|
||||||
|
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built
|
||||||
|
// page count and table offsets, stamp `version` as the commit point, then swap the tmp
|
||||||
|
// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer
|
||||||
|
// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate
|
||||||
|
// the total page count. The parser must still be alive (anchors are read from it).
|
||||||
|
// On failure the tmp is removed and any pre-existing file at filePath is left intact.
|
||||||
|
bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) {
|
||||||
|
const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||||
|
|
||||||
|
const auto failCommit = [this]() {
|
||||||
|
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||||
|
file.close();
|
||||||
|
Storage.remove(binTmpPath().c_str());
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const uint32_t lutOffset = file.position();
|
const uint32_t lutOffset = file.position();
|
||||||
bool hasFailedLutRecords = false;
|
for (const auto& entry : build_->lut) {
|
||||||
// Write LUT
|
|
||||||
for (const auto& entry : lut) {
|
|
||||||
if (entry.fileOffset == 0) {
|
if (entry.fileOffset == 0) {
|
||||||
hasFailedLutRecords = true;
|
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||||
break;
|
return failCommit();
|
||||||
}
|
}
|
||||||
serialization::writePod(file, entry.fileOffset);
|
serialization::writePod(file, entry.fileOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFailedLutRecords) {
|
// Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a
|
||||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
// partial, skip anchors that landed on the incomplete trailing page the suspend drops.
|
||||||
// Explicitly close() file before calling Storage.remove()
|
|
||||||
file.close();
|
|
||||||
Storage.remove(filePath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
|
||||||
const uint32_t anchorMapOffset = file.position();
|
const uint32_t anchorMapOffset = file.position();
|
||||||
const auto& anchors = visitor.getAnchors();
|
const auto& anchors = build_->parser->getAnchors();
|
||||||
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
uint16_t anchorCount = 0;
|
||||||
for (const auto& [anchor, page] : anchors) {
|
for (const auto& [anchor, page] : anchors) {
|
||||||
|
if (!asPartial || page < builtPageCount_) anchorCount++;
|
||||||
|
}
|
||||||
|
serialization::writePod(file, anchorCount);
|
||||||
|
for (const auto& [anchor, page] : anchors) {
|
||||||
|
if (asPartial && page >= builtPageCount_) continue;
|
||||||
serialization::writeString(file, anchor);
|
serialization::writeString(file, anchor);
|
||||||
serialization::writePod(file, page);
|
serialization::writePod(file, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t paragraphLutOffset = file.position();
|
const uint32_t paragraphLutOffset = file.position();
|
||||||
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
|
||||||
for (const auto& entry : lut) {
|
for (const auto& entry : build_->lut) {
|
||||||
serialization::writePod(file, entry.paragraphIndex);
|
serialization::writePod(file, entry.paragraphIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
||||||
for (const auto& entry : lut) {
|
for (const auto& entry : build_->lut) {
|
||||||
serialization::writePod(file, entry.listItemIndex);
|
serialization::writePod(file, entry.listItemIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
if (asPartial) {
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
// Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t).
|
||||||
serialization::writePod(file, pageCount);
|
serialization::writePod(file, bytesConsumed);
|
||||||
|
serialization::writePod(file, totalBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch header with the built page count and section offsets...
|
||||||
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_));
|
||||||
|
serialization::writePod(file, builtPageCount_);
|
||||||
serialization::writePod(file, lutOffset);
|
serialization::writePod(file, lutOffset);
|
||||||
serialization::writePod(file, anchorMapOffset);
|
serialization::writePod(file, anchorMapOffset);
|
||||||
serialization::writePod(file, paragraphLutOffset);
|
serialization::writePod(file, paragraphLutOffset);
|
||||||
serialization::writePod(file, liLutFileOffset);
|
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
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
if (cssParser) {
|
|
||||||
cssParser->clear();
|
// Swap into place. A crash between remove and rename loses the old file but keeps a
|
||||||
|
// fully-committed tmp; the next build just removes it and rebuilds.
|
||||||
|
if (Storage.exists(filePath.c_str())) {
|
||||||
|
Storage.remove(filePath.c_str());
|
||||||
|
}
|
||||||
|
if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) {
|
||||||
|
LOG_ERR("SCT", "Failed to move built section into place");
|
||||||
|
Storage.remove(binTmpPath().c_str());
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
bool Section::finalizeBuild() {
|
||||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
// Flush the trailing page (emits the last page via the completePageFn into the LUT).
|
||||||
|
build_->parser->finishParse();
|
||||||
|
|
||||||
|
if (!build_->reusedHtml) {
|
||||||
|
// Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future
|
||||||
|
// rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded.
|
||||||
|
if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) {
|
||||||
|
LOG_DBG("SCT", "Failed to promote HTML cache, removing temp");
|
||||||
|
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0);
|
||||||
|
if (build_->cssParser) build_->cssParser->clear();
|
||||||
|
build_.reset();
|
||||||
|
if (!committed) {
|
||||||
|
// commitBuildFile removed filePath before the failed swap, so nothing valid remains.
|
||||||
|
partial_ = false;
|
||||||
|
partialPageCount_ = 0;
|
||||||
|
pageCount = 0;
|
||||||
|
builtPageCount_ = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buildComplete_ = true;
|
||||||
|
partial_ = false;
|
||||||
|
partialPageCount_ = 0;
|
||||||
|
pageCount = builtPageCount_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Section::suspendBuild() {
|
||||||
|
if (!build_) return;
|
||||||
|
|
||||||
|
// Only worth persisting if this build produced pages a pre-existing partial doesn't
|
||||||
|
// already cover; otherwise keep the older (bigger) partial and just drop the tmp.
|
||||||
|
const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_);
|
||||||
|
|
||||||
|
bool committed = false;
|
||||||
|
if (worthKeeping) {
|
||||||
|
// Capture the parse watermark and commit before tearing the parser down (the anchor
|
||||||
|
// map is read from it). The incomplete trailing page is intentionally not flushed:
|
||||||
|
// only fully laid-out pages are persisted, and the rebuild re-derives the rest.
|
||||||
|
const uint32_t consumed = static_cast<uint32_t>(build_->parser->parseBytesConsumed());
|
||||||
|
committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes);
|
||||||
|
if (committed) {
|
||||||
|
partial_ = true;
|
||||||
|
partialPageCount_ = builtPageCount_;
|
||||||
|
partialBytesConsumed_ = consumed;
|
||||||
|
partialTotalBytes_ = build_->totalBytes;
|
||||||
|
LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build_->parser) build_->parser->abortParse();
|
||||||
|
if (build_->cssParser) build_->cssParser->clear();
|
||||||
|
if (!committed && file) {
|
||||||
|
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||||
|
file.close();
|
||||||
|
Storage.remove(binTmpPath().c_str());
|
||||||
|
}
|
||||||
|
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||||
|
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||||
|
}
|
||||||
|
build_.reset();
|
||||||
|
buildComplete_ = false;
|
||||||
|
pageCount = partial_ ? partialPageCount_ : 0;
|
||||||
|
builtPageCount_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Section::abandonBuild() {
|
||||||
|
if (!build_) return;
|
||||||
|
if (build_->parser) build_->parser->abortParse();
|
||||||
|
if (build_->cssParser) build_->cssParser->clear();
|
||||||
|
if (file) {
|
||||||
|
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||||
|
file.close();
|
||||||
|
Storage.remove(binTmpPath().c_str());
|
||||||
|
}
|
||||||
|
// A parse error would recur against the same HTML, so drop any partial too -- resuming
|
||||||
|
// from it would just re-enter the failing build every open.
|
||||||
|
if (Storage.exists(filePath.c_str())) {
|
||||||
|
Storage.remove(filePath.c_str());
|
||||||
|
}
|
||||||
|
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||||
|
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||||
|
}
|
||||||
|
build_.reset();
|
||||||
|
buildComplete_ = false;
|
||||||
|
partial_ = false;
|
||||||
|
partialPageCount_ = 0;
|
||||||
|
pageCount = 0;
|
||||||
|
builtPageCount_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Page> Section::loadPageDuringBuild(const int page) {
|
||||||
|
if (!build_ || page < 0 || page >= static_cast<int>(build_->lut.size()) || !file) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const uint32_t pos = build_->lut[page].fileOffset;
|
||||||
|
if (pos == 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
// The .bin is open O_RDWR for the build. Read the already-written page, then restore
|
||||||
|
// the write cursor so the next onPageComplete keeps appending where it left off.
|
||||||
|
const uint32_t writePos = file.position();
|
||||||
|
file.seek(pos);
|
||||||
|
auto p = Page::deserialize(file);
|
||||||
|
file.seek(writePos);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a page from the committed file at filePath (finalized section or partial from a
|
||||||
|
// previous session). Uses a local handle so it is safe while a build holds the member
|
||||||
|
// `file` open on the tmp .bin.
|
||||||
|
std::unique_ptr<Page> Section::loadPageAt(const int page) const {
|
||||||
|
HalFile f;
|
||||||
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||||
uint32_t lutOffset;
|
uint32_t lutOffset;
|
||||||
serialization::readPod(file, lutOffset);
|
serialization::readPod(f, lutOffset);
|
||||||
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
f.seek(lutOffset + sizeof(uint32_t) * page);
|
||||||
uint32_t pagePos;
|
uint32_t pagePos;
|
||||||
serialization::readPod(file, pagePos);
|
serialization::readPod(f, pagePos);
|
||||||
file.seek(pagePos);
|
f.seek(pagePos);
|
||||||
|
|
||||||
auto page = Page::deserialize(file);
|
return Page::deserialize(f);
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
||||||
file.close();
|
}
|
||||||
return page;
|
|
||||||
|
std::unique_ptr<Page> Section::loadPage(const int page) {
|
||||||
|
if (page < 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (build_ && page < static_cast<int>(build_->lut.size())) {
|
||||||
|
return loadPageDuringBuild(page);
|
||||||
|
}
|
||||||
|
// Not (yet) in the active build: serve from the file on disk -- a finalized section,
|
||||||
|
// or a partial from a previous session whose pages the rebuild hasn't reached again.
|
||||||
|
const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount);
|
||||||
|
if (page >= onDisk) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return loadPageAt(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Section::getTextFromSectionFile() {
|
std::string Section::getTextFromSectionFile() {
|
||||||
std::string fullText;
|
std::string fullText;
|
||||||
auto p = this->loadPageFromSectionFile();
|
auto p = loadPage(currentPage);
|
||||||
if (p) {
|
if (p) {
|
||||||
for (const auto& el : p->elements) {
|
for (const auto& el : p->elements) {
|
||||||
if (el->getTag() == TAG_PageLine) {
|
if (el->getTag() == TAG_PageLine) {
|
||||||
const auto& line = static_cast<const PageLine&>(*el);
|
const auto& line = static_cast<const PageLine&>(*el);
|
||||||
if (line.getBlock()) {
|
if (line.getBlock()) {
|
||||||
const auto& words = line.getBlock()->getWords();
|
const auto& block = *line.getBlock();
|
||||||
for (const auto& w : words) {
|
for (uint16_t i = 0; i < block.wordCount(); i++) {
|
||||||
if (!fullText.empty()) fullText += " ";
|
if (!fullText.empty()) fullText += " ";
|
||||||
fullText += w;
|
fullText += block.wordText(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,6 +761,15 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
|
|||||||
return std::nullopt;
|
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));
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
||||||
uint16_t count;
|
uint16_t count;
|
||||||
serialization::readPod(f, count);
|
serialization::readPod(f, count);
|
||||||
|
|||||||
+105
-7
@@ -3,11 +3,14 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
|
|
||||||
class Page;
|
class Page;
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
|
class ChapterHtmlSlimParser;
|
||||||
|
class CssParser;
|
||||||
|
|
||||||
class Section {
|
class Section {
|
||||||
std::shared_ptr<Epub> epub;
|
std::shared_ptr<Epub> epub;
|
||||||
@@ -21,16 +24,67 @@ class Section {
|
|||||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
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:
|
public:
|
||||||
uint16_t pageCount = 0;
|
uint16_t pageCount = 0;
|
||||||
int currentPage = 0;
|
int currentPage = 0;
|
||||||
|
|
||||||
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
// Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the
|
||||||
: epub(epub),
|
// forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp.
|
||||||
spineIndex(spineIndex),
|
explicit Section(const std::shared_ptr<Epub>& epub, int spineIndex, GfxRenderer& renderer);
|
||||||
renderer(renderer),
|
~Section();
|
||||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
|
||||||
~Section() = default;
|
|
||||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||||
uint8_t imageRendering, bool focusReadingEnabled);
|
uint8_t imageRendering, bool focusReadingEnabled);
|
||||||
@@ -39,12 +93,56 @@ class Section {
|
|||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||||
uint8_t imageRendering, bool focusReadingEnabled,
|
uint8_t imageRendering, bool focusReadingEnabled,
|
||||||
const std::function<void()>& popupFn = nullptr);
|
const std::function<void()>& popupFn = nullptr);
|
||||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
|
||||||
|
// Incremental build: lay out the section a few pages at a time so a large chapter
|
||||||
|
// can show its first page immediately and keep the UI responsive while the rest
|
||||||
|
// builds. createSectionFile() above is the one-shot wrapper over these.
|
||||||
|
// if (!startBuild(...)) fail;
|
||||||
|
// each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop.
|
||||||
|
bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||||
|
uint8_t imageRendering, bool focusReadingEnabled, const std::function<void()>& popupFn = nullptr);
|
||||||
|
// Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns
|
||||||
|
// false on error (the build is abandoned). Sets isBuildComplete() when finished.
|
||||||
|
bool buildSomeMore(int maxPages);
|
||||||
|
bool isBuilding() const { return static_cast<bool>(build_); }
|
||||||
|
bool isBuildComplete() const { return buildComplete_; }
|
||||||
|
// Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based
|
||||||
|
// estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine
|
||||||
|
// is still building, so "page X of Y" / progress don't read off the small build watermark.
|
||||||
|
uint16_t estimatedTotalPages() const;
|
||||||
|
void abandonBuild();
|
||||||
|
// Persist an in-progress build as a partial section file (version sentinel + LUTs +
|
||||||
|
// watermark trailer) instead of discarding it, so the next open of this spine can show
|
||||||
|
// its pages instantly and only rebuild in the background. Called by the destructor, so
|
||||||
|
// any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a
|
||||||
|
// pre-existing partial when it covers more pages than this build reached.
|
||||||
|
void suspendBuild();
|
||||||
|
// True when a partial file was loaded: pageCount is a watermark, not the chapter total.
|
||||||
|
bool isPartial() const { return partial_; }
|
||||||
|
|
||||||
|
// Unified page read: from the active build if it has reached the page, otherwise from
|
||||||
|
// the on-disk file (finalized section, or a partial the rebuild hasn't caught up to).
|
||||||
|
std::unique_ptr<Page> loadPage(int page);
|
||||||
|
|
||||||
std::string getTextFromSectionFile();
|
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.
|
// Look up the page number for an anchor id from the section cache file.
|
||||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
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.
|
// Get the page count from the section cache file without fully loading it.
|
||||||
std::optional<uint16_t> getCachedPageCount() const;
|
std::optional<uint16_t> getCachedPageCount() const;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ struct BlockStyle {
|
|||||||
bool isRtl = false; // true if resolved direction is RTL
|
bool isRtl = false; // true if resolved direction is RTL
|
||||||
bool directionDefined = false; // true if direction was explicitly set in CSS/HTML
|
bool directionDefined = false; // true if direction was explicitly set in CSS/HTML
|
||||||
|
|
||||||
|
// Set when this block was created by a <br> element. Used by startNewTextBlock to inject
|
||||||
|
// a full line-height gap when the <br> block stays empty (section-break use case).
|
||||||
|
// NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks.
|
||||||
|
bool fromBrElement = false;
|
||||||
|
|
||||||
// Combined insets (margin + padding)
|
// Combined insets (margin + padding)
|
||||||
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
||||||
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
||||||
@@ -92,6 +97,9 @@ struct BlockStyle {
|
|||||||
result.directionDefined = true;
|
result.directionDefined = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fromBrElement is consumed by startNewTextBlock when an empty <br> block
|
||||||
|
// is merged with the following paragraph; never propagate it further.
|
||||||
|
result.fromBrElement = false;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,159 @@
|
|||||||
#include <BidiUtils.h>
|
#include <BidiUtils.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <Memory.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasFocus, const uint16_t textBytes) {
|
||||||
|
// Layout documented in TextBlock.h: 16-bit arrays first, then 8-bit arrays, then text.
|
||||||
|
size_t size = static_cast<size_t>(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t));
|
||||||
|
if (hasFocus) {
|
||||||
|
size += static_cast<size_t>(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t));
|
||||||
|
}
|
||||||
|
return size + textBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBlock::bindArenaPointers() {
|
||||||
|
uint8_t* base = arena.get();
|
||||||
|
const size_t wc = numWords;
|
||||||
|
textOffArr = reinterpret_cast<const uint16_t*>(base);
|
||||||
|
xposArr = reinterpret_cast<const int16_t*>(base + wc * 2);
|
||||||
|
size_t off = wc * 4;
|
||||||
|
if (focusPresent) {
|
||||||
|
focusSuffixXArr = reinterpret_cast<const uint16_t*>(base + off);
|
||||||
|
off += wc * 2;
|
||||||
|
}
|
||||||
|
stylesArr = base + off;
|
||||||
|
off += wc;
|
||||||
|
if (focusPresent) {
|
||||||
|
focusBoundaryArr = base + off;
|
||||||
|
off += wc;
|
||||||
|
}
|
||||||
|
textArr = reinterpret_cast<const char*>(base + off);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextBlock::TextBlock(const std::vector<std::string>& words, const std::vector<int16_t>& wordXpos,
|
||||||
|
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
|
||||||
|
const std::vector<uint16_t>& focusSuffixX, const BlockStyle& blockStyle)
|
||||||
|
: blockStyle(blockStyle) {
|
||||||
// Focus annotations are optional: empty vectors mean no word in this block has a split.
|
// Focus annotations are optional: empty vectors mean no word in this block has a split.
|
||||||
// When present, they must be sized in lockstep with words[].
|
// When present, they must be sized in lockstep with words[].
|
||||||
const bool hasFocus = !wordFocusBoundary.empty();
|
const bool hasFocus = !focusBoundary.empty();
|
||||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > 10000 ||
|
||||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
(hasFocus && (words.size() != focusBoundary.size() || words.size() != focusSuffixX.size()))) {
|
||||||
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
LOG_ERR("TXB", "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)",
|
||||||
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(),
|
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
||||||
(uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size());
|
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(focusBoundary.size()),
|
||||||
|
static_cast<uint32_t>(focusSuffixX.size()));
|
||||||
|
isValid = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
numWords = static_cast<uint16_t>(words.size());
|
||||||
|
focusPresent = hasFocus;
|
||||||
|
if (numWords == 0) {
|
||||||
|
return; // valid empty block, no arena
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: total text size, one NUL per word. A line is at most a physical
|
||||||
|
// row of the page, so uint16_t offsets are ample; reject anything larger.
|
||||||
|
size_t totalText = 0;
|
||||||
|
for (const auto& w : words) totalText += w.size() + 1;
|
||||||
|
if (totalText > UINT16_MAX) {
|
||||||
|
LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast<uint32_t>(totalText));
|
||||||
|
numWords = 0;
|
||||||
|
focusPresent = false;
|
||||||
|
isValid = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textBytes = static_cast<uint16_t>(totalText);
|
||||||
|
|
||||||
|
const size_t size = arenaSize(numWords, focusPresent, textBytes);
|
||||||
|
arena = makeUniqueNoThrow<uint8_t[]>(size);
|
||||||
|
if (!arena) {
|
||||||
|
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(size));
|
||||||
|
numWords = 0;
|
||||||
|
textBytes = 0;
|
||||||
|
focusPresent = false;
|
||||||
|
isValid = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bindArenaPointers();
|
||||||
|
|
||||||
|
// Pass 2: fill. Mutable aliases of the const views bound above.
|
||||||
|
auto* textOff = const_cast<uint16_t*>(textOffArr);
|
||||||
|
auto* xpos = const_cast<int16_t*>(xposArr);
|
||||||
|
auto* styles = const_cast<uint8_t*>(stylesArr);
|
||||||
|
auto* text = const_cast<char*>(textArr);
|
||||||
|
uint16_t off = 0;
|
||||||
|
for (uint16_t i = 0; i < numWords; i++) {
|
||||||
|
textOff[i] = off;
|
||||||
|
xpos[i] = wordXpos[i];
|
||||||
|
styles[i] = static_cast<uint8_t>(wordStyles[i]);
|
||||||
|
memcpy(text + off, words[i].data(), words[i].size());
|
||||||
|
off += static_cast<uint16_t>(words[i].size());
|
||||||
|
text[off++] = '\0';
|
||||||
|
}
|
||||||
|
if (focusPresent) {
|
||||||
|
auto* suffixX = const_cast<uint16_t*>(focusSuffixXArr);
|
||||||
|
auto* boundary = const_cast<uint8_t*>(focusBoundaryArr);
|
||||||
|
for (uint16_t i = 0; i < numWords; i++) {
|
||||||
|
suffixX[i] = focusSuffixX[i];
|
||||||
|
boundary[i] = focusBoundary[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||||
|
if (!isValid) {
|
||||||
|
LOG_ERR("TXB", "Render skipped: invalid block");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool scanning = renderer.isFontCacheScanning();
|
const bool scanning = renderer.isFontCacheScanning();
|
||||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||||
for (size_t i = 0; i < words.size(); i++) {
|
|
||||||
const int wordX = wordXpos[i] + x;
|
struct DecorationLineTracker {
|
||||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
EpdFontFamily::Style style;
|
||||||
const auto baseDir = static_cast<BidiUtils::BidiBaseDir>(
|
int yOffset;
|
||||||
BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0));
|
int startX = -1;
|
||||||
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
|
int endX = -1;
|
||||||
|
int yPos = 0;
|
||||||
|
|
||||||
|
bool active() const { return startX != -1; }
|
||||||
|
void reset() {
|
||||||
|
startX = -1;
|
||||||
|
endX = -1;
|
||||||
|
yPos = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DecorationLineTracker decorationLines[] = {
|
||||||
|
{EpdFontFamily::UNDERLINE, ascender + 2},
|
||||||
|
{EpdFontFamily::STRIKETHROUGH, ascender * 4 / 5},
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto flushDecoration = [&](DecorationLineTracker& line) {
|
||||||
|
if (line.active()) {
|
||||||
|
renderer.drawLine(line.startX, line.yPos, line.endX, line.yPos, 2, true);
|
||||||
|
line.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auto flushDecorations = [&]() {
|
||||||
|
for (auto& line : decorationLines) {
|
||||||
|
flushDecoration(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (uint16_t i = 0; i < numWords; i++) {
|
||||||
|
const char* word = wordText(i);
|
||||||
|
const int wordX = xposArr[i] + x;
|
||||||
|
const EpdFontFamily::Style currentStyle = wordStyle(i);
|
||||||
|
const auto baseDir =
|
||||||
|
static_cast<BidiUtils::BidiBaseDir>(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0));
|
||||||
|
const uint8_t boundary = focusBoundary(i);
|
||||||
|
|
||||||
// SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside
|
// SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside
|
||||||
// drawText, so these offsets are chosen relative to the full-size ascender:
|
// drawText, so these offsets are chosen relative to the full-size ascender:
|
||||||
@@ -49,54 +178,81 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
|
static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
|
||||||
"boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
|
"boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
|
||||||
const auto boldStyle = static_cast<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
|
const auto boldStyle = static_cast<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
|
||||||
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
|
const size_t boldLen =
|
||||||
memcpy(boldBuf, words[i].c_str(), boldLen);
|
std::min<size_t>({static_cast<size_t>(boundary), static_cast<size_t>(wordTextLen(i)), sizeof(boldBuf) - 1});
|
||||||
|
memcpy(boldBuf, word, boldLen);
|
||||||
boldBuf[boldLen] = '\0';
|
boldBuf[boldLen] = '\0';
|
||||||
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir);
|
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir);
|
||||||
const int suffixX = wordX + wordFocusSuffixX[i];
|
const int suffixX = wordX + focusSuffixXArr[i];
|
||||||
renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir);
|
renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir);
|
||||||
} else {
|
} else {
|
||||||
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
|
renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
if (scanning) {
|
||||||
const std::string& w = words[i];
|
continue;
|
||||||
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
}
|
||||||
const int underlineY = wordY + ascender + 2;
|
|
||||||
|
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
|
||||||
|
int lineStartX = wordX;
|
||||||
|
int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir);
|
||||||
|
|
||||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||||
underlineWidth = (underlineWidth + 1) / 2;
|
lineWidth = (lineWidth + 1) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
|
// Do not decorate the synthetic em-space used for paragraph indentation.
|
||||||
|
if (wordTextLen(i) >= 3 && static_cast<uint8_t>(word[0]) == 0xE2 && static_cast<uint8_t>(word[1]) == 0x80 &&
|
||||||
|
static_cast<uint8_t>(word[2]) == 0x83) {
|
||||||
|
const char* visibleText = word + 3;
|
||||||
|
lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
||||||
|
lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir);
|
||||||
|
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||||
|
lineWidth = (lineWidth + 1) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& line : decorationLines) {
|
||||||
|
if ((currentStyle & line.style) == 0) {
|
||||||
|
flushDecoration(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int lineY = wordY + line.yOffset;
|
||||||
|
if (line.active() && line.yPos != lineY) {
|
||||||
|
flushDecoration(line);
|
||||||
|
}
|
||||||
|
if (!line.active()) {
|
||||||
|
line.startX = lineStartX;
|
||||||
|
line.yPos = lineY;
|
||||||
|
}
|
||||||
|
line.endX = lineStartX + lineWidth;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flushDecorations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flushDecorations();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TextBlock::serialize(HalFile& file) const {
|
bool TextBlock::serialize(HalFile& file) const {
|
||||||
// Focus annotations are optional; vectors are either empty (no splits in this block)
|
if (!isValid) {
|
||||||
// or sized in lockstep with words[].
|
LOG_ERR("TXB", "Serialization failed: invalid block");
|
||||||
const bool hasFocus = !wordFocusBoundary.empty();
|
|
||||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
|
||||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
|
||||||
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
|
||||||
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
|
||||||
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
|
|
||||||
static_cast<uint32_t>(wordFocusSuffixX.size()));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word data
|
// Word data: scalars, then the arena verbatim -- its in-memory layout is
|
||||||
serialization::writePod(file, static_cast<uint16_t>(words.size()));
|
// exactly the on-disk layout (see TextBlock.h), so one write covers all
|
||||||
for (const auto& w : words) serialization::writeString(file, w);
|
// per-word arrays and the text blob.
|
||||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
serialization::writePod(file, numWords);
|
||||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
serialization::writePod(file, static_cast<uint8_t>(focusPresent ? 1 : 0));
|
||||||
// Focus block: 1-byte presence flag, followed by per-word vectors only when present.
|
serialization::writePod(file, textBytes);
|
||||||
// Saves 3 bytes/word when focus reading is disabled or no word on this line was split.
|
if (numWords > 0) {
|
||||||
serialization::writePod(file, static_cast<uint8_t>(hasFocus ? 1 : 0));
|
const size_t size = arenaSize(numWords, focusPresent, textBytes);
|
||||||
if (hasFocus) {
|
if (file.write(arena.get(), size) != size) {
|
||||||
for (auto b : wordFocusBoundary) serialization::writePod(file, b);
|
LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast<uint32_t>(size));
|
||||||
for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx);
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Style (alignment + margins/padding/indent)
|
// Style (alignment + margins/padding/indent)
|
||||||
@@ -120,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const {
|
|||||||
|
|
||||||
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
|
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
|
||||||
uint16_t wc;
|
uint16_t wc;
|
||||||
std::vector<std::string> words;
|
uint8_t hasFocus;
|
||||||
std::vector<int16_t> wordXpos;
|
uint16_t textBytes;
|
||||||
std::vector<EpdFontFamily::Style> wordStyles;
|
|
||||||
std::vector<uint8_t> wordFocusBoundary;
|
|
||||||
std::vector<uint16_t> wordFocusSuffixX;
|
|
||||||
BlockStyle blockStyle;
|
|
||||||
|
|
||||||
// Word count
|
|
||||||
serialization::readPod(file, wc);
|
serialization::readPod(file, wc);
|
||||||
|
serialization::readPod(file, hasFocus);
|
||||||
|
serialization::readPod(file, textBytes);
|
||||||
|
|
||||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
// Sanity checks: cap the arena allocation and reject impossible geometry
|
||||||
|
// (every word carries at least its NUL terminator).
|
||||||
if (wc > 10000) {
|
if (wc > 10000) {
|
||||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) {
|
||||||
|
LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
// Word data
|
std::unique_ptr<TextBlock> block(new (std::nothrow) TextBlock());
|
||||||
words.resize(wc);
|
if (!block) {
|
||||||
wordXpos.resize(wc);
|
LOG_ERR("TXB", "OOM: TextBlock");
|
||||||
wordStyles.resize(wc);
|
return nullptr;
|
||||||
for (auto& w : words) serialization::readString(file, w);
|
}
|
||||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
block->numWords = wc;
|
||||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
block->textBytes = textBytes;
|
||||||
// Focus block: presence flag, then vectors only if present. Empty vectors when absent
|
block->focusPresent = hasFocus != 0;
|
||||||
// signal "no splits in this block" to render() (zero per-word RAM cost).
|
|
||||||
uint8_t hasFocus;
|
if (wc > 0) {
|
||||||
serialization::readPod(file, hasFocus);
|
const size_t size = arenaSize(wc, block->focusPresent, textBytes);
|
||||||
if (hasFocus) {
|
block->arena = makeUniqueNoThrow<uint8_t[]>(size);
|
||||||
wordFocusBoundary.resize(wc);
|
if (!block->arena) {
|
||||||
wordFocusSuffixX.resize(wc);
|
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(size));
|
||||||
for (auto& b : wordFocusBoundary) serialization::readPod(file, b);
|
return nullptr;
|
||||||
for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx);
|
}
|
||||||
|
if (file.read(block->arena.get(), size) != size) {
|
||||||
|
LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast<uint32_t>(size));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
block->bindArenaPointers();
|
||||||
|
|
||||||
|
// Validate offsets before anything dereferences wordText(): offset 0 first,
|
||||||
|
// strictly increasing, in bounds, and every word NUL-terminated (word i ends
|
||||||
|
// at the byte before offset i+1; the last word at the last text byte).
|
||||||
|
const uint16_t* textOff = block->textOffArr;
|
||||||
|
const char* text = block->textArr;
|
||||||
|
if (textOff[0] != 0 || text[textBytes - 1] != '\0') {
|
||||||
|
LOG_ERR("TXB", "Deserialization failed: corrupt text layout");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
for (uint16_t i = 1; i < wc; i++) {
|
||||||
|
if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') {
|
||||||
|
LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Style (alignment + margins/padding/indent)
|
// Style (alignment + margins/padding/indent)
|
||||||
|
BlockStyle& blockStyle = block->blockStyle;
|
||||||
serialization::readPod(file, blockStyle.alignment);
|
serialization::readPod(file, blockStyle.alignment);
|
||||||
serialization::readPod(file, blockStyle.textAlignDefined);
|
serialization::readPod(file, blockStyle.textAlignDefined);
|
||||||
serialization::readPod(file, blockStyle.marginTop);
|
serialization::readPod(file, blockStyle.marginTop);
|
||||||
@@ -170,7 +349,5 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
|
|||||||
serialization::readPod(file, blockStyle.isRtl);
|
serialization::readPod(file, blockStyle.isRtl);
|
||||||
serialization::readPod(file, blockStyle.directionDefined);
|
serialization::readPod(file, blockStyle.directionDefined);
|
||||||
|
|
||||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
|
return block;
|
||||||
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
|
|
||||||
blockStyle));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,42 +9,83 @@
|
|||||||
#include "Block.h"
|
#include "Block.h"
|
||||||
#include "BlockStyle.h"
|
#include "BlockStyle.h"
|
||||||
|
|
||||||
// Represents a line of text on a page
|
// Represents a line of text on a page.
|
||||||
|
//
|
||||||
|
// All per-word data lives in ONE flat heap allocation (the arena) instead of
|
||||||
|
// six parallel vectors: a resident page holds ~25-30 of these blocks, and the
|
||||||
|
// vector-of-string layout cost ~250 throwing allocations per page load, which
|
||||||
|
// was the primary driver of heap fragmentation on the ESP32-C3.
|
||||||
|
//
|
||||||
|
// Arena layout, in order (2-byte alignment holds by construction: all 16-bit
|
||||||
|
// arrays come first and the arena base is allocator-aligned; RISC-V faults on
|
||||||
|
// unaligned multi-byte access):
|
||||||
|
// uint16_t textOff[wordCount] byte offset of word i's text in text[]
|
||||||
|
// int16_t xpos[wordCount]
|
||||||
|
// uint16_t focusSuffixX[wordCount] present only when focusPresent
|
||||||
|
// uint8_t styles[wordCount]
|
||||||
|
// uint8_t focusBoundary[wordCount] present only when focusPresent
|
||||||
|
// char text[textBytes] all words back to back, NUL-terminated
|
||||||
|
//
|
||||||
|
// Each word is stored NUL-terminated so render() can hand `text + textOff[i]`
|
||||||
|
// straight to C APIs (drawText) with no std::string materialization.
|
||||||
|
//
|
||||||
|
// Focus split semantics (unchanged from the vector layout): boundary N > 0
|
||||||
|
// means the first N bytes of word i render bold, the remainder in the base
|
||||||
|
// style. N is bounded to 9 codepoints (<= 36 UTF-8 bytes) by the clamp in
|
||||||
|
// ParsedText::addWord. focusSuffixX is the pre-computed pixel offset from the
|
||||||
|
// word start to the regular suffix. Both arrays are omitted from the arena
|
||||||
|
// entirely when no word on the line has a split (zero per-word RAM cost when
|
||||||
|
// focus reading is disabled).
|
||||||
class TextBlock final : public Block {
|
class TextBlock final : public Block {
|
||||||
private:
|
private:
|
||||||
std::vector<std::string> words;
|
|
||||||
std::vector<int16_t> wordXpos;
|
|
||||||
std::vector<EpdFontFamily::Style> wordStyles;
|
|
||||||
// Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold,
|
|
||||||
// the remainder in the base style. 0 means no split (whole word uses wordStyles[i]).
|
|
||||||
// N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by
|
|
||||||
// FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t.
|
|
||||||
// Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost
|
|
||||||
// when focus reading is disabled, or on lines that happen to contain no splittable words).
|
|
||||||
std::vector<uint8_t> wordFocusBoundary;
|
|
||||||
// Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0.
|
|
||||||
// Eliminates getTextAdvanceX from the render path. 0 when boundary == 0.
|
|
||||||
// Empty in lockstep with wordFocusBoundary.
|
|
||||||
std::vector<uint16_t> wordFocusSuffixX;
|
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
uint16_t numWords = 0;
|
||||||
|
uint16_t textBytes = 0; // total size of the text region, including NULs
|
||||||
|
bool focusPresent = false;
|
||||||
|
bool isValid = true;
|
||||||
|
// The ONLY allocation: makeUniqueNoThrow, so OOM yields an invalid block
|
||||||
|
// instead of abort() (bare new is not nothrow with -fno-exceptions).
|
||||||
|
std::unique_ptr<uint8_t[]> arena;
|
||||||
|
// Typed views into the arena, bound once after the arena is filled. All
|
||||||
|
// 16-bit bases sit at even offsets, so direct dereference is alignment-safe.
|
||||||
|
const uint16_t* textOffArr = nullptr;
|
||||||
|
const int16_t* xposArr = nullptr;
|
||||||
|
const uint16_t* focusSuffixXArr = nullptr; // null when !focusPresent
|
||||||
|
const uint8_t* stylesArr = nullptr;
|
||||||
|
const uint8_t* focusBoundaryArr = nullptr; // null when !focusPresent
|
||||||
|
const char* textArr = nullptr;
|
||||||
|
|
||||||
|
TextBlock() = default; // deserialize() fills the fields directly
|
||||||
|
static size_t arenaSize(uint16_t wordCount, bool hasFocus, uint16_t textBytes);
|
||||||
|
void bindArenaPointers();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
|
// Flatten-on-construct: copies the layout-time vectors into the arena; the
|
||||||
std::vector<EpdFontFamily::Style> word_styles, std::vector<uint8_t> focus_boundary,
|
// vectors die with the caller. On arena OOM the block is empty and valid()
|
||||||
std::vector<uint16_t> focus_suffix_x, const BlockStyle& blockStyle = BlockStyle())
|
// is false -- callers must check and fail the line instead of using it.
|
||||||
: words(std::move(words)),
|
explicit TextBlock(const std::vector<std::string>& words, const std::vector<int16_t>& wordXpos,
|
||||||
wordXpos(std::move(word_xpos)),
|
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
|
||||||
wordStyles(std::move(word_styles)),
|
const std::vector<uint16_t>& focusSuffixX, const BlockStyle& blockStyle = BlockStyle());
|
||||||
wordFocusBoundary(std::move(focus_boundary)),
|
|
||||||
wordFocusSuffixX(std::move(focus_suffix_x)),
|
|
||||||
blockStyle(blockStyle) {}
|
|
||||||
~TextBlock() override = default;
|
~TextBlock() override = default;
|
||||||
|
TextBlock(const TextBlock&) = delete;
|
||||||
|
TextBlock& operator=(const TextBlock&) = delete;
|
||||||
|
|
||||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
const std::vector<std::string>& getWords() const { return words; }
|
bool isEmpty() override { return numWords == 0; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool valid() const { return isValid; }
|
||||||
size_t wordCount() const { return words.size(); }
|
uint16_t wordCount() const { return numWords; }
|
||||||
// given a renderer works out where to break the words into lines
|
// NUL-terminated by construction; safe to pass to C APIs directly.
|
||||||
|
const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; }
|
||||||
|
uint16_t wordTextLen(const uint16_t i) const {
|
||||||
|
const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes;
|
||||||
|
return end - textOffArr[i] - 1; // exclude the NUL
|
||||||
|
}
|
||||||
|
int16_t wordXpos(const uint16_t i) const { return xposArr[i]; }
|
||||||
|
EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast<EpdFontFamily::Style>(stylesArr[i]); }
|
||||||
|
uint8_t focusBoundary(const uint16_t i) const { return focusPresent ? focusBoundaryArr[i] : 0; }
|
||||||
|
uint16_t focusSuffixX(const uint16_t i) const { return focusPresent ? focusSuffixXArr[i] : 0; }
|
||||||
|
|
||||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||||
BlockType getType() override { return TEXT_BLOCK; }
|
BlockType getType() override { return TEXT_BLOCK; }
|
||||||
bool serialize(HalFile& file) const;
|
bool serialize(HalFile& file) const;
|
||||||
|
|||||||
@@ -67,13 +67,6 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe
|
|||||||
[](char a, char b) { return asciiToLower(a) == b; });
|
[](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.
|
// 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
|
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
||||||
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
||||||
@@ -252,11 +245,20 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
||||||
// text-decoration can have multiple space-separated values
|
// text-decoration can have multiple space-separated values. Compare whole tokens
|
||||||
if (icontainsAscii(val, "underline")) {
|
// so malformed values like "notunderline" do not accidentally enable a line.
|
||||||
return CssTextDecoration::Underline;
|
CssTextDecoration result = CssTextDecoration::None;
|
||||||
}
|
bool explicitNone = false;
|
||||||
return CssTextDecoration::None;
|
forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) {
|
||||||
|
if (iequalsAscii(token, "none")) {
|
||||||
|
explicitNone = true;
|
||||||
|
} else if (iequalsAscii(token, "underline")) {
|
||||||
|
result = result | CssTextDecoration::Underline;
|
||||||
|
} else if (iequalsAscii(token, "line-through")) {
|
||||||
|
result = result | CssTextDecoration::LineThrough;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return explicitNone ? CssTextDecoration::None : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
CssLength CssParser::interpretLength(std::string_view val) {
|
CssLength CssParser::interpretLength(std::string_view val) {
|
||||||
@@ -868,7 +870,7 @@ bool CssParser::loadFromCache() {
|
|||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
class CssParser {
|
class CssParser {
|
||||||
public:
|
public:
|
||||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||||
static constexpr uint8_t CSS_CACHE_VERSION = 6;
|
static constexpr uint8_t CSS_CACHE_VERSION = 7;
|
||||||
|
|
||||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
||||||
~CssParser() = default;
|
~CssParser() = default;
|
||||||
|
|||||||
@@ -52,8 +52,19 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
|
|||||||
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
||||||
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
||||||
|
|
||||||
// Text decoration options
|
// Text decoration options. Values are bit flags so CSS can combine multiple line decorations.
|
||||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 };
|
||||||
|
|
||||||
|
constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) {
|
||||||
|
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) {
|
||||||
|
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr uint8_t CSS_TEXT_DECORATION_MASK =
|
||||||
|
static_cast<uint8_t>(CssTextDecoration::Underline) | static_cast<uint8_t>(CssTextDecoration::LineThrough);
|
||||||
|
|
||||||
// Display options - only None and Block are relevant for e-ink rendering
|
// Display options - only None and Block are relevant for e-ink rendering
|
||||||
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
|||||||
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
||||||
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
||||||
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
||||||
|
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
|
||||||
constexpr const char* IMAGE_TAGS[] = {"img"};
|
constexpr const char* IMAGE_TAGS[] = {"img"};
|
||||||
constexpr const char* SKIP_TAGS[] = {"head"};
|
constexpr const char* SKIP_TAGS[] = {"head"};
|
||||||
|
|
||||||
@@ -89,13 +90,50 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update effective bold/italic/underline based on block style and inline style stack
|
EpdFontFamily::Style ChapterHtmlSlimParser::fontStyleForTextDecoration(const CssTextDecoration decoration) {
|
||||||
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR;
|
||||||
|
if ((decoration & CssTextDecoration::Underline) != CssTextDecoration::None) {
|
||||||
|
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::UNDERLINE);
|
||||||
|
}
|
||||||
|
if ((decoration & CssTextDecoration::LineThrough) != CssTextDecoration::None) {
|
||||||
|
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::STRIKETHROUGH);
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChapterHtmlSlimParser::applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css) {
|
||||||
|
if (css.hasTextDecoration()) {
|
||||||
|
entry.hasTextDecoration = true;
|
||||||
|
entry.textDecoration = css.textDecoration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChapterHtmlSlimParser::pushDecorationStyleEntry(const CssTextDecoration defaultDecoration,
|
||||||
|
const CssStyle& cssStyle) {
|
||||||
|
StyleStackEntry entry;
|
||||||
|
entry.depth = depth;
|
||||||
|
entry.hasTextDecoration = true;
|
||||||
|
entry.textDecoration = cssStyle.hasTextDecoration() ? cssStyle.textDecoration : defaultDecoration;
|
||||||
|
if (cssStyle.hasFontWeight()) {
|
||||||
|
entry.hasBold = true;
|
||||||
|
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
|
}
|
||||||
|
if (cssStyle.hasFontStyle()) {
|
||||||
|
entry.hasItalic = true;
|
||||||
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
|
}
|
||||||
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
|
inlineStyleStack.push_back(entry);
|
||||||
|
updateEffectiveInlineStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update effective bold/italic/decorations based on block style and inline style stack
|
||||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||||
// Start with block-level styles
|
// Start with block-level styles
|
||||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
effectiveUnderline =
|
effectiveTextDecoration =
|
||||||
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
|
||||||
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
||||||
effectiveDirection = currentCssStyle.direction;
|
effectiveDirection = currentCssStyle.direction;
|
||||||
effectiveSup = false;
|
effectiveSup = false;
|
||||||
@@ -109,8 +147,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
|||||||
if (entry.hasItalic) {
|
if (entry.hasItalic) {
|
||||||
effectiveItalic = entry.italic;
|
effectiveItalic = entry.italic;
|
||||||
}
|
}
|
||||||
if (entry.hasUnderline) {
|
// CSS line decorations propagate through descendants; child entries add
|
||||||
effectiveUnderline = entry.underline;
|
// their own lines but cannot cancel an ancestor's already active line.
|
||||||
|
if (entry.hasTextDecoration) {
|
||||||
|
effectiveTextDecoration = effectiveTextDecoration | entry.textDecoration;
|
||||||
}
|
}
|
||||||
if (entry.hasDirection) {
|
if (entry.hasDirection) {
|
||||||
effectiveDirectionDefined = true;
|
effectiveDirectionDefined = true;
|
||||||
@@ -164,7 +204,6 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
|||||||
// Determine font style from depth-based tracking and CSS effective style
|
// Determine font style from depth-based tracking and CSS effective style
|
||||||
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
||||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||||
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
|
||||||
|
|
||||||
// Combine style flags using bitwise OR
|
// Combine style flags using bitwise OR
|
||||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||||
@@ -174,9 +213,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
|||||||
if (isItalic) {
|
if (isItalic) {
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
||||||
}
|
}
|
||||||
if (isUnderline) {
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
|
||||||
}
|
|
||||||
if (effectiveSup) {
|
if (effectiveSup) {
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
||||||
} else if (effectiveSub) {
|
} else if (effectiveSub) {
|
||||||
@@ -202,7 +239,16 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
|||||||
// open. Merge those into the new style so the first child in a container inherits
|
// open. Merge those into the new style so the first child in a container inherits
|
||||||
// the container's vertical spacing.
|
// the container's vertical spacing.
|
||||||
const auto style = currentTextBlock->getBlockStyle();
|
const auto style = currentTextBlock->getBlockStyle();
|
||||||
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
|
BlockStyle incoming = blockStyle;
|
||||||
|
if (style.fromBrElement) {
|
||||||
|
// The empty block was created by a <br> section separator. Inject a full line of
|
||||||
|
// blank space before the following paragraph so the scene/section break is visible.
|
||||||
|
// This only fires when the <br> block stayed empty (i.e. no inline text was added).
|
||||||
|
const int16_t lineHeight = static_cast<int16_t>(renderer.getLineHeight(fontId) * lineCompression + 0.5f);
|
||||||
|
incoming.marginTop = static_cast<int16_t>(incoming.marginTop + lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(incoming, BlockStyle::CombineAxis::Vertical));
|
||||||
|
|
||||||
flushPendingAnchor();
|
flushPendingAnchor();
|
||||||
return;
|
return;
|
||||||
@@ -420,14 +466,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
headerStyle.bold = false;
|
headerStyle.bold = false;
|
||||||
headerStyle.hasItalic = true;
|
headerStyle.hasItalic = true;
|
||||||
headerStyle.italic = true;
|
headerStyle.italic = true;
|
||||||
headerStyle.hasUnderline = true;
|
|
||||||
headerStyle.underline = false;
|
|
||||||
self->inlineStyleStack.push_back(headerStyle);
|
self->inlineStyleStack.push_back(headerStyle);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
|
const CssTextDecoration savedTextDecoration = self->effectiveTextDecoration;
|
||||||
|
self->effectiveTextDecoration = CssTextDecoration::None;
|
||||||
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
}
|
}
|
||||||
|
self->effectiveTextDecoration = savedTextDecoration;
|
||||||
self->nextWordContinues = false;
|
self->nextWordContinues = false;
|
||||||
self->inlineStyleStack.pop_back();
|
self->inlineStyleStack.pop_back();
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -760,12 +807,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->currentFootnote.number[0] = '\0';
|
self->currentFootnote.number[0] = '\0';
|
||||||
self->currentFootnoteLinkTextLen = 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;
|
StyleStackEntry entry;
|
||||||
entry.depth = self->depth;
|
entry.depth = self->depth;
|
||||||
entry.hasUnderline = true;
|
entry.hasTextDecoration = true;
|
||||||
entry.underline = true;
|
entry.textDecoration = CssTextDecoration::Underline;
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -818,7 +864,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
}
|
}
|
||||||
self->startNewTextBlock(self->blockStyleStack.back().withoutBottom());
|
// Tag the new block so startNewTextBlock can inject a full line-height gap if
|
||||||
|
// the block remains empty (i.e. <br> is a section separator between paragraphs).
|
||||||
|
// If the block gets text added before the next block opens it becomes non-empty,
|
||||||
|
// goes through makePages() normally, and the flag has no effect (inline <br> case).
|
||||||
|
BlockStyle brStyle =
|
||||||
|
self->currentTextBlock ? self->currentTextBlock->getBlockStyle() : self->blockStyleStack.back();
|
||||||
|
brStyle.fromBrElement = true;
|
||||||
|
self->startNewTextBlock(brStyle);
|
||||||
} else {
|
} else {
|
||||||
self->currentCssStyle = cssStyle;
|
self->currentCssStyle = cssStyle;
|
||||||
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle,
|
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle,
|
||||||
@@ -837,23 +890,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
self->nextWordContinues = true;
|
self->nextWordContinues = true;
|
||||||
}
|
}
|
||||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
self->pushDecorationStyleEntry(CssTextDecoration::Underline, cssStyle);
|
||||||
// Push inline style entry for underline tag
|
} else if (matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS))) {
|
||||||
StyleStackEntry entry;
|
// Flush buffer before style change so preceding text gets current style
|
||||||
entry.depth = self->depth; // Track depth for matching pop
|
if (self->partWordBufferIndex > 0) {
|
||||||
entry.hasUnderline = true;
|
self->flushPartWordBuffer();
|
||||||
entry.underline = true;
|
self->nextWordContinues = true;
|
||||||
if (cssStyle.hasFontWeight()) {
|
|
||||||
entry.hasBold = true;
|
|
||||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
|
||||||
}
|
}
|
||||||
if (cssStyle.hasFontStyle()) {
|
self->pushDecorationStyleEntry(CssTextDecoration::LineThrough, cssStyle);
|
||||||
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))) {
|
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
|
||||||
// Flush buffer before style change so preceding text gets current style
|
// Flush buffer before style change so preceding text gets current style
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
@@ -870,10 +914,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasItalic = true;
|
entry.hasItalic = true;
|
||||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
applyTextDecorationToEntry(entry, cssStyle);
|
||||||
entry.hasUnderline = true;
|
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
|
||||||
}
|
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -893,10 +934,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasBold = true;
|
entry.hasBold = true;
|
||||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
applyTextDecorationToEntry(entry, cssStyle);
|
||||||
entry.hasUnderline = true;
|
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
|
||||||
}
|
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -935,10 +973,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasItalic = true;
|
entry.hasItalic = true;
|
||||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
}
|
}
|
||||||
if (cssStyle.hasTextDecoration()) {
|
applyTextDecorationToEntry(entry, cssStyle);
|
||||||
entry.hasUnderline = true;
|
|
||||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
|
||||||
}
|
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
if (cssStyle.hasVerticalAlign()) {
|
if (cssStyle.hasVerticalAlign()) {
|
||||||
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
||||||
@@ -1150,9 +1185,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
||||||
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
||||||
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
||||||
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
|
||||||
|
|
||||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic;
|
||||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||||
const bool tableStructuralTag = isTableStructuralTag(name);
|
const bool tableStructuralTag = isTableStructuralTag(name);
|
||||||
|
|
||||||
@@ -1171,7 +1205,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
||||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
||||||
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
||||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
|
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) ||
|
||||||
|
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
|
||||||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
||||||
|
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
@@ -1230,11 +1265,6 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
self->italicUntilDepth = INT_MAX;
|
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
|
// Pop from inline style stack if we pushed an entry at this depth
|
||||||
// This handles all inline elements: b, i, u, span, etc.
|
// This handles all inline elements: b, i, u, span, etc.
|
||||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||||
@@ -1261,7 +1291,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
|
||||||
|
|
||||||
|
bool ChapterHtmlSlimParser::beginParse() {
|
||||||
// Initialize block style stack with a root entry representing "no ancestor block elements".
|
// 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
|
// The user's paragraph alignment is set as the default so child elements without explicit
|
||||||
// text-align inherit it correctly through getCombinedBlockStyle.
|
// text-align inherit it correctly through getCombinedBlockStyle.
|
||||||
@@ -1279,67 +1311,78 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|||||||
paragraphAlignmentBlockStyle.alignment = align;
|
paragraphAlignmentBlockStyle.alignment = align;
|
||||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
startNewTextBlock(paragraphAlignmentBlockStyle);
|
||||||
|
|
||||||
XML_Parser parser = XML_ParserCreate(nullptr);
|
xmlParser_ = XML_ParserCreate(nullptr);
|
||||||
int done;
|
if (!xmlParser_) {
|
||||||
|
|
||||||
if (!parser) {
|
|
||||||
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
||||||
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
||||||
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
|
XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand);
|
||||||
|
|
||||||
HalFile file;
|
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
|
||||||
if (!Storage.openFileForRead("EHP", filepath, file)) {
|
destroyXmlParser(xmlParser_);
|
||||||
destroyXmlParser(parser);
|
xmlParser_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file size to decide whether to show indexing popup.
|
// Get file size to decide whether to show indexing popup.
|
||||||
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
|
||||||
popupFn();
|
popupFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
XML_SetUserData(parser, this);
|
XML_SetUserData(xmlParser_, this);
|
||||||
XML_SetElementHandler(parser, startElement, endElement);
|
XML_SetElementHandler(xmlParser_, startElement, endElement);
|
||||||
XML_SetCharacterDataHandler(parser, characterData);
|
XML_SetCharacterDataHandler(xmlParser_, characterData);
|
||||||
|
|
||||||
// Compute the time taken to parse and build pages
|
parseStartTime_ = millis();
|
||||||
const uint32_t chapterStartTime = millis();
|
return true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
|
ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() {
|
||||||
|
void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE);
|
||||||
|
if (!buf) {
|
||||||
|
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||||
|
return ParseStatus::Error;
|
||||||
|
}
|
||||||
|
|
||||||
if (len == 0 && file.available() > 0) {
|
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
|
||||||
LOG_ERR("EHP", "File read error");
|
|
||||||
destroyXmlParser(parser);
|
|
||||||
file.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
done = file.available() == 0;
|
if (len == 0 && parseFile_.available() > 0) {
|
||||||
|
LOG_ERR("EHP", "File read error");
|
||||||
|
return ParseStatus::Error;
|
||||||
|
}
|
||||||
|
|
||||||
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
const int done = parseFile_.available() == 0;
|
||||||
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);
|
|
||||||
|
|
||||||
destroyXmlParser(parser);
|
if (XML_ParseBuffer(xmlParser_, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||||
file.close();
|
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_),
|
||||||
|
XML_ErrorString(XML_GetErrorCode(xmlParser_)));
|
||||||
|
return ParseStatus::Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return done ? ParseStatus::Done : ParseStatus::More;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChapterHtmlSlimParser::abortParse() {
|
||||||
|
if (xmlParser_) {
|
||||||
|
destroyXmlParser(xmlParser_);
|
||||||
|
xmlParser_ = nullptr;
|
||||||
|
}
|
||||||
|
// Only close the file if it was successfully opened in beginParse()
|
||||||
|
if (parseFile_.isOpen()) {
|
||||||
|
parseFile_.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChapterHtmlSlimParser::finishParse() {
|
||||||
|
if (xmlParser_) {
|
||||||
|
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_);
|
||||||
|
destroyXmlParser(xmlParser_);
|
||||||
|
xmlParser_ = nullptr;
|
||||||
|
}
|
||||||
|
parseFile_.close();
|
||||||
|
|
||||||
// Process last page if there is still text
|
// Process last page if there is still text
|
||||||
if (currentTextBlock) {
|
if (currentTextBlock) {
|
||||||
@@ -1357,6 +1400,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|||||||
return true;
|
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) {
|
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
||||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
#include <expat.h>
|
#include <expat.h>
|
||||||
|
|
||||||
#include <climits>
|
#include <climits>
|
||||||
@@ -31,7 +32,6 @@ class ChapterHtmlSlimParser {
|
|||||||
int skipUntilDepth = INT_MAX;
|
int skipUntilDepth = INT_MAX;
|
||||||
int boldUntilDepth = INT_MAX;
|
int boldUntilDepth = INT_MAX;
|
||||||
int italicUntilDepth = INT_MAX;
|
int italicUntilDepth = INT_MAX;
|
||||||
int underlineUntilDepth = INT_MAX;
|
|
||||||
// buffer for building up words from characters, will auto break if longer than this
|
// buffer for building up words from characters, will auto break if longer than this
|
||||||
// leave one char at end for null pointer
|
// leave one char at end for null pointer
|
||||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||||
@@ -60,7 +60,8 @@ class ChapterHtmlSlimParser {
|
|||||||
int depth = 0;
|
int depth = 0;
|
||||||
bool hasBold = false, bold = false;
|
bool hasBold = false, bold = false;
|
||||||
bool hasItalic = false, italic = false;
|
bool hasItalic = false, italic = false;
|
||||||
bool hasUnderline = false, underline = false;
|
bool hasTextDecoration = false;
|
||||||
|
CssTextDecoration textDecoration = CssTextDecoration::None;
|
||||||
bool hasDirection = false;
|
bool hasDirection = false;
|
||||||
CssTextDirection direction = CssTextDirection::Ltr;
|
CssTextDirection direction = CssTextDirection::Ltr;
|
||||||
bool hasSup = false, sup = false;
|
bool hasSup = false, sup = false;
|
||||||
@@ -71,7 +72,7 @@ class ChapterHtmlSlimParser {
|
|||||||
CssStyle currentCssStyle;
|
CssStyle currentCssStyle;
|
||||||
bool effectiveBold = false;
|
bool effectiveBold = false;
|
||||||
bool effectiveItalic = false;
|
bool effectiveItalic = false;
|
||||||
bool effectiveUnderline = false;
|
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
|
||||||
bool effectiveDirectionDefined = false;
|
bool effectiveDirectionDefined = false;
|
||||||
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
||||||
bool effectiveSup = false;
|
bool effectiveSup = false;
|
||||||
@@ -96,12 +97,25 @@ class ChapterHtmlSlimParser {
|
|||||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||||
int wordsExtractedInBlock = 0;
|
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 updateEffectiveInlineStyle();
|
||||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||||
void flushPendingAnchor();
|
void flushPendingAnchor();
|
||||||
void flushPartWordBuffer();
|
void flushPartWordBuffer();
|
||||||
void makePages();
|
void makePages();
|
||||||
|
static EpdFontFamily::Style fontStyleForTextDecoration(CssTextDecoration decoration);
|
||||||
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
|
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);
|
void emitHorizontalRule(const BlockStyle& blockStyle);
|
||||||
// XML callbacks
|
// XML callbacks
|
||||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||||
@@ -141,8 +155,28 @@ class ChapterHtmlSlimParser {
|
|||||||
imageBasePath(imageBasePath),
|
imageBasePath(imageBasePath),
|
||||||
tocAnchors(std::move(tocAnchors)) {}
|
tocAnchors(std::move(tocAnchors)) {}
|
||||||
|
|
||||||
~ChapterHtmlSlimParser() = default;
|
~ChapterHtmlSlimParser();
|
||||||
|
|
||||||
|
// One-shot parse: builds every page before returning (begin + step* + finish).
|
||||||
bool parseAndBuildPages();
|
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);
|
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||||
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
||||||
|
|
||||||
|
// Byte progress of the in-flight parse, used to estimate a still-building section's total page
|
||||||
|
// count (a giant single-spine book never fully lays out, so its real count is unknown). Valid
|
||||||
|
// between beginParse() and finishParse()/abortParse().
|
||||||
|
size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; }
|
||||||
|
size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; }
|
||||||
};
|
};
|
||||||
|
|||||||
+48
-40
@@ -79,6 +79,53 @@ std::string normalisePath(const std::string& path) {
|
|||||||
return result;
|
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) {
|
void sortFileList(std::vector<std::string>& strs) {
|
||||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||||
// Directories first
|
// Directories first
|
||||||
@@ -86,46 +133,7 @@ void sortFileList(std::vector<std::string>& strs) {
|
|||||||
bool isDir2 = str2.back() == '/';
|
bool isDir2 = str2.back() == '/';
|
||||||
if (isDir1 != isDir2) return isDir1;
|
if (isDir1 != isDir2) return isDir1;
|
||||||
|
|
||||||
// Start naive natural sort
|
return naturalLess(str1, str2);
|
||||||
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';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path);
|
|||||||
|
|
||||||
std::string normalisePath(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);
|
void sortFileList(std::vector<std::string>& strs);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ void FontCacheManager::resetStats() {
|
|||||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||||
|
|
||||||
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
||||||
scanText_ += text;
|
if (!text) return;
|
||||||
|
const size_t remaining = (scanTextLen_ < SCAN_TEXT_CAPACITY - 1) ? (SCAN_TEXT_CAPACITY - 1 - scanTextLen_) : 0;
|
||||||
|
if (remaining > 0) {
|
||||||
|
const size_t textLen = strnlen(text, remaining);
|
||||||
|
memcpy(scanText_ + scanTextLen_, text, textLen);
|
||||||
|
scanTextLen_ += textLen;
|
||||||
|
scanText_[scanTextLen_] = '\0';
|
||||||
|
}
|
||||||
if (scanFontId_ < 0) scanFontId_ = fontId;
|
if (scanFontId_ < 0) scanFontId_ = fontId;
|
||||||
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
||||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
||||||
@@ -80,15 +87,15 @@ FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manage
|
|||||||
manager_->scanMode_ = ScanMode::Scanning;
|
manager_->scanMode_ = ScanMode::Scanning;
|
||||||
manager_->clearCache();
|
manager_->clearCache();
|
||||||
manager_->resetStats();
|
manager_->resetStats();
|
||||||
manager_->scanText_.clear();
|
manager_->scanTextLen_ = 0;
|
||||||
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
|
manager_->scanText_[0] = '\0';
|
||||||
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
||||||
manager_->scanFontId_ = -1;
|
manager_->scanFontId_ = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||||
manager_->scanMode_ = ScanMode::None;
|
manager_->scanMode_ = ScanMode::None;
|
||||||
if (manager_->scanText_.empty()) return;
|
if (manager_->scanTextLen_ == 0) return;
|
||||||
|
|
||||||
// Build style bitmask from all styles that appeared during the scan
|
// Build style bitmask from all styles that appeared during the scan
|
||||||
uint8_t styleMask = 0;
|
uint8_t styleMask = 0;
|
||||||
@@ -97,11 +104,10 @@ void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
|||||||
}
|
}
|
||||||
if (styleMask == 0) styleMask = 1; // default to regular
|
if (styleMask == 0) styleMask = 1; // default to regular
|
||||||
|
|
||||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
|
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_, styleMask);
|
||||||
|
|
||||||
// Free scan string memory
|
manager_->scanTextLen_ = 0;
|
||||||
manager_->scanText_.clear();
|
manager_->scanText_[0] = '\0';
|
||||||
manager_->scanText_.shrink_to_fit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
|
||||||
|
|
||||||
class FontDecompressor;
|
class FontDecompressor;
|
||||||
class SdCardFont;
|
class SdCardFont;
|
||||||
@@ -51,7 +51,9 @@ class FontCacheManager {
|
|||||||
|
|
||||||
enum class ScanMode : uint8_t { None, Scanning };
|
enum class ScanMode : uint8_t { None, Scanning };
|
||||||
ScanMode scanMode_ = ScanMode::None;
|
ScanMode scanMode_ = ScanMode::None;
|
||||||
std::string scanText_;
|
static constexpr size_t SCAN_TEXT_CAPACITY = 2048;
|
||||||
|
char scanText_[SCAN_TEXT_CAPACITY] = {};
|
||||||
|
size_t scanTextLen_ = 0;
|
||||||
uint32_t scanStyleCounts_[4] = {};
|
uint32_t scanStyleCounts_[4] = {};
|
||||||
int scanFontId_ = -1;
|
int scanFontId_ = -1;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ void GfxRenderer::begin() {
|
|||||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::releaseFrameBufferForBuild() {
|
||||||
|
display.releaseFrameBuffers();
|
||||||
|
frameBuffer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GfxRenderer::restoreFrameBufferAfterBuild() {
|
||||||
|
if (!display.reallocFrameBuffers()) {
|
||||||
|
LOG_ERR("GFX", "Framebuffer realloc failed after build");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
frameBuffer = display.getFrameBuffer();
|
||||||
|
return frameBuffer != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
||||||
|
|
||||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||||
@@ -1054,8 +1068,24 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
|||||||
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int size) const {
|
||||||
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
|
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
|
||||||
|
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
|
||||||
|
// icon's position to 8px (one byte) along the rotated axis, which prevents it
|
||||||
|
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
|
||||||
|
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
|
||||||
|
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
|
||||||
|
// only called by the UI themes, which all render in forced Portrait.
|
||||||
|
const int rowBytes = (size + 7) / 8;
|
||||||
|
for (int row = 0; row < size; row++) {
|
||||||
|
for (int col = 0; col < size; col++) {
|
||||||
|
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
|
||||||
|
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
|
||||||
|
if (ink) {
|
||||||
|
drawPixel(x + (size - 1 - row), y + col, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class GfxRenderer {
|
|||||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
||||||
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
||||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||||
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
void drawIcon(const uint8_t bitmap[], int x, int y, int size) const;
|
||||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||||
float cropY = 0) const;
|
float cropY = 0) const;
|
||||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||||
@@ -250,6 +250,13 @@ class GfxRenderer {
|
|||||||
// Font helpers
|
// Font helpers
|
||||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||||
|
|
||||||
|
// Lend the framebuffer to memory-hungry phases such as section pagination.
|
||||||
|
// Nothing may draw/display while it is released. restore returns the buffer
|
||||||
|
// white, so callers must redraw the full screen afterward.
|
||||||
|
void releaseFrameBufferForBuild();
|
||||||
|
bool restoreFrameBufferAfterBuild();
|
||||||
|
bool hasFrameBuffer() const { return frameBuffer != nullptr; }
|
||||||
|
|
||||||
// Low level functions
|
// Low level functions
|
||||||
uint8_t* getFrameBuffer() const;
|
uint8_t* getFrameBuffer() const;
|
||||||
size_t getBufferSize() const;
|
size_t getBufferSize() const;
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ STR_CHAPTER_PREFIX: "Раздзел:"
|
|||||||
STR_PAGES_SEPARATOR: "стар. |"
|
STR_PAGES_SEPARATOR: "стар. |"
|
||||||
STR_BOOK_PREFIX: "Кніга:"
|
STR_BOOK_PREFIX: "Кніга:"
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
||||||
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
|
|
||||||
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
||||||
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
||||||
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
||||||
@@ -296,7 +295,8 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
|
|||||||
STR_LINK: "[спасылка]"
|
STR_LINK: "[спасылка]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||||
STR_SLEEP_NEVER: "Ніколі"
|
STR_SLEEP_NEVER: "Ніколі"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
|
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
||||||
|
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
||||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||||
|
|||||||
@@ -272,7 +272,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
|||||||
STR_PAGES_SEPARATOR: " pàgines | "
|
STR_PAGES_SEPARATOR: " pàgines | "
|
||||||
STR_BOOK_PREFIX: "Llibre: "
|
STR_BOOK_PREFIX: "Llibre: "
|
||||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
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_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||||
@@ -305,7 +304,8 @@ STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
|||||||
STR_LINK: "[enllaç]"
|
STR_LINK: "[enllaç]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||||
|
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||||
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
||||||
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
|
|||||||
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
||||||
STR_CAT_DISPLAY: "Displej"
|
STR_CAT_DISPLAY: "Displej"
|
||||||
STR_CAT_READER: "Čtečka"
|
STR_CAT_READER: "Čtečka"
|
||||||
STR_CAT_CONTROLS: "Ovládací prvky"
|
STR_CAT_CONTROLS: "Ovládání"
|
||||||
STR_CAT_SYSTEM: "Systém"
|
STR_CAT_SYSTEM: "Systém"
|
||||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||||
@@ -243,7 +243,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
|||||||
STR_PAGES_SEPARATOR: "stránek |"
|
STR_PAGES_SEPARATOR: "stránek |"
|
||||||
STR_BOOK_PREFIX: "Kniha:"
|
STR_BOOK_PREFIX: "Kniha:"
|
||||||
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
|
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_SYNCING_TIME: "Čas synchronizace..."
|
||||||
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
||||||
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
||||||
@@ -273,5 +272,6 @@ STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
|||||||
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
STR_STEP_HINT_FRONT: "Přední tlačítka:"
|
||||||
|
STR_STEP_HINT_SIDE: "Boční tlačítka:"
|
||||||
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Kapitel: "
|
|||||||
STR_PAGES_SEPARATOR: " sider | "
|
STR_PAGES_SEPARATOR: " sider | "
|
||||||
STR_BOOK_PREFIX: "Bog: "
|
STR_BOOK_PREFIX: "Bog: "
|
||||||
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
|
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_SYNCING_TIME: "Synkroniserer tid..."
|
||||||
STR_CALC_HASH: "Beregner dokument-hash..."
|
STR_CALC_HASH: "Beregner dokument-hash..."
|
||||||
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
||||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Aldrig"
|
STR_SLEEP_NEVER: "Aldrig"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
|
STR_STEP_HINT_FRONT: "Frontknapper:"
|
||||||
|
STR_STEP_HINT_SIDE: "Sideknapper:"
|
||||||
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Hoofdstuk: "
|
|||||||
STR_PAGES_SEPARATOR: " pagina's | "
|
STR_PAGES_SEPARATOR: " pagina's | "
|
||||||
STR_BOOK_PREFIX: "Boek: "
|
STR_BOOK_PREFIX: "Boek: "
|
||||||
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
|
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_SYNCING_TIME: "Tijd synchroniseren..."
|
||||||
STR_CALC_HASH: "Document-hash berekenen..."
|
STR_CALC_HASH: "Document-hash berekenen..."
|
||||||
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
||||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nooit"
|
STR_SLEEP_NEVER: "Nooit"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
|
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
||||||
|
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ STR_NO_CHAPTERS: "No chapters"
|
|||||||
STR_END_OF_BOOK: "End of book"
|
STR_END_OF_BOOK: "End of book"
|
||||||
STR_EMPTY_CHAPTER: "Empty chapter"
|
STR_EMPTY_CHAPTER: "Empty chapter"
|
||||||
STR_INDEXING: "Indexing"
|
STR_INDEXING: "Indexing"
|
||||||
|
STR_INDEX_FAILED: "Failed to index - invalid book"
|
||||||
STR_MEMORY_ERROR: "Memory error"
|
STR_MEMORY_ERROR: "Memory error"
|
||||||
STR_PAGE_LOAD_ERROR: "Page load error"
|
STR_PAGE_LOAD_ERROR: "Page load error"
|
||||||
STR_EMPTY_FILE: "Empty file"
|
STR_EMPTY_FILE: "Empty file"
|
||||||
@@ -70,6 +71,8 @@ STR_IMAGES: "Images"
|
|||||||
STR_IMAGES_DISPLAY: "Display"
|
STR_IMAGES_DISPLAY: "Display"
|
||||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||||
STR_IMAGES_SUPPRESS: "Suppress"
|
STR_IMAGES_SUPPRESS: "Suppress"
|
||||||
|
STR_EOB_HOME: "Home"
|
||||||
|
STR_EOB_CONTINUE_WITH: "Continue with"
|
||||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||||
STR_ORIENTATION: "Reading Orientation"
|
STR_ORIENTATION: "Reading Orientation"
|
||||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||||
@@ -286,6 +289,25 @@ STR_HW_BACK_LABEL: "Back (1st button)"
|
|||||||
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
|
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
|
||||||
STR_HW_LEFT_LABEL: "Left (3rd button)"
|
STR_HW_LEFT_LABEL: "Left (3rd button)"
|
||||||
STR_HW_RIGHT_LABEL: "Right (4th button)"
|
STR_HW_RIGHT_LABEL: "Right (4th button)"
|
||||||
|
STR_BLUETOOTH: "Bluetooth"
|
||||||
|
STR_TOGGLE_BLUETOOTH: "Toggle Bluetooth"
|
||||||
|
STR_BT_SCAN_PAIR: "Scan & Pair"
|
||||||
|
STR_BT_NO_DEVICES: "No devices found"
|
||||||
|
STR_BT_FREE_HINT1: "Set Free2/3 to Reader Mode and"
|
||||||
|
STR_BT_FREE_HINT2: "Volume Function to pair"
|
||||||
|
STR_BT_DISCONNECT: "Disconnect"
|
||||||
|
STR_BT_CONNECTED_TO: "Connected: %s"
|
||||||
|
STR_BT_NOT_CONNECTED: "Not connected"
|
||||||
|
STR_BT_PAIRED_DEVICES: "Paired Devices"
|
||||||
|
STR_BT_NO_PAIRED: "No paired devices"
|
||||||
|
STR_BT_MAP_BUTTONS: "Map Remote Buttons"
|
||||||
|
STR_BT_PRESS_REMOTE: "Press a button on your remote"
|
||||||
|
STR_BT_CONNECTING_POPUP: "BT Connecting..."
|
||||||
|
STR_BT_PAUSED_LOW_MEM_POPUP: "BT paused (low memory)"
|
||||||
|
STR_STATE_PAUSED: "PAUSED"
|
||||||
|
STR_BT_PAGE_FORWARD: "Page Forward"
|
||||||
|
STR_BT_PAGE_BACK: "Page Back"
|
||||||
|
STR_BT_FORGET_PROMPT: "Hold Confirm to forget"
|
||||||
STR_GO_TO_PERCENT: "Go to %"
|
STR_GO_TO_PERCENT: "Go to %"
|
||||||
STR_GO_HOME_BUTTON: "Go Home"
|
STR_GO_HOME_BUTTON: "Go Home"
|
||||||
STR_SYNC_PROGRESS: "Sync Progress"
|
STR_SYNC_PROGRESS: "Sync Progress"
|
||||||
@@ -297,7 +319,6 @@ STR_CHAPTER_PREFIX: "Chapter: "
|
|||||||
STR_PAGES_SEPARATOR: " pages | "
|
STR_PAGES_SEPARATOR: " pages | "
|
||||||
STR_BOOK_PREFIX: "Book: "
|
STR_BOOK_PREFIX: "Book: "
|
||||||
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
|
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_SYNCING_TIME: "Syncing time..."
|
||||||
STR_CALC_HASH: "Calculating document hash..."
|
STR_CALC_HASH: "Calculating document hash..."
|
||||||
STR_HASH_FAILED: "Failed to calculate document hash"
|
STR_HASH_FAILED: "Failed to calculate document hash"
|
||||||
@@ -332,7 +353,8 @@ STR_LINK: "[link]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Never"
|
STR_SLEEP_NEVER: "Never"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
|
STR_STEP_HINT_FRONT: "Front buttons:"
|
||||||
|
STR_STEP_HINT_SIDE: "Side buttons:"
|
||||||
STR_ADD_SERVER: "Add Server"
|
STR_ADD_SERVER: "Add Server"
|
||||||
STR_SERVER_NAME: "Server Name"
|
STR_SERVER_NAME: "Server Name"
|
||||||
STR_NO_SERVERS: "No OPDS servers configured"
|
STR_NO_SERVERS: "No OPDS servers configured"
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ STR_CHAPTER_PREFIX: "Luku: "
|
|||||||
STR_PAGES_SEPARATOR: " sivua | "
|
STR_PAGES_SEPARATOR: " sivua | "
|
||||||
STR_BOOK_PREFIX: "Kirja: "
|
STR_BOOK_PREFIX: "Kirja: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
|
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_SYNCING_TIME: "Synkronoidaan aikaa..."
|
||||||
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
||||||
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
||||||
@@ -271,5 +270,6 @@ STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
|||||||
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Ei koskaan"
|
STR_SLEEP_NEVER: "Ei koskaan"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
STR_STEP_HINT_FRONT: "Etupainikkeet:"
|
||||||
|
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
|
||||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ STR_CHAPTER_PREFIX: "Chapitre : "
|
|||||||
STR_PAGES_SEPARATOR: " pages | "
|
STR_PAGES_SEPARATOR: " pages | "
|
||||||
STR_BOOK_PREFIX: "Livre : "
|
STR_BOOK_PREFIX: "Livre : "
|
||||||
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
||||||
STR_PERCENT_STEP_HINT: "Gauche/Droite : 1% Haut/Bas : 10%"
|
|
||||||
STR_SYNCING_TIME: "Synchro de l’heure…"
|
STR_SYNCING_TIME: "Synchro de l’heure…"
|
||||||
STR_CALC_HASH: "Calcul hash doc…"
|
STR_CALC_HASH: "Calcul hash doc…"
|
||||||
STR_HASH_FAILED: "Échec calcul hash"
|
STR_HASH_FAILED: "Échec calcul hash"
|
||||||
@@ -299,7 +298,8 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
|
|||||||
STR_LINK: "[lien]"
|
STR_LINK: "[lien]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Jamais"
|
STR_SLEEP_NEVER: "Jamais"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
|
STR_STEP_HINT_FRONT: "Boutons avant :"
|
||||||
|
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
||||||
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
||||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
|||||||
STR_PAGES_SEPARATOR: " Seiten | "
|
STR_PAGES_SEPARATOR: " Seiten | "
|
||||||
STR_BOOK_PREFIX: "Buch: "
|
STR_BOOK_PREFIX: "Buch: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
|
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_SYNCING_TIME: "Zeit synchronisieren…"
|
||||||
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
||||||
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
||||||
@@ -322,7 +321,8 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
|
|||||||
STR_LINK: "[Link]"
|
STR_LINK: "[Link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
||||||
STR_SLEEP_NEVER: "Nie"
|
STR_SLEEP_NEVER: "Nie"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
|
STR_STEP_HINT_FRONT: "Vordere Tasten:"
|
||||||
|
STR_STEP_HINT_SIDE: "Seitentasten:"
|
||||||
STR_ADD_SERVER: "Server hinzufügen"
|
STR_ADD_SERVER: "Server hinzufügen"
|
||||||
STR_SERVER_NAME: "Servername"
|
STR_SERVER_NAME: "Servername"
|
||||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||||
|
|||||||
@@ -268,7 +268,6 @@ STR_CHAPTER_PREFIX: "פרק: "
|
|||||||
STR_PAGES_SEPARATOR: " דפים | "
|
STR_PAGES_SEPARATOR: " דפים | "
|
||||||
STR_BOOK_PREFIX: "ספר: "
|
STR_BOOK_PREFIX: "ספר: "
|
||||||
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
||||||
STR_PERCENT_STEP_HINT: "שמאל/ימין: 1% למעלה/למטה: 10%"
|
|
||||||
STR_SYNCING_TIME: "מסנכרן שעון..."
|
STR_SYNCING_TIME: "מסנכרן שעון..."
|
||||||
STR_CALC_HASH: "מחשב האש..."
|
STR_CALC_HASH: "מחשב האש..."
|
||||||
STR_HASH_FAILED: "חישוב האש נכשל"
|
STR_HASH_FAILED: "חישוב האש נכשל"
|
||||||
@@ -390,4 +389,5 @@ STR_BOOKMARK_OPTION: "סימנייה"
|
|||||||
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
||||||
STR_SLEEP_NEVER: "אף פעם"
|
STR_SLEEP_NEVER: "אף פעם"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
|
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
|
||||||
|
STR_STEP_HINT_SIDE: "לחצני צד:"
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Fejezet: "
|
|||||||
STR_PAGES_SEPARATOR: " oldal | "
|
STR_PAGES_SEPARATOR: " oldal | "
|
||||||
STR_BOOK_PREFIX: "Könyv: "
|
STR_BOOK_PREFIX: "Könyv: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
|
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_SYNCING_TIME: "Idő szinkronizálása..."
|
||||||
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
||||||
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
||||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
||||||
STR_SLEEP_NEVER: "Soha"
|
STR_SLEEP_NEVER: "Soha"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
|
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
||||||
|
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
||||||
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||||
|
|||||||
@@ -274,7 +274,6 @@ STR_CHAPTER_PREFIX: "Capitolo: "
|
|||||||
STR_PAGES_SEPARATOR: " pagine | "
|
STR_PAGES_SEPARATOR: " pagine | "
|
||||||
STR_BOOK_PREFIX: "Libro: "
|
STR_BOOK_PREFIX: "Libro: "
|
||||||
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
|
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_SYNCING_TIME: "Sincronizzazione orario..."
|
||||||
STR_CALC_HASH: "Calcolo hash documento..."
|
STR_CALC_HASH: "Calcolo hash documento..."
|
||||||
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
||||||
@@ -307,7 +306,8 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
|
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
|
||||||
|
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot"
|
STR_SCREENSHOT_BUTTON: "Screenshot"
|
||||||
STR_ADD_SERVER: "Aggiungi server"
|
STR_ADD_SERVER: "Aggiungi server"
|
||||||
STR_SERVER_NAME: "Nome server"
|
STR_SERVER_NAME: "Nome server"
|
||||||
@@ -383,3 +383,6 @@ STR_DISABLED: "Disattivato"
|
|||||||
STR_BOOKMARK_OPTION: "Segnalibro"
|
STR_BOOKMARK_OPTION: "Segnalibro"
|
||||||
STR_KOSYNC: "KOSync"
|
STR_KOSYNC: "KOSync"
|
||||||
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
|
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
|
||||||
|
STR_EOB_CONTINUE_WITH: "Continua con"
|
||||||
|
STR_EOB_HOME: "Home"
|
||||||
|
STR_INDEX_FAILED: "Indicizzazione fallita - libro non valido"
|
||||||
@@ -237,7 +237,6 @@ STR_CHAPTER_PREFIX: "Тарау: "
|
|||||||
STR_PAGES_SEPARATOR: " бет | "
|
STR_PAGES_SEPARATOR: " бет | "
|
||||||
STR_BOOK_PREFIX: "Кітап: "
|
STR_BOOK_PREFIX: "Кітап: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
||||||
STR_PERCENT_STEP_HINT: "Сол/Оң: 1% Жоғары/Төмен: 10%"
|
|
||||||
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
||||||
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
||||||
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
||||||
@@ -294,7 +293,8 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
|
|||||||
STR_LINK: "[сілтеме]"
|
STR_LINK: "[сілтеме]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||||
STR_SLEEP_NEVER: "Ешқашан"
|
STR_SLEEP_NEVER: "Ешқашан"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
|
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
||||||
|
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
||||||
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
||||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Sk: "
|
|||||||
STR_PAGES_SEPARATOR: " psl. | "
|
STR_PAGES_SEPARATOR: " psl. | "
|
||||||
STR_BOOK_PREFIX: "Kn: "
|
STR_BOOK_PREFIX: "Kn: "
|
||||||
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
|
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_SYNCING_TIME: "Sinchr. laikas..."
|
||||||
STR_CALC_HASH: "Skaičiuojama maiša..."
|
STR_CALC_HASH: "Skaičiuojama maiša..."
|
||||||
STR_HASH_FAILED: "Maišos klaida"
|
STR_HASH_FAILED: "Maišos klaida"
|
||||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
|
|||||||
STR_LINK: "[nuoroda]"
|
STR_LINK: "[nuoroda]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||||
STR_SLEEP_NEVER: "Niekada"
|
STR_SLEEP_NEVER: "Niekada"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
|
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
||||||
|
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
||||||
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ STR_CHAPTER_PREFIX: "Rozdział: "
|
|||||||
STR_PAGES_SEPARATOR: " stron | "
|
STR_PAGES_SEPARATOR: " stron | "
|
||||||
STR_BOOK_PREFIX: "Książka: "
|
STR_BOOK_PREFIX: "Książka: "
|
||||||
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
|
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_SYNCING_TIME: "Synchronizacja czasu..."
|
||||||
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
||||||
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
||||||
@@ -308,7 +307,8 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nigdy"
|
STR_SLEEP_NEVER: "Nigdy"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
|
STR_STEP_HINT_FRONT: "Przyciski przednie:"
|
||||||
|
STR_STEP_HINT_SIDE: "Przyciski boczne:"
|
||||||
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
||||||
STR_ADD_SERVER: "Dodaj serwer"
|
STR_ADD_SERVER: "Dodaj serwer"
|
||||||
STR_SERVER_NAME: "Nazwa serwera"
|
STR_SERVER_NAME: "Nazwa serwera"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
|
|||||||
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
||||||
STR_INDEXING: "Indexando"
|
STR_INDEXING: "Indexando"
|
||||||
STR_MEMORY_ERROR: "Erro de memória"
|
STR_MEMORY_ERROR: "Erro de memória"
|
||||||
STR_PAGE_LOAD_ERROR: "Erro página"
|
STR_PAGE_LOAD_ERROR: "Erro ao carregar pág."
|
||||||
STR_EMPTY_FILE: "Arquivo vazio"
|
STR_EMPTY_FILE: "Arquivo vazio"
|
||||||
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
||||||
STR_LOADING: "Carregando..."
|
STR_LOADING: "Carregando..."
|
||||||
@@ -32,8 +32,8 @@ STR_CONNECTING: "Conectando..."
|
|||||||
STR_CONNECTED: "Conectado!"
|
STR_CONNECTED: "Conectado!"
|
||||||
STR_CONNECTION_FAILED: "Falha na conexão"
|
STR_CONNECTION_FAILED: "Falha na conexão"
|
||||||
STR_FORGET_NETWORK: "Esquecer rede?"
|
STR_FORGET_NETWORK: "Esquecer rede?"
|
||||||
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
|
STR_SAVE_PASSWORD: "Salvar senha na próxima vez?"
|
||||||
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
|
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
|
||||||
STR_JOIN_NETWORK: "Entrar em uma rede"
|
STR_JOIN_NETWORK: "Entrar em uma rede"
|
||||||
STR_CREATE_HOTSPOT: "Criar hotspot"
|
STR_CREATE_HOTSPOT: "Criar hotspot"
|
||||||
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
|
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_STARTING_HOTSPOT: "Iniciando hotspot..."
|
||||||
STR_HOTSPOT_MODE: "Modo hotspot"
|
STR_HOTSPOT_MODE: "Modo hotspot"
|
||||||
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
||||||
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
|
STR_OPEN_URL_HINT: "Abra esta URL no seu navegador"
|
||||||
STR_OR_HTTP_PREFIX: "ou http://"
|
STR_OR_HTTP_PREFIX: "ou http://"
|
||||||
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
||||||
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
||||||
@@ -50,30 +50,36 @@ STR_MAC_ADDRESS: "Endereço MAC:"
|
|||||||
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
||||||
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
||||||
STR_TO_PREFIX: "para"
|
STR_TO_PREFIX: "para"
|
||||||
STR_CALIBRE_RECEIVING: "Recebendo:"
|
STR_CALIBRE_RECEIVING: "Recebendo: "
|
||||||
STR_CALIBRE_RECEIVED: "Recebido:"
|
STR_CALIBRE_RECEIVED: "Recebido: "
|
||||||
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
||||||
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
||||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
|
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar ao dispositivo\""
|
||||||
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
||||||
STR_CAT_DISPLAY: "Tela"
|
STR_CAT_DISPLAY: "Tela"
|
||||||
STR_CAT_READER: "Leitor"
|
STR_CAT_READER: "Leitor"
|
||||||
STR_CAT_CONTROLS: "Controles"
|
STR_CAT_CONTROLS: "Controles"
|
||||||
STR_CAT_SYSTEM: "Sistema"
|
STR_CAT_SYSTEM: "Sistema"
|
||||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
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_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||||
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
||||||
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
||||||
STR_TEXT_AA: "Suavização de texto"
|
STR_TEXT_AA: "Suavização de texto"
|
||||||
|
STR_IMAGES: "Imagens"
|
||||||
|
STR_IMAGES_DISPLAY: "Exibição"
|
||||||
|
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
|
||||||
|
STR_IMAGES_SUPPRESS: "Ocultar"
|
||||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||||
STR_ORIENTATION: "Orientação de leitura"
|
STR_ORIENTATION: "Orientação de leitura"
|
||||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
||||||
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
|
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
|
||||||
STR_FONT_FAMILY: "Fonte do leitor"
|
STR_FONT_FAMILY: "Fonte do leitor"
|
||||||
STR_FONT_SIZE: "Tam. fonte UI"
|
STR_FONT_SIZE: "Tam. fonte UI"
|
||||||
STR_LINE_SPACING: "Espaçamento entre linhas"
|
STR_LINE_SPACING: "Espaçamento entre linhas"
|
||||||
@@ -81,6 +87,7 @@ STR_SCREEN_MARGIN: "Margens da tela"
|
|||||||
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
||||||
STR_HYPHENATION: "Hifenização"
|
STR_HYPHENATION: "Hifenização"
|
||||||
STR_TIME_TO_SLEEP: "Tempo para repousar"
|
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_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
|
||||||
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
||||||
STR_REFRESH_FREQ: "Frequência atualização"
|
STR_REFRESH_FREQ: "Frequência atualização"
|
||||||
@@ -128,6 +135,7 @@ STR_ALWAYS: "Sempre"
|
|||||||
STR_IGNORE: "Ignorar"
|
STR_IGNORE: "Ignorar"
|
||||||
STR_SLEEP: "Repouso"
|
STR_SLEEP: "Repouso"
|
||||||
STR_PAGE_TURN: "Virar página"
|
STR_PAGE_TURN: "Virar página"
|
||||||
|
STR_FORCE_REFRESH: "Atualizar tela"
|
||||||
STR_PORTRAIT: "Retrato"
|
STR_PORTRAIT: "Retrato"
|
||||||
STR_LANDSCAPE_CW: "Paisagem H"
|
STR_LANDSCAPE_CW: "Paisagem H"
|
||||||
STR_INVERTED: "Invertido"
|
STR_INVERTED: "Invertido"
|
||||||
@@ -135,6 +143,9 @@ STR_ORIENTATION_INVERTED: "Retrato 180°"
|
|||||||
STR_LANDSCAPE_CCW: "Paisagem AH"
|
STR_LANDSCAPE_CCW: "Paisagem AH"
|
||||||
STR_PREV_NEXT: "Ant/Próx"
|
STR_PREV_NEXT: "Ant/Próx"
|
||||||
STR_NEXT_PREV: "Próx/Ant"
|
STR_NEXT_PREV: "Próx/Ant"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Marcador"
|
||||||
|
STR_DISABLED: "Desabilitado"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
STR_SMALL: "Pequeno"
|
STR_SMALL: "Pequeno"
|
||||||
@@ -162,17 +173,21 @@ STR_UPDATING: "Atualizando..."
|
|||||||
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
||||||
STR_UPDATE_FAILED: "Falha na atualização"
|
STR_UPDATE_FAILED: "Falha na atualização"
|
||||||
STR_UPDATE_COMPLETE: "Atualização concluída"
|
STR_UPDATE_COMPLETE: "Atualização concluída"
|
||||||
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
|
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
|
||||||
STR_NO_ENTRIES: "Nenhum entries encontrado"
|
STR_RESTARTING_HINT: "Reiniciando... Se não reiniciar, pressione e segure o botão de energia por alguns segundos."
|
||||||
|
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
|
||||||
STR_DOWNLOADING: "Baixando..."
|
STR_DOWNLOADING: "Baixando..."
|
||||||
STR_DOWNLOAD_FAILED: "Falha no download"
|
STR_DOWNLOAD_FAILED: "Falha no download"
|
||||||
STR_ERROR_MSG: "Erro:"
|
STR_ERROR_MSG: "Erro:"
|
||||||
STR_UNNAMED: "Sem nome"
|
STR_UNNAMED: "Sem nome"
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir"
|
||||||
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
||||||
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
||||||
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
||||||
STR_NETWORK_PREFIX: "Rede:"
|
STR_NEXT_PAGE: "Próxima Página »"
|
||||||
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
|
STR_PREV_PAGE: "« Página Anterior"
|
||||||
|
STR_NETWORK_PREFIX: "Rede: "
|
||||||
|
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
|
||||||
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
||||||
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
||||||
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
||||||
@@ -181,10 +196,9 @@ STR_BACK: "« Voltar"
|
|||||||
STR_EXIT: "« Sair"
|
STR_EXIT: "« Sair"
|
||||||
STR_HOME: "« Início"
|
STR_HOME: "« Início"
|
||||||
STR_SELECT: "Escolher"
|
STR_SELECT: "Escolher"
|
||||||
|
STR_SELECTED: "Selecionado"
|
||||||
STR_TOGGLE: "Alternar"
|
STR_TOGGLE: "Alternar"
|
||||||
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||||
STR_BOOKMARK_REMOVED: "Marcador removido."
|
|
||||||
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
|
|
||||||
STR_CONFIRM: "Confirmar"
|
STR_CONFIRM: "Confirmar"
|
||||||
STR_CANCEL: "Cancelar"
|
STR_CANCEL: "Cancelar"
|
||||||
STR_CONNECT: "Conectar"
|
STR_CONNECT: "Conectar"
|
||||||
@@ -193,6 +207,8 @@ STR_DOWNLOAD: "Baixar"
|
|||||||
STR_RETRY: "Tentar novamente"
|
STR_RETRY: "Tentar novamente"
|
||||||
STR_YES: "Sim"
|
STR_YES: "Sim"
|
||||||
STR_NO: "Não"
|
STR_NO: "Não"
|
||||||
|
STR_SHOW: "Mostrar"
|
||||||
|
STR_HIDE: "Ocultar"
|
||||||
STR_STATE_ON: "LIG."
|
STR_STATE_ON: "LIG."
|
||||||
STR_STATE_OFF: "DESL."
|
STR_STATE_OFF: "DESL."
|
||||||
STR_NOT_SET: "Não definido"
|
STR_NOT_SET: "Não definido"
|
||||||
@@ -203,15 +219,51 @@ STR_DIR_DOWN: "Baixo"
|
|||||||
STR_OK_BUTTON: "OK"
|
STR_OK_BUTTON: "OK"
|
||||||
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
||||||
STR_FILTER_CONTRAST: "Contraste"
|
STR_FILTER_CONTRAST: "Contraste"
|
||||||
STR_UI_THEME: "Tema da interface"
|
STR_CUSTOMISE_STATUS_BAR: "Customizar Barra de Status"
|
||||||
|
STR_CHAPTER_PAGE_COUNT: "Páginas do Capítulo"
|
||||||
|
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de Progresso"
|
||||||
|
STR_PROGRESS_BAR: "Barra de Progresso"
|
||||||
|
STR_PROGRESS_BAR_THICKNESS: "Espessura da Barra de Progresso"
|
||||||
|
STR_PROGRESS_BAR_THIN: "Fino"
|
||||||
|
STR_PROGRESS_BAR_MEDIUM: "Médio"
|
||||||
|
STR_PROGRESS_BAR_THICK: "Grosso"
|
||||||
|
STR_BOOK: "Livro"
|
||||||
|
STR_CHAPTER: "Capítulo"
|
||||||
|
STR_EXAMPLE_CHAPTER: "Capítulo 21"
|
||||||
|
STR_EXAMPLE_BOOK: "Título do Livro"
|
||||||
|
STR_PREVIEW: "Prévia"
|
||||||
|
STR_TITLE: "Título"
|
||||||
|
STR_BATTERY: "Bateria"
|
||||||
|
STR_XTC_STATUS_BAR: "Barra de Status XTC"
|
||||||
|
STR_BOTTOM: "Baixo"
|
||||||
|
STR_TOP: "Topo"
|
||||||
|
STR_CLOCK: "Relógio"
|
||||||
|
STR_CLOCK_UTC_OFFSET: "Offset UTC do Relógio"
|
||||||
|
STR_CLOCK_FORMAT: "Formato do Relógio"
|
||||||
|
STR_CLOCK_FORMAT_24H: "24 horas"
|
||||||
|
STR_CLOCK_FORMAT_12H: "12 horas"
|
||||||
|
STR_CURRENT_TIME: "Hora atual:"
|
||||||
|
STR_NEXT_FIELD: "Próximo"
|
||||||
|
STR_CLOCK_SYNC: "Sincronizar Relógio"
|
||||||
|
STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora"
|
||||||
|
STR_CLOCK_SYNCING: "Sincronizando via NTP..."
|
||||||
|
STR_CLOCK_SYNC_OK: "Relógio sincronizado"
|
||||||
|
STR_CLOCK_SYNC_FAIL: "Falha na sincronização"
|
||||||
|
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi não conectado"
|
||||||
|
STR_CLOCK_SYNC_NO_WIFI_HINT: "Conecte ao Wi-Fi primeiro, depois tente novamente."
|
||||||
|
STR_CLOCK_SYNCED: "Relógio sincronizado"
|
||||||
|
STR_UI_THEME: "Tema"
|
||||||
STR_THEME_CLASSIC: "Clássico"
|
STR_THEME_CLASSIC: "Clássico"
|
||||||
STR_THEME_LYRA: "Lyra"
|
STR_THEME_LYRA: "Lyra"
|
||||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
|
||||||
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
|
STR_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_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_OPDS_BROWSER: "Navegador OPDS"
|
||||||
|
STR_SEARCH: "Busca"
|
||||||
STR_COVER_CUSTOM: "Capa + personalizado"
|
STR_COVER_CUSTOM: "Capa + personalizado"
|
||||||
STR_QUICK_RESUME: "Retomada rápida"
|
STR_QUICK_RESUME: "Retomada rápida"
|
||||||
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||||
@@ -225,11 +277,11 @@ STR_CALIBRE_SETUP: "Configuração"
|
|||||||
STR_CALIBRE_STATUS: "Status"
|
STR_CALIBRE_STATUS: "Status"
|
||||||
STR_CLEAR_BUTTON: "Limpar"
|
STR_CLEAR_BUTTON: "Limpar"
|
||||||
STR_DEFAULT_VALUE: "Padrão"
|
STR_DEFAULT_VALUE: "Padrão"
|
||||||
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
|
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
|
||||||
STR_UNASSIGNED: "Não atribuído"
|
STR_UNASSIGNED: "Não atribuído"
|
||||||
STR_ALREADY_ASSIGNED: "Já atribuído"
|
STR_ALREADY_ASSIGNED: "Já atribuído"
|
||||||
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
|
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir layout padrão"
|
||||||
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
|
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar remapeamento"
|
||||||
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
||||||
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
||||||
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
||||||
@@ -239,14 +291,15 @@ STR_GO_HOME_BUTTON: "Ir para o início"
|
|||||||
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
||||||
STR_DELETE_CACHE: "Excluir cache do livro"
|
STR_DELETE_CACHE: "Excluir cache do livro"
|
||||||
STR_DELETE: "Excluir"
|
STR_DELETE: "Excluir"
|
||||||
|
STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?"
|
||||||
|
STR_DISPLAY_QR: "Mostrar página como QR"
|
||||||
STR_CHAPTER_PREFIX: "Capítulo:"
|
STR_CHAPTER_PREFIX: "Capítulo:"
|
||||||
STR_PAGES_SEPARATOR: "páginas |"
|
STR_PAGES_SEPARATOR: "páginas |"
|
||||||
STR_BOOK_PREFIX: "Livro:"
|
STR_BOOK_PREFIX: "Livro:"
|
||||||
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
|
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_SYNCING_TIME: "Sincronizando horário..."
|
||||||
STR_CALC_HASH: "Calculando hash documento..."
|
STR_CALC_HASH: "Calculando hash do documento..."
|
||||||
STR_HASH_FAILED: "Falha ao calcular o hash documento"
|
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
|
||||||
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
||||||
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
||||||
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
||||||
@@ -264,14 +317,69 @@ STR_UPLOAD_PROMPT: "Enviar posição atual?"
|
|||||||
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
||||||
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
||||||
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
|
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_UPLOAD: "Enviar"
|
||||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||||
STR_FOCUS_READING: "Leitura focada"
|
STR_FOCUS_READING: "Leitura focada"
|
||||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
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_SCREENSHOT_BUTTON: "Capturar tela"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nunca"
|
STR_SLEEP_NEVER: "Nunca"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
STR_STEP_HINT_FRONT: "Botões frontais:"
|
||||||
|
STR_STEP_HINT_SIDE: "Botões laterais:"
|
||||||
|
STR_ADD_SERVER: "Adicionar Servidor"
|
||||||
|
STR_SERVER_NAME: "Nome do Servidor"
|
||||||
|
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||||
|
STR_DELETE_SERVER: "Excluir Servidor"
|
||||||
|
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||||
|
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
||||||
|
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
||||||
|
STR_MANAGE_FONTS: "Gerenciar fontes"
|
||||||
|
STR_FONT_BROWSER: "Navegador de fontes"
|
||||||
|
STR_LOADING_FONT_LIST: "Carregando lista de fontes..."
|
||||||
|
STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível"
|
||||||
|
STR_FONT_INSTALLED: "Fonte instalada!"
|
||||||
|
STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte"
|
||||||
|
STR_INSTALLED: "Instalada"
|
||||||
|
STR_DOWNLOAD_ALL: "Baixar tudo"
|
||||||
|
STR_UPDATE_ALL: "Atualizar tudo"
|
||||||
|
STR_UPDATE_AVAILABLE: "Atualizar"
|
||||||
|
STR_CRASH_TITLE: "Falha do sistema"
|
||||||
|
STR_CRASH_DESCRIPTION: "Um relatório detalhado foi salvo em crash_report.txt. Por favor, inclua este arquivo no seu relatório de erro."
|
||||||
|
STR_CRASH_REASON: "Motivo da falha:"
|
||||||
|
STR_CRASH_NO_REASON: "(Nenhum motivo registrado)"
|
||||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||||
|
STR_KB_HINT_MOVE_CURSOR: "Pressione ESQ ou DIR para mover o cursor"
|
||||||
|
STR_KB_HINT_RETURN_CURSOR: "Pressione ESQ para retornar à posição do cursor"
|
||||||
|
STR_KB_HINT_HIDE_PASSWORD: "Segure DIR e pressione [***] para ocultar a senha"
|
||||||
|
STR_KB_HINT_SHOW_PASSWORD: "Segure DIR e pressione [abc] para exibir a senha"
|
||||||
|
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pressione [***] para ocultar a senha"
|
||||||
|
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pressione [abc] para exibir a senha"
|
||||||
|
STR_KB_HINT_EDIT_ENTRY: "Segure CIMA para editar a entrada"
|
||||||
|
STR_KB_TIPS: "Dicas:"
|
||||||
|
STR_KB_HINT_RETURN_KEYBOARD: "Pressione BAIXO para retornar ao teclado"
|
||||||
|
STR_KB_HINT_EXIT_URL_MODE: "Pressione ABC para sair do modo URL"
|
||||||
|
STR_KB_HINT_CLEAR_TEXT: "Segure DEL para limpar todo o texto"
|
||||||
|
STR_KB_HINT_SECONDARY_CHAR: "Segure SELECT para caractere secundário"
|
||||||
|
STR_KB_HINT_UPPER_SECONDARY: "Segure SELECT para MAIÚSCULO ou caractere secundário"
|
||||||
|
STR_KB_HINT_LOWER_SECONDARY: "Segure SELECT para minúsculo ou caractere secundário"
|
||||||
|
STR_KB_HINT_URL_SNIPPETS: "Pressione URL para atalhos"
|
||||||
|
STR_SD_FIRMWARE_UPDATE: "Atualização de firmware via cartão SD"
|
||||||
|
STR_SELECT_FIRMWARE_FILE: "Selecione o arquivo de firmware (.bin)"
|
||||||
|
STR_NO_BIN_FILES: "Nenhum arquivo .bin encontrado"
|
||||||
|
STR_VALIDATING_FIRMWARE: "Validando firmware..."
|
||||||
|
STR_INVALID_FIRMWARE: "Arquivo de firmware inválido"
|
||||||
|
STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição"
|
||||||
|
STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais"
|
||||||
|
STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?"
|
||||||
|
STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo"
|
||||||
|
STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
|
||||||
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
|
||||||
|
STR_RECOVERY_MODE: "Modo de Recuperação"
|
||||||
|
STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o"
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ STR_CHAPTER_PREFIX: "Capitol: "
|
|||||||
STR_PAGES_SEPARATOR: " pagini | "
|
STR_PAGES_SEPARATOR: " pagini | "
|
||||||
STR_BOOK_PREFIX: "Carte: "
|
STR_BOOK_PREFIX: "Carte: "
|
||||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
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_SYNCING_TIME: "Timp de sincronizare..."
|
||||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||||
@@ -298,7 +297,8 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Niciodată"
|
STR_SLEEP_NEVER: "Niciodată"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
|
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
||||||
|
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
||||||
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
||||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||||
|
|||||||
@@ -298,7 +298,6 @@ STR_CHAPTER_PREFIX: "Глава: "
|
|||||||
STR_PAGES_SEPARATOR: " стр. | "
|
STR_PAGES_SEPARATOR: " стр. | "
|
||||||
STR_BOOK_PREFIX: "Книга: "
|
STR_BOOK_PREFIX: "Книга: "
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
||||||
STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%"
|
|
||||||
STR_SYNCING_TIME: "Синхронизация времени..."
|
STR_SYNCING_TIME: "Синхронизация времени..."
|
||||||
STR_CALC_HASH: "Расчёт хэша документа..."
|
STR_CALC_HASH: "Расчёт хэша документа..."
|
||||||
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
||||||
@@ -332,7 +331,8 @@ STR_LINK: "[ссылка]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||||
STR_SLEEP_NEVER: "Никогда"
|
STR_SLEEP_NEVER: "Никогда"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
|
STR_STEP_HINT_FRONT: "Передние кнопки:"
|
||||||
|
STR_STEP_HINT_SIDE: "Боковые кнопки:"
|
||||||
STR_ADD_SERVER: "Добавить сервер"
|
STR_ADD_SERVER: "Добавить сервер"
|
||||||
STR_SERVER_NAME: "Имя сервера"
|
STR_SERVER_NAME: "Имя сервера"
|
||||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
|||||||
STR_PAGES_SEPARATOR: "strán |"
|
STR_PAGES_SEPARATOR: "strán |"
|
||||||
STR_BOOK_PREFIX: "Kniha:"
|
STR_BOOK_PREFIX: "Kniha:"
|
||||||
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
|
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_SYNCING_TIME: "Čas synchronizácie..."
|
||||||
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||||
@@ -328,7 +327,8 @@ STR_LINK: "[link]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
|
||||||
|
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
|
||||||
STR_ADD_SERVER: "Pridať server"
|
STR_ADD_SERVER: "Pridať server"
|
||||||
STR_SERVER_NAME: "Názov servera"
|
STR_SERVER_NAME: "Názov servera"
|
||||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ STR_CHAPTER_PREFIX: "Poglavje: "
|
|||||||
STR_PAGES_SEPARATOR: " strani | "
|
STR_PAGES_SEPARATOR: " strani | "
|
||||||
STR_BOOK_PREFIX: "Knjiga: "
|
STR_BOOK_PREFIX: "Knjiga: "
|
||||||
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
|
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_SYNCING_TIME: "Sinhronizacija časa..."
|
||||||
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
||||||
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
||||||
@@ -295,7 +294,8 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
|
|||||||
STR_LINK: "[povezava]"
|
STR_LINK: "[povezava]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikoli"
|
STR_SLEEP_NEVER: "Nikoli"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
|
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
||||||
|
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
||||||
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
||||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ STR_CHAPTER_PREFIX: "Cap.: "
|
|||||||
STR_PAGES_SEPARATOR: " pág. | "
|
STR_PAGES_SEPARATOR: " pág. | "
|
||||||
STR_BOOK_PREFIX: "Libro: "
|
STR_BOOK_PREFIX: "Libro: "
|
||||||
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
|
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_SYNCING_TIME: "Tiempo de sincronización..."
|
||||||
STR_CALC_HASH: "Calculando hash del documento..."
|
STR_CALC_HASH: "Calculando hash del documento..."
|
||||||
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
||||||
@@ -331,7 +330,8 @@ STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
|||||||
STR_LINK: "[enlace]"
|
STR_LINK: "[enlace]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||||
STR_SLEEP_NEVER: "Nunca"
|
STR_SLEEP_NEVER: "Nunca"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
|
STR_STEP_HINT_FRONT: "Botones frontales:"
|
||||||
|
STR_STEP_HINT_SIDE: "Botones laterales:"
|
||||||
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||||
STR_ADD_SERVER: "Agregar servidor"
|
STR_ADD_SERVER: "Agregar servidor"
|
||||||
STR_SERVER_NAME: "Nombre de servidor"
|
STR_SERVER_NAME: "Nombre de servidor"
|
||||||
|
|||||||
@@ -295,7 +295,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
|||||||
STR_PAGES_SEPARATOR: " sidor | "
|
STR_PAGES_SEPARATOR: " sidor | "
|
||||||
STR_BOOK_PREFIX: "Bok:"
|
STR_BOOK_PREFIX: "Bok:"
|
||||||
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
|
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_SYNCING_TIME: "Synkroniserar tid…"
|
||||||
STR_CALC_HASH: "Beräknar dokumenthash"
|
STR_CALC_HASH: "Beräknar dokumenthash"
|
||||||
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
||||||
@@ -328,7 +327,8 @@ STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
|
|||||||
STR_LINK: "[länk]"
|
STR_LINK: "[länk]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Aldrig"
|
STR_SLEEP_NEVER: "Aldrig"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
|
STR_STEP_HINT_FRONT: "Framknappar:"
|
||||||
|
STR_STEP_HINT_SIDE: "Sidoknappar:"
|
||||||
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||||
STR_ADD_SERVER: "Lägg till server"
|
STR_ADD_SERVER: "Lägg till server"
|
||||||
STR_SERVER_NAME: "Servernamn"
|
STR_SERVER_NAME: "Servernamn"
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ STR_CHAPTER_PREFIX: "Bölüm: "
|
|||||||
STR_PAGES_SEPARATOR: " sayfa | "
|
STR_PAGES_SEPARATOR: " sayfa | "
|
||||||
STR_BOOK_PREFIX: "Kitap: "
|
STR_BOOK_PREFIX: "Kitap: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
|
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_SYNCING_TIME: "Zaman senkronize ediliyor..."
|
||||||
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
||||||
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
||||||
@@ -289,7 +288,8 @@ STR_IMAGES_SUPPRESS: "Bastır"
|
|||||||
STR_LINK: "[bağlantı]"
|
STR_LINK: "[bağlantı]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
||||||
STR_SLEEP_NEVER: "Asla"
|
STR_SLEEP_NEVER: "Asla"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
|
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
||||||
|
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
||||||
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
||||||
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||||
STR_PREVIEW: "Önizleme"
|
STR_PREVIEW: "Önizleme"
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Розділ: "
|
|||||||
STR_PAGES_SEPARATOR: " сторінок | "
|
STR_PAGES_SEPARATOR: " сторінок | "
|
||||||
STR_BOOK_PREFIX: "Книга: "
|
STR_BOOK_PREFIX: "Книга: "
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
||||||
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
|
|
||||||
STR_SYNCING_TIME: "Синхронізація часу..."
|
STR_SYNCING_TIME: "Синхронізація часу..."
|
||||||
STR_CALC_HASH: "Обчислення хешу документа..."
|
STR_CALC_HASH: "Обчислення хешу документа..."
|
||||||
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
||||||
@@ -329,7 +328,8 @@ STR_LINK: "[посилання]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||||
STR_SLEEP_NEVER: "Ніколи"
|
STR_SLEEP_NEVER: "Ніколи"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
STR_STEP_HINT_FRONT: "Передні кнопки:"
|
||||||
|
STR_STEP_HINT_SIDE: "Бічні кнопки:"
|
||||||
STR_ADD_SERVER: "Додати сервер"
|
STR_ADD_SERVER: "Додати сервер"
|
||||||
STR_SERVER_NAME: "Назва сервера"
|
STR_SERVER_NAME: "Назва сервера"
|
||||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ STR_HYPHENATION: "Partició de mots"
|
|||||||
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||||
|
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||||
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
||||||
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
|
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"
|
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
|
||||||
@@ -276,7 +277,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
|||||||
STR_PAGES_SEPARATOR: " pàgines | "
|
STR_PAGES_SEPARATOR: " pàgines | "
|
||||||
STR_BOOK_PREFIX: "Llibre: "
|
STR_BOOK_PREFIX: "Llibre: "
|
||||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
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_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ STR_CHAPTER_PREFIX: "Chương: "
|
|||||||
STR_PAGES_SEPARATOR: " trang | "
|
STR_PAGES_SEPARATOR: " trang | "
|
||||||
STR_BOOK_PREFIX: "Sách: "
|
STR_BOOK_PREFIX: "Sách: "
|
||||||
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
|
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_SYNCING_TIME: "Đang đồng bộ giờ..."
|
||||||
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
|
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"
|
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
|
||||||
@@ -328,7 +327,8 @@ STR_LINK: "[liên kết]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
||||||
STR_SLEEP_NEVER: "Không bao giờ"
|
STR_SLEEP_NEVER: "Không bao giờ"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
|
STR_STEP_HINT_FRONT: "Nút trước:"
|
||||||
|
STR_STEP_HINT_SIDE: "Nút bên:"
|
||||||
STR_ADD_SERVER: "Thêm máy chủ"
|
STR_ADD_SERVER: "Thêm máy chủ"
|
||||||
STR_SERVER_NAME: "Tên máy chủ"
|
STR_SERVER_NAME: "Tên máy chủ"
|
||||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||||
|
|||||||
@@ -278,13 +278,18 @@ class XPathParagraphResolver final : public Print {
|
|||||||
path.push_back({name, siblingIndex});
|
path.push_back({name, siblingIndex});
|
||||||
parentStates.emplace_back();
|
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") {
|
if (name == "p") {
|
||||||
paragraphCount++;
|
paragraphCount++;
|
||||||
if (paragraphCount == targetParagraph) {
|
} else if (name == "li") {
|
||||||
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
paragraphCount++;
|
||||||
stopped = true;
|
}
|
||||||
XML_StopParser(parser, XML_FALSE);
|
if (paragraphCount == targetParagraph) {
|
||||||
}
|
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
||||||
|
stopped = true;
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
depth++;
|
depth++;
|
||||||
|
|||||||
@@ -1,118 +1,43 @@
|
|||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <MD5Builder.h>
|
#include <MD5Builder.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
#include <Serialization.h>
|
|
||||||
|
|
||||||
#include "KOReaderJsonIO.h"
|
|
||||||
|
|
||||||
// Initialize the static instance
|
|
||||||
KOReaderCredentialStore KOReaderCredentialStore::instance;
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// File format version (for binary migration)
|
|
||||||
constexpr uint8_t KOREADER_FILE_VERSION = 1;
|
|
||||||
|
|
||||||
// File paths
|
|
||||||
constexpr char KOREADER_FILE_BIN[] = "/.crosspoint/koreader.bin";
|
|
||||||
constexpr char KOREADER_FILE_JSON[] = "/.crosspoint/koreader.json";
|
|
||||||
constexpr char KOREADER_FILE_BAK[] = "/.crosspoint/koreader.bin.bak";
|
|
||||||
|
|
||||||
// Default sync server URL
|
// Default sync server URL
|
||||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||||
|
|
||||||
// Legacy obfuscation key - "KOReader" in ASCII (only used for binary migration)
|
|
||||||
constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72};
|
|
||||||
constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY);
|
|
||||||
|
|
||||||
void legacyDeobfuscate(std::string& data) {
|
|
||||||
for (size_t i = 0; i < data.size(); i++) {
|
|
||||||
data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool KOReaderCredentialStore::saveToFile() const {
|
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
Storage.mkdir("/.crosspoint");
|
doc["username"] = getUsername();
|
||||||
return KOReaderJsonIO::save(*this, KOREADER_FILE_JSON);
|
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||||
|
doc["serverUrl"] = getServerUrl();
|
||||||
|
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool KOReaderCredentialStore::loadFromFile() {
|
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||||
// Try JSON first
|
std::string user = doc["username"] | "";
|
||||||
if (Storage.exists(KOREADER_FILE_JSON)) {
|
|
||||||
String json = Storage.readFile(KOREADER_FILE_JSON);
|
|
||||||
if (!json.isEmpty()) {
|
|
||||||
bool resave = false;
|
|
||||||
bool result = KOReaderJsonIO::load(*this, json.c_str(), &resave);
|
|
||||||
if (result && resave) {
|
|
||||||
saveToFile();
|
|
||||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to binary migration
|
bool needsResave = false;
|
||||||
if (Storage.exists(KOREADER_FILE_BIN)) {
|
std::string pass = extractPassword(doc, needsResave);
|
||||||
if (loadFromBinaryFile()) {
|
|
||||||
if (saveToFile()) {
|
|
||||||
Storage.rename(KOREADER_FILE_BIN, KOREADER_FILE_BAK);
|
|
||||||
LOG_DBG("KRS", "Migrated koreader.bin to koreader.json");
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
LOG_ERR("KRS", "Failed to save KOReader credentials during migration");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("KRS", "No credentials file found");
|
setCredentials(user, pass);
|
||||||
return false;
|
setServerUrl(doc["serverUrl"] | "");
|
||||||
}
|
|
||||||
|
|
||||||
bool KOReaderCredentialStore::loadFromBinaryFile() {
|
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||||
HalFile file;
|
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||||
if (!Storage.openFileForRead("KRS", KOREADER_FILE_BIN, file)) {
|
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t version;
|
|
||||||
serialization::readPod(file, version);
|
|
||||||
if (version != KOREADER_FILE_VERSION) {
|
|
||||||
LOG_DBG("KRS", "Unknown file version: %u", version);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
serialization::readString(file, username);
|
|
||||||
} else {
|
} else {
|
||||||
username.clear();
|
LOG_DBG("KRS", "Invalid matchMethod %u in JSON, resetting to FILENAME", method);
|
||||||
|
setMatchMethod(DocumentMatchMethod::FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.available()) {
|
if (needsResave) {
|
||||||
serialization::readString(file, password);
|
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||||
legacyDeobfuscate(password);
|
saveToFile();
|
||||||
} else {
|
|
||||||
password.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
serialization::readString(file, serverUrl);
|
|
||||||
} else {
|
|
||||||
serverUrl.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
uint8_t method;
|
|
||||||
serialization::readPod(file, method);
|
|
||||||
matchMethod = static_cast<DocumentMatchMethod>(method);
|
|
||||||
} else {
|
|
||||||
matchMethod = DocumentMatchMethod::FILENAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -14,9 +17,9 @@ enum class DocumentMatchMethod : uint8_t {
|
|||||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||||
* but prevents casual reading and ties credentials to the specific device).
|
* but prevents casual reading and ties credentials to the specific device).
|
||||||
*/
|
*/
|
||||||
class KOReaderCredentialStore {
|
|
||||||
|
class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore> {
|
||||||
private:
|
private:
|
||||||
static KOReaderCredentialStore instance;
|
|
||||||
std::string username;
|
std::string username;
|
||||||
std::string password;
|
std::string password;
|
||||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||||
@@ -24,20 +27,14 @@ class KOReaderCredentialStore {
|
|||||||
|
|
||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
KOReaderCredentialStore() = default;
|
KOReaderCredentialStore() = default;
|
||||||
|
~KOReaderCredentialStore() = default;
|
||||||
|
|
||||||
bool loadFromBinaryFile();
|
friend class PersistableStore<KOReaderCredentialStore>;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Delete copy constructor and assignment
|
static const char* getFilePath() { return "/.crosspoint/koreader.json"; }
|
||||||
KOReaderCredentialStore(const KOReaderCredentialStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete;
|
bool fromJson(JsonVariantConst doc);
|
||||||
|
|
||||||
// Get singleton instance
|
|
||||||
static KOReaderCredentialStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
// Save/load from SD card
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
// Credential management
|
// Credential management
|
||||||
void setCredentials(const std::string& user, const std::string& pass);
|
void setCredentials(const std::string& user, const std::string& pass);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
#include "KOReaderJsonIO.h"
|
|
||||||
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <ObfuscationUtils.h>
|
|
||||||
|
|
||||||
#include "KOReaderCredentialStore.h"
|
|
||||||
|
|
||||||
namespace KOReaderJsonIO {
|
|
||||||
|
|
||||||
bool save(const KOReaderCredentialStore& store, const char* path) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["username"] = store.getUsername();
|
|
||||||
doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword());
|
|
||||||
doc["serverUrl"] = store.getServerUrl();
|
|
||||||
doc["matchMethod"] = static_cast<uint8_t>(store.getMatchMethod());
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
return Storage.writeFile(path, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave) {
|
|
||||||
if (needsResave) *needsResave = false;
|
|
||||||
JsonDocument doc;
|
|
||||||
auto error = deserializeJson(doc, json);
|
|
||||||
if (error) {
|
|
||||||
LOG_ERR("KRS", "JSON parse error: %s", error.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string user = doc["username"] | std::string("");
|
|
||||||
|
|
||||||
bool ok = false;
|
|
||||||
std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
|
||||||
if (!ok || pass.empty()) {
|
|
||||||
pass = doc["password"] | std::string("");
|
|
||||||
if (!pass.empty() && needsResave) *needsResave = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.setCredentials(user, pass);
|
|
||||||
store.setServerUrl(doc["serverUrl"] | std::string(""));
|
|
||||||
|
|
||||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
|
||||||
store.setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace KOReaderJsonIO
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
class KOReaderCredentialStore;
|
|
||||||
|
|
||||||
namespace KOReaderJsonIO {
|
|
||||||
bool save(const KOReaderCredentialStore& store, const char* path);
|
|
||||||
bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave);
|
|
||||||
} // namespace KOReaderJsonIO
|
|
||||||
@@ -709,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
|||||||
float intra =
|
float intra =
|
||||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||||
// Progress-based XPath correctly handles both <p> and <li> positions.
|
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||||
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);
|
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()) {
|
if (result.xpath.empty()) {
|
||||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ OpdsParser::OpdsParser() {
|
|||||||
if (!parser) {
|
if (!parser) {
|
||||||
errorOccured = true;
|
errorOccured = true;
|
||||||
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
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); }
|
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
|
||||||
@@ -20,10 +24,6 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
|||||||
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||||
if (errorOccured) return 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);
|
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
||||||
size_t remaining = length;
|
size_t remaining = length;
|
||||||
constexpr size_t chunkSize = 1024;
|
constexpr size_t chunkSize = 1024;
|
||||||
@@ -54,6 +54,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void OpdsParser::flush() {
|
void OpdsParser::flush() {
|
||||||
|
if (errorOccured || !parser) return;
|
||||||
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
||||||
errorOccured = true;
|
errorOccured = true;
|
||||||
destroyXmlParser(parser);
|
destroyXmlParser(parser);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "PersistableStore.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
|
bool PersistableStoreBase::writeDocToFile(const char* path, const JsonDocument& doc) {
|
||||||
|
Storage.mkdir("/.crosspoint");
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
if (!Storage.writeFile(path, json)) {
|
||||||
|
LOG_ERR("PERSIST", "Failed to write %s", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PersistableStoreBase::readDocFromFile(const char* path, JsonDocument& doc) {
|
||||||
|
if (!Storage.exists(path)) {
|
||||||
|
return false; // Expected on first boot — not an error.
|
||||||
|
}
|
||||||
|
String json = Storage.readFile(path);
|
||||||
|
if (json.isEmpty()) {
|
||||||
|
LOG_ERR("PERSIST", "Failed to read %s (empty)", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto error = deserializeJson(doc, json);
|
||||||
|
if (error) {
|
||||||
|
LOG_ERR("PERSIST", "JSON parse error in %s: %s", path, error.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PersistableStoreBase::extractPassword(JsonVariantConst doc, bool& needsResave) {
|
||||||
|
bool ok = false;
|
||||||
|
std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
||||||
|
if (!ok) {
|
||||||
|
// Deobfuscation failed — fall back to legacy plaintext password.
|
||||||
|
pass = doc["password"] | "";
|
||||||
|
if (!pass.empty()) needsResave = true;
|
||||||
|
}
|
||||||
|
// A successfully decoded empty string is a legitimate value; preserve as-is.
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Non-template core of PersistableStore.
|
||||||
|
*
|
||||||
|
* All ArduinoJson parse/serialize machinery is instantiated once here (in
|
||||||
|
* PersistableStore.cpp) instead of in every store's translation unit. GCC
|
||||||
|
* emits the JSON serializer/parser templates as local .isra clones per TU
|
||||||
|
* (~0.5KB each), so keeping serializeJson/deserializeJson out of the stores
|
||||||
|
* is what makes the abstraction flash-neutral.
|
||||||
|
*/
|
||||||
|
class PersistableStoreBase {
|
||||||
|
protected:
|
||||||
|
PersistableStoreBase() = default;
|
||||||
|
~PersistableStoreBase() = default;
|
||||||
|
|
||||||
|
// Serializes doc and writes it to path (ensures /.crosspoint exists). Logs on failure.
|
||||||
|
static bool writeDocToFile(const char* path, const JsonDocument& doc);
|
||||||
|
|
||||||
|
// Reads path and parses it into doc. Returns false silently when the file
|
||||||
|
// does not exist (expected on first boot); logs on read/parse failure.
|
||||||
|
static bool readDocFromFile(const char* path, JsonDocument& doc);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function for extracting an obfuscated password from a JSON value.
|
||||||
|
* Accepts JsonVariantConst so callers can pass either a whole JsonDocument
|
||||||
|
* or a JsonObject element (e.g. inside an array iteration).
|
||||||
|
* If the decoded password requires a resave (e.g. from plaintext fallback), `needsResave` is set to true.
|
||||||
|
*/
|
||||||
|
static std::string extractPassword(JsonVariantConst doc, bool& needsResave);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Base class for persistable singletons using CRTP.
|
||||||
|
*
|
||||||
|
* Derived classes must provide:
|
||||||
|
* - A private default constructor
|
||||||
|
* - friend class PersistableStore<Derived>;
|
||||||
|
* - static const char* getFilePath();
|
||||||
|
* - void toJson(JsonDocument& doc) const;
|
||||||
|
* - bool fromJson(JsonVariantConst doc);
|
||||||
|
*
|
||||||
|
* Note for implementers: read string values as `const char*` (e.g.
|
||||||
|
* `obj["name"] | ""`), never as `| std::string("")` — ArduinoJson's
|
||||||
|
* std::string converter drags a per-TU copy of the whole JSON serializer
|
||||||
|
* into flash via its serializeJson fallback.
|
||||||
|
*/
|
||||||
|
template <typename T>
|
||||||
|
class PersistableStore : public PersistableStoreBase {
|
||||||
|
protected:
|
||||||
|
PersistableStore() = default;
|
||||||
|
~PersistableStore() = default;
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Delete copy constructor and assignment
|
||||||
|
PersistableStore(const PersistableStore&) = delete;
|
||||||
|
PersistableStore& operator=(const PersistableStore&) = delete;
|
||||||
|
|
||||||
|
static T& getInstance() {
|
||||||
|
static T instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveToFile() const {
|
||||||
|
JsonDocument doc;
|
||||||
|
static_cast<const T*>(this)->toJson(doc);
|
||||||
|
return writeDocToFile(T::getFilePath(), doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadFromFile() {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (!readDocFromFile(T::getFilePath(), doc)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return static_cast<T*>(this)->fromJson(doc.as<JsonVariantConst>());
|
||||||
|
}
|
||||||
|
};
|
||||||
+1
-1
@@ -42,7 +42,7 @@ std::string Txt::getTitle() const {
|
|||||||
|
|
||||||
// Remove .txt extension
|
// Remove .txt extension
|
||||||
if (FsHelpers::hasTxtExtension(filename)) {
|
if (FsHelpers::hasTxtExtension(filename)) {
|
||||||
filename = filename.substr(0, filename.length() - 4);
|
filename.resize(filename.length() - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
|||||||
|
|
||||||
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
|
void HalDisplay::releaseFrameBuffers() { einkDisplay.releaseBuffers(); }
|
||||||
|
|
||||||
|
bool HalDisplay::reallocFrameBuffers() { return einkDisplay.reallocBuffers(); }
|
||||||
|
|
||||||
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class HalDisplay {
|
|||||||
// Access to frame buffer
|
// Access to frame buffer
|
||||||
uint8_t* getFrameBuffer() const;
|
uint8_t* getFrameBuffer() const;
|
||||||
|
|
||||||
|
// Lend the framebuffer's RAM to a memory-hungry phase. No display calls may
|
||||||
|
// run between release and a successful realloc; buffers come back white, so
|
||||||
|
// callers must redraw the full screen.
|
||||||
|
void releaseFrameBuffers();
|
||||||
|
bool reallocFrameBuffers();
|
||||||
|
|
||||||
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
||||||
// to the gray region in physical panel coordinates (no-arg = full frame).
|
// to the gray region in physical panel coordinates (no-arg = full frame).
|
||||||
// Call after the BW base frame is displayed and before the grayscale planes
|
// Call after the BW base frame is displayed and before the grayscale planes
|
||||||
|
|||||||
-1
Submodule open-x4-sdk deleted from 198ad26721
+76
-5
@@ -13,7 +13,10 @@ framework = arduino
|
|||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
upload_speed = 921600
|
upload_speed = 921600
|
||||||
check_tool = cppcheck
|
check_tool = cppcheck
|
||||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||||
|
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||||
|
; project header as missing (~470 information-level lines) and fails the job.
|
||||||
|
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||||
check_skip_packages = yes
|
check_skip_packages = yes
|
||||||
|
|
||||||
board_upload.flash_size = 16MB
|
board_upload.flash_size = 16MB
|
||||||
@@ -35,9 +38,21 @@ build_flags =
|
|||||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||||
|
-DFREEINK_DEVICE_X4=1
|
||||||
|
-DFREEINK_DEVICE_X3=1
|
||||||
-Wno-bidi-chars
|
-Wno-bidi-chars
|
||||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||||
-fno-exceptions
|
-fno-exceptions
|
||||||
|
# FreeInk panel profiles: compile both X3 (792x528/UC8253) and X4 (800x480/SSD1677);
|
||||||
|
# the firmware picks the active one at runtime via HalDisplay setDisplayX3().
|
||||||
|
-DFREEINK_DEVICE_X3=1
|
||||||
|
-DFREEINK_DEVICE_X4=1
|
||||||
|
# BLE HID page-turner host (BleKeyboardHost). NimBLE role/bond config is baked into
|
||||||
|
# the prebuilt arduino-esp32 framework's sdkconfig.h, so we don't redefine it here
|
||||||
|
# (doing so only warns and has no effect). The host only compiles when
|
||||||
|
# FREEINK_CAP_BLE_HID_HOST is set on the env (below); with the capability off,
|
||||||
|
# BleKeyboardHost links stubs and pulls in zero NimBLE code.
|
||||||
|
-DFREEINK_BLE_HID_SHOW_UNNAMED_DEVICES=0
|
||||||
|
|
||||||
build_unflags =
|
build_unflags =
|
||||||
-std=gnu++11
|
-std=gnu++11
|
||||||
@@ -48,6 +63,48 @@ board_build.flash_mode = dio
|
|||||||
board_build.flash_size = 16MB
|
board_build.flash_size = 16MB
|
||||||
board_build.partitions = partitions.csv
|
board_build.partitions = partitions.csv
|
||||||
|
|
||||||
|
; Shrink the NimBLE footprint for a 1-connection HID host moving 3-6 byte reports.
|
||||||
|
; Field-measured: begin() costs ~52 KB with these trims vs ~68 KB with the prebuilt
|
||||||
|
; framework defaults — and that 15 KB is the difference between the stack landing
|
||||||
|
; above the reader's render shed floor (stable coexistence) and below it (a
|
||||||
|
; guaranteed shed/restart flap). Rebuilds the Arduino core libs on first build
|
||||||
|
; (slower once, cached after; needs the CMake pin in platformio.local.ini on macOS).
|
||||||
|
custom_sdkconfig =
|
||||||
|
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n
|
||||||
|
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n
|
||||||
|
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||||
|
CONFIG_BT_NIMBLE_MAX_CCCDS=2
|
||||||
|
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23
|
||||||
|
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=6
|
||||||
|
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6 ; was 24 x 320 B
|
||||||
|
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=6 ; was 24 x 255 B
|
||||||
|
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=12 ; was 30 x 70 B; only scan bursts need many
|
||||||
|
; IDF 5.5 sizes the HCI transport pools under TRANSPORT_* names; pin both spellings.
|
||||||
|
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||||
|
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12
|
||||||
|
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4 ; was 64; a HID host never does prepared writes
|
||||||
|
CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only
|
||||||
|
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only
|
||||||
|
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30
|
||||||
|
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||||
|
; the core source list; all other bundled libraries default to enabled.
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||||
|
|
||||||
|
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||||
|
; require embedded server certs the lib builder can't generate
|
||||||
|
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||||
|
custom_component_remove =
|
||||||
|
espressif/esp_insights
|
||||||
|
espressif/esp_rainmaker
|
||||||
|
espressif/esp_diagnostics
|
||||||
|
espressif/esp_diag_data_store
|
||||||
|
espressif/esp_schedule
|
||||||
|
espressif/esp_rcp_update
|
||||||
|
espressif/esp_secure_cert_mgr
|
||||||
|
espressif/cbor
|
||||||
|
|
||||||
extra_scripts =
|
extra_scripts =
|
||||||
pre:scripts/build_html.py
|
pre:scripts/build_html.py
|
||||||
pre:scripts/gen_i18n.py
|
pre:scripts/gen_i18n.py
|
||||||
@@ -57,10 +114,19 @@ extra_scripts =
|
|||||||
|
|
||||||
; Libraries
|
; Libraries
|
||||||
lib_deps =
|
lib_deps =
|
||||||
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
|
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
|
||||||
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
|
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||||
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
|
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
||||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||||
|
; FreeInk HAL support libs the above depend on (BoardConfig pin maps, etc.).
|
||||||
|
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||||
|
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||||
|
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||||
|
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||||
|
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||||
|
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||||
|
BleKeyboardHost=symlink://freeink-sdk/libs/network/BleKeyboardHost
|
||||||
|
h2zero/NimBLE-Arduino @ ^2.3.8
|
||||||
bblanchon/ArduinoJson @ 7.4.2
|
bblanchon/ArduinoJson @ 7.4.2
|
||||||
ricmoo/QRCode @ 0.0.1
|
ricmoo/QRCode @ 0.0.1
|
||||||
bitbank2/PNGdec @ 1.1.6
|
bitbank2/PNGdec @ 1.1.6
|
||||||
@@ -74,6 +140,9 @@ build_flags =
|
|||||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||||
|
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||||
|
-DFREEINK_BLE_HID_SCAN_DEBUG=1 ; verbose BLE scan lifecycle/advertisement logs for bring-up
|
||||||
|
-DFREEINK_BLE_HID_REPORT_DEBUG=1 ; raw HID report hex dumps + report-map hints (bring-up)
|
||||||
|
|
||||||
|
|
||||||
[env:gh_release]
|
[env:gh_release]
|
||||||
@@ -83,6 +152,7 @@ build_flags =
|
|||||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
||||||
|
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||||
|
|
||||||
[env:gh_release_rc]
|
[env:gh_release_rc]
|
||||||
extends = base
|
extends = base
|
||||||
@@ -91,6 +161,7 @@ build_flags =
|
|||||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||||
|
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||||
|
|
||||||
[env:slim]
|
[env:slim]
|
||||||
extends = base
|
extends = base
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a test EPUB for <br> section-break rendering.
|
||||||
|
|
||||||
|
Tests that a bare <br> element between paragraphs produces a visible blank-line
|
||||||
|
gap (section separator), while a <br> inside a paragraph only produces a line
|
||||||
|
break with no extra spacing.
|
||||||
|
|
||||||
|
Cases covered:
|
||||||
|
1. Standalone <br> between paragraphs (section break — must show gap).
|
||||||
|
2. <br class="..."> with a CSS class (calibre-style section break).
|
||||||
|
3. Multiple consecutive <br> elements (each adds one line of spacing).
|
||||||
|
4. Inline <br> inside a <p> (line break only — no extra gap).
|
||||||
|
5. <br> at start of chapter (no gap before first paragraph).
|
||||||
|
6. <br> following a heading.
|
||||||
|
|
||||||
|
Visual verification instructions are embedded as the first paragraph of each
|
||||||
|
chapter so a human tester can confirm the expected result on device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
|
||||||
|
OUTPUT_PATH = OUTPUT_DIR / "test_br_section_break.epub"
|
||||||
|
|
||||||
|
FILLER = (
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
|
||||||
|
"tempor incididunt ut labore et dolore magna aliqua."
|
||||||
|
)
|
||||||
|
|
||||||
|
CSS = """\
|
||||||
|
body { margin: 0; padding: 0; }
|
||||||
|
p { margin-top: 1pt; margin-bottom: 0; text-indent: 1em; text-align: justify; }
|
||||||
|
h1 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||||
|
h2 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||||
|
.section-br { display: block; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
def xhtml(title, body):
|
||||||
|
return f"""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="styles/test.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{body}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 1 — standalone <br> between paragraphs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch1 = xhtml("Ch1: Standalone br", f"""
|
||||||
|
<h1>Ch 1: Standalone <br> Section Break</h1>
|
||||||
|
<p>PASS: A visible blank-line gap should appear between the two sections below.</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<br/>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<p>PASS: The gap above should be roughly one line tall (same as a blank line).</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 2 — <br class="..."> CSS-classed section break (calibre style)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch2 = xhtml("Ch2: Classed br", f"""
|
||||||
|
<h1>Ch 2: <br class="section-br"/></h1>
|
||||||
|
<p>PASS: A blank-line gap should appear between the two sections below, identical
|
||||||
|
to Ch 1, even though the <br> carries a CSS class.</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<br class="section-br"/>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 3 — multiple consecutive <br> elements
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch3 = xhtml("Ch3: Multiple br", f"""
|
||||||
|
<h1>Ch 3: Multiple Consecutive <br> Elements</h1>
|
||||||
|
<p>PASS: Two blank lines should appear between the sections (one per <br>).</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<p>PASS: Three blank lines should appear below.</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 4 — inline <br> inside a paragraph (line break, NOT a gap)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch4 = xhtml("Ch4: Inline br", """
|
||||||
|
<h1>Ch 4: Inline <br> Inside a Paragraph</h1>
|
||||||
|
<p>PASS: The two lines below should be adjacent with NO extra gap between them.
|
||||||
|
The <br> is inside the paragraph and must only break the line.</p>
|
||||||
|
<p>First line of the paragraph.<br/>Second line of the paragraph — directly below, no gap.</p>
|
||||||
|
<p>PASS: Above should look like two closely-spaced lines, not like two paragraphs
|
||||||
|
separated by a blank line.</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 5 — <br> following a heading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch5 = xhtml("Ch5: br after heading", f"""
|
||||||
|
<h1>Ch 5: <br> After a Heading</h1>
|
||||||
|
<br/>
|
||||||
|
<p>PASS: There should be a blank-line gap between the heading above and this paragraph.</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
<h2>Section heading</h2>
|
||||||
|
<br/>
|
||||||
|
<p>PASS: There should be a blank-line gap between the section heading and this paragraph.</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chapter 6 — <br> at very start of chapter (no spurious leading gap)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ch6 = xhtml("Ch6: br at chapter start", f"""<br/>
|
||||||
|
<h1>Ch 6: <br> at Chapter Start</h1>
|
||||||
|
<p>PASS: This heading should appear near the top of the page with no large blank
|
||||||
|
area above it despite the <br> being the very first element.</p>
|
||||||
|
<p>{FILLER}</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
CHAPTERS = [
|
||||||
|
("ch1", "chapter1.xhtml", "Chapter 1: Standalone br", ch1),
|
||||||
|
("ch2", "chapter2.xhtml", "Chapter 2: Classed br", ch2),
|
||||||
|
("ch3", "chapter3.xhtml", "Chapter 3: Multiple br", ch3),
|
||||||
|
("ch4", "chapter4.xhtml", "Chapter 4: Inline br", ch4),
|
||||||
|
("ch5", "chapter5.xhtml", "Chapter 5: br after heading", ch5),
|
||||||
|
("ch6", "chapter6.xhtml", "Chapter 6: br at start", ch6),
|
||||||
|
]
|
||||||
|
|
||||||
|
def build_epub(path):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as epub:
|
||||||
|
# mimetype must be first and uncompressed
|
||||||
|
epub.writestr("mimetype", "application/epub+zip",
|
||||||
|
compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
epub.writestr("META-INF/container.xml", """\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/content.opf"
|
||||||
|
media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>""")
|
||||||
|
|
||||||
|
epub.writestr("OEBPS/styles/test.css", CSS)
|
||||||
|
|
||||||
|
manifest_items = []
|
||||||
|
spine_items = []
|
||||||
|
nav_items = []
|
||||||
|
|
||||||
|
for (chid, chfile, chtitle, chcontent) in CHAPTERS:
|
||||||
|
epub.writestr(f"OEBPS/{chfile}", chcontent)
|
||||||
|
manifest_items.append(
|
||||||
|
f' <item id="{chid}" href="{chfile}" media-type="application/xhtml+xml"/>')
|
||||||
|
spine_items.append(f' <itemref idref="{chid}"/>')
|
||||||
|
nav_items.append(f' <li><a href="{chfile}">{chtitle}</a></li>')
|
||||||
|
|
||||||
|
manifest_items.append(
|
||||||
|
' <item id="nav" href="nav.xhtml" '
|
||||||
|
'media-type="application/xhtml+xml" properties="nav"/>')
|
||||||
|
|
||||||
|
content_opf = f"""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="uid">test-epub-br-section-break</dc:identifier>
|
||||||
|
<dc:title>Test: br Section Break</dc:title>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
{chr(10).join(manifest_items)}
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
{chr(10).join(spine_items)}
|
||||||
|
</spine>
|
||||||
|
</package>"""
|
||||||
|
epub.writestr("OEBPS/content.opf", content_opf)
|
||||||
|
|
||||||
|
nav_xhtml = f"""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||||
|
<head><title>Table of Contents</title></head>
|
||||||
|
<body>
|
||||||
|
<nav epub:type="toc">
|
||||||
|
<ol>
|
||||||
|
{chr(10).join(nav_items)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
epub.writestr("OEBPS/nav.xhtml", nav_xhtml)
|
||||||
|
|
||||||
|
print(f"Generated: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_epub(OUTPUT_PATH)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include "BleInput.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalPowerManager.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
|
||||||
|
namespace bleinput {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
volatile bool g_startInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NimBLE controller init/deinit hang (interrupt WDT) if run at the 10 MHz low-power
|
||||||
|
// frequency, so force normal CPU speed around both. Centralized here so every caller
|
||||||
|
// (boot restore, settings toggle, reader toggle, sleep) is covered automatically.
|
||||||
|
bool ensureStarted() {
|
||||||
|
g_startInProgress = true;
|
||||||
|
HalPowerManager::Lock powerLock;
|
||||||
|
const bool ok = BleHid.begin(kHostName);
|
||||||
|
g_startInProgress = false;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool startInProgress() { return g_startInProgress; }
|
||||||
|
|
||||||
|
// Full teardown (NimBLE deinit), not just a link drop, so the BLE stack's RAM is
|
||||||
|
// returned to the heap — otherwise memory-hungry work like EPUB inflate can't
|
||||||
|
// allocate even after the user turns Bluetooth off.
|
||||||
|
void stop() {
|
||||||
|
HalPowerManager::Lock powerLock;
|
||||||
|
BleHid.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value) {
|
||||||
|
if (ev.special != freeink::SpecialKey::None) {
|
||||||
|
kind = 0;
|
||||||
|
value = static_cast<uint8_t>(ev.special);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ev.keycode != 0) {
|
||||||
|
kind = 1;
|
||||||
|
value = ev.keycode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const char* specialName(uint8_t value) {
|
||||||
|
switch (static_cast<freeink::SpecialKey>(value)) {
|
||||||
|
case freeink::SpecialKey::Enter:
|
||||||
|
return "Enter";
|
||||||
|
case freeink::SpecialKey::Backspace:
|
||||||
|
return "Backspace";
|
||||||
|
case freeink::SpecialKey::Tab:
|
||||||
|
return "Tab";
|
||||||
|
case freeink::SpecialKey::Escape:
|
||||||
|
return "Escape";
|
||||||
|
case freeink::SpecialKey::Delete:
|
||||||
|
return "Delete";
|
||||||
|
case freeink::SpecialKey::Left:
|
||||||
|
return "Left";
|
||||||
|
case freeink::SpecialKey::Right:
|
||||||
|
return "Right";
|
||||||
|
case freeink::SpecialKey::Up:
|
||||||
|
return "Up";
|
||||||
|
case freeink::SpecialKey::Down:
|
||||||
|
return "Down";
|
||||||
|
case freeink::SpecialKey::Home:
|
||||||
|
return "Home";
|
||||||
|
case freeink::SpecialKey::End:
|
||||||
|
return "End";
|
||||||
|
case freeink::SpecialKey::PageUp:
|
||||||
|
return "Page Up";
|
||||||
|
case freeink::SpecialKey::PageDown:
|
||||||
|
return "Page Down";
|
||||||
|
default:
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input) {
|
||||||
|
if (!BleHid.isRunning() || BleHid.isConnected()) return;
|
||||||
|
// drawPopup refreshes the panel itself, so draw once and let e-ink hold it while we
|
||||||
|
// pump the host. Holds until the remote links, the user presses a button to bail, or
|
||||||
|
// a generous timeout (a remote that slept after a disconnect needs a button to wake).
|
||||||
|
GUI.drawPopup(renderer, tr(STR_BT_CONNECTING_POPUP));
|
||||||
|
const unsigned long deadline = millis() + 10000;
|
||||||
|
while (!BleHid.isConnected() && millis() < deadline) {
|
||||||
|
BleHid.poll();
|
||||||
|
input.update();
|
||||||
|
if (input.wasAnyPressed()) break;
|
||||||
|
delay(50);
|
||||||
|
}
|
||||||
|
// Note: the caller must redraw to clear the popup. For grayscale reader pages the
|
||||||
|
// caller should also request a ghost-cleanup (HALF) refresh first — a plain fast/
|
||||||
|
// partial refresh ghosts badly over the BW popup (see Activity::requestGhostCleanup).
|
||||||
|
}
|
||||||
|
|
||||||
|
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen) {
|
||||||
|
if (!out || outLen == 0) return;
|
||||||
|
if (kind == 0) {
|
||||||
|
const char* name = specialName(value);
|
||||||
|
if (name) {
|
||||||
|
strncpy(out, name, outLen - 1);
|
||||||
|
out[outLen - 1] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Printable ASCII usage handled as a generic key code; show the raw value.
|
||||||
|
snprintf(out, outLen, "Key 0x%02X", static_cast<unsigned>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace bleinput
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// CrossPoint <-> FreeInk BLE HID host glue.
|
||||||
|
//
|
||||||
|
// Thin, capability-safe helpers around freeink::BleKeyboardHost (the `BleHid`
|
||||||
|
// singleton). When FREEINK_CAP_BLE_HID_HOST is compiled out the SDK links stubs,
|
||||||
|
// so every call here is still valid and simply no-ops / returns false — callers
|
||||||
|
// need no #ifdefs.
|
||||||
|
//
|
||||||
|
// The (kind, value) pair produced by encodeKey() is the stable identity stored in
|
||||||
|
// CrossPointSettings::bleKeyMap. Page-turner remotes emit "special" keys
|
||||||
|
// (PageUp/PageDown/arrows); plain keyboards emit usage codes. We deliberately
|
||||||
|
// ignore modifiers and the printable char for matching (page turners don't use
|
||||||
|
// modifiers), keeping the persisted entry a trivial two-byte comparison.
|
||||||
|
|
||||||
|
#include <BleKeyboardHost.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
class MappedInputManager;
|
||||||
|
|
||||||
|
namespace bleinput {
|
||||||
|
|
||||||
|
// Advertised central name shown to peripherals during pairing.
|
||||||
|
inline constexpr const char* kHostName = "CrossPoint";
|
||||||
|
|
||||||
|
// Heap floor for starting the NimBLE stack (measured begin() cost: ~52-57 KB).
|
||||||
|
// The reader now lends the framebuffer to section builds, so BLE startup no
|
||||||
|
// longer needs to reserve the old full build headroom. Keep a modest margin and
|
||||||
|
// let the render/build shed paths handle genuinely tight moments.
|
||||||
|
inline constexpr size_t kStartMinFreeHeap = 56 * 1024;
|
||||||
|
|
||||||
|
// Lower floor for the Bluetooth settings screen, where the user has explicitly asked
|
||||||
|
// for BLE right now (scanning/pairing is dead without the stack). No page renders or
|
||||||
|
// section builds run there, so the reader-sized reserve above doesn't apply — only
|
||||||
|
// NimBLE's own ~57 KB plus working margin.
|
||||||
|
inline constexpr size_t kStartMinFreeHeapExplicit = 70 * 1024;
|
||||||
|
|
||||||
|
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
|
||||||
|
// NimBLE init failed. Safe to call repeatedly.
|
||||||
|
bool ensureStarted();
|
||||||
|
bool startInProgress();
|
||||||
|
|
||||||
|
// Drop the active link (e.g. before deep sleep or when the user disables BT).
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// Encode a decoded key event into the stable (kind, value) identity used by the
|
||||||
|
// settings map. kind: 0 = SpecialKey, 1 = HID usage. Returns false when the event
|
||||||
|
// carries no usable identity (no special key and no usage code).
|
||||||
|
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value);
|
||||||
|
|
||||||
|
// Human-readable name for a stored (kind, value) identity, for the mapping UI.
|
||||||
|
// Writes a null-terminated string into out (e.g. "Page Down", "Key 0x4B").
|
||||||
|
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen);
|
||||||
|
|
||||||
|
// Draw a "BT Connecting..." popup and pump the BLE host until the bonded remote
|
||||||
|
// links, the user presses a button to dismiss, or a timeout. No-op if BLE isn't
|
||||||
|
// running or is already connected. The caller must redraw afterward to clear it.
|
||||||
|
void showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input);
|
||||||
|
|
||||||
|
} // namespace bleinput
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "I18nKeys.h"
|
#include "I18nKeys.h"
|
||||||
@@ -23,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 2;
|
||||||
constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin";
|
||||||
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
|
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
|
||||||
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
|
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
|
||||||
@@ -96,6 +97,7 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool CrossPointSettings::saveToFile() const {
|
bool CrossPointSettings::saveToFile() const {
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,11 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
||||||
if (!json.isEmpty()) {
|
if (!json.isEmpty()) {
|
||||||
bool resave = false;
|
bool resave = false;
|
||||||
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
bool result;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
|
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||||
|
}
|
||||||
if (result && resave) {
|
if (result && resave) {
|
||||||
if (saveToFile()) {
|
if (saveToFile()) {
|
||||||
LOG_DBG("CPS", "Resaved settings to update format");
|
LOG_DBG("CPS", "Resaved settings to update format");
|
||||||
@@ -166,6 +172,7 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(inputFile, version);
|
serialization::readPod(inputFile, version);
|
||||||
@@ -222,13 +229,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
|
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
{
|
|
||||||
std::string urlStr;
|
|
||||||
serialization::readString(inputFile, urlStr);
|
|
||||||
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
|
||||||
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
|
||||||
}
|
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
|
||||||
serialization::readPod(inputFile, textAntiAliasing);
|
serialization::readPod(inputFile, textAntiAliasing);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
|
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
|
||||||
@@ -237,20 +237,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, hyphenationEnabled);
|
serialization::readPod(inputFile, hyphenationEnabled);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
{
|
|
||||||
std::string usernameStr;
|
|
||||||
serialization::readString(inputFile, usernameStr);
|
|
||||||
strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1);
|
|
||||||
opdsUsername[sizeof(opdsUsername) - 1] = '\0';
|
|
||||||
}
|
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
|
||||||
{
|
|
||||||
std::string passwordStr;
|
|
||||||
serialization::readString(inputFile, passwordStr);
|
|
||||||
strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1);
|
|
||||||
opdsPassword[sizeof(opdsPassword) - 1] = '\0';
|
|
||||||
}
|
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
|
||||||
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, uiTheme);
|
serialization::readPod(inputFile, uiTheme);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <iosfwd>
|
#include <iosfwd>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
class CrossPointSettings {
|
class CrossPointSettings {
|
||||||
private:
|
private:
|
||||||
|
mutable std::mutex _mutex;
|
||||||
|
|
||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
CrossPointSettings() = default;
|
CrossPointSettings() = default;
|
||||||
|
|
||||||
@@ -17,6 +20,10 @@ class CrossPointSettings {
|
|||||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||||
CrossPointSettings& operator=(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 {
|
enum SLEEP_SCREEN_MODE {
|
||||||
DARK = 0,
|
DARK = 0,
|
||||||
LIGHT = 1,
|
LIGHT = 1,
|
||||||
@@ -218,6 +225,22 @@ class CrossPointSettings {
|
|||||||
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
||||||
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
||||||
uint8_t frontButtonRight = FRONT_HW_RIGHT;
|
uint8_t frontButtonRight = FRONT_HW_RIGHT;
|
||||||
|
// --- Bluetooth (BLE HID page-turner) ---
|
||||||
|
// Master on/off for the BLE HID host. Persisted; auto-restored on boot/wake.
|
||||||
|
// Managed by BluetoothSettingsActivity and the in-reader "Toggle Bluetooth" menu item.
|
||||||
|
uint8_t bluetoothEnabled = 0;
|
||||||
|
// Remote-button mapping table: each slot binds a decoded BLE key identity to a
|
||||||
|
// logical MappedInputManager::Button. Fixed-capacity POD (no heap), persisted
|
||||||
|
// manually in JsonSettingsIO (like the front-button remap). 0xFF = empty/unassigned.
|
||||||
|
// Headroom for several buttons plus optional presets and rolling-code remotes
|
||||||
|
// (some buttons emit more than one code). Each entry is 3 bytes.
|
||||||
|
static constexpr uint8_t BLE_MAP_CAPACITY = 10;
|
||||||
|
struct BleKeyMapEntry {
|
||||||
|
uint8_t keyKind = 0xFF; // 0 = SpecialKey, 1 = HID usage code; 0xFF = empty slot
|
||||||
|
uint8_t keyValue = 0; // (uint8_t)freeink::SpecialKey, or the raw HID usage id
|
||||||
|
uint8_t button = 0xFF; // (uint8_t)MappedInputManager::Button; 0xFF = unassigned
|
||||||
|
};
|
||||||
|
BleKeyMapEntry bleKeyMap[BLE_MAP_CAPACITY] = {};
|
||||||
// Reader font settings
|
// Reader font settings
|
||||||
uint8_t fontFamily = NOTOSERIF;
|
uint8_t fontFamily = NOTOSERIF;
|
||||||
uint8_t fontSize = MEDIUM;
|
uint8_t fontSize = MEDIUM;
|
||||||
@@ -231,10 +254,6 @@ class CrossPointSettings {
|
|||||||
|
|
||||||
// Reader screen margin settings
|
// Reader screen margin settings
|
||||||
uint8_t screenMargin = 5;
|
uint8_t screenMargin = 5;
|
||||||
// OPDS browser settings
|
|
||||||
char opdsServerUrl[128] = "";
|
|
||||||
char opdsUsername[64] = "";
|
|
||||||
char opdsPassword[64] = "";
|
|
||||||
// Hide battery percentage
|
// Hide battery percentage
|
||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press page turn button behavior
|
// Long-press page turn button behavior
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||||
@@ -32,6 +33,7 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool CrossPointState::saveToFile() const {
|
bool CrossPointState::saveToFile() const {
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ bool CrossPointState::loadFromFile() {
|
|||||||
if (Storage.exists(STATE_FILE_JSON)) {
|
if (Storage.exists(STATE_FILE_JSON)) {
|
||||||
String json = Storage.readFile(STATE_FILE_JSON);
|
String json = Storage.readFile(STATE_FILE_JSON);
|
||||||
if (!json.isEmpty()) {
|
if (!json.isEmpty()) {
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
return JsonSettingsIO::loadState(*this, json.c_str());
|
return JsonSettingsIO::loadState(*this, json.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,7 @@ bool CrossPointState::loadFromBinaryFile() {
|
|||||||
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(_mutex);
|
||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(inputFile, version);
|
serialization::readPod(inputFile, version);
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
class CrossPointState {
|
class CrossPointState {
|
||||||
|
mutable std::mutex _mutex;
|
||||||
|
|
||||||
// Static instance
|
// Static instance
|
||||||
static CrossPointState instance;
|
static CrossPointState instance;
|
||||||
|
|
||||||
public:
|
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;
|
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
||||||
|
|
||||||
std::string openEpubPath;
|
std::string openEpubPath;
|
||||||
|
|||||||
+30
-147
@@ -5,12 +5,15 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <iterator>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "BookmarkEntry.h"
|
#include "BookmarkEntry.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
@@ -142,6 +145,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
|||||||
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
||||||
doc["frontButtonLeft"] = s.frontButtonLeft;
|
doc["frontButtonLeft"] = s.frontButtonLeft;
|
||||||
doc["frontButtonRight"] = s.frontButtonRight;
|
doc["frontButtonRight"] = s.frontButtonRight;
|
||||||
|
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
|
||||||
|
doc["bluetoothEnabled"] = s.bluetoothEnabled;
|
||||||
|
JsonArray bleMap = doc["bleKeyMap"].to<JsonArray>();
|
||||||
|
for (const auto& e : s.bleKeyMap) {
|
||||||
|
if (e.keyKind == 0xFF || e.button == 0xFF) continue; // skip empty/unassigned slots
|
||||||
|
JsonObject o = bleMap.add<JsonObject>();
|
||||||
|
o["k"] = e.keyKind;
|
||||||
|
o["v"] = e.keyValue;
|
||||||
|
o["b"] = e.button;
|
||||||
|
}
|
||||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
doc["fontFamily"] = s.fontFamily;
|
doc["fontFamily"] = s.fontFamily;
|
||||||
// SD card font family name — not in SettingsList, save manually
|
// SD card font family name — not in SettingsList, save manually
|
||||||
@@ -153,10 +166,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
|||||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
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;
|
String json;
|
||||||
serializeJson(doc, json);
|
serializeJson(doc, json);
|
||||||
return Storage.writeFile(path, json);
|
return Storage.writeFile(path, json);
|
||||||
@@ -243,6 +252,23 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
|||||||
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
||||||
CrossPointSettings::validateFrontButtonMapping(s);
|
CrossPointSettings::validateFrontButtonMapping(s);
|
||||||
|
|
||||||
|
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
|
||||||
|
s.bluetoothEnabled = clamp(doc["bluetoothEnabled"] | (uint8_t)0, 2, 0);
|
||||||
|
std::fill(std::begin(s.bleKeyMap), std::end(s.bleKeyMap), CrossPointSettings::BleKeyMapEntry{}); // reset to empty
|
||||||
|
JsonArrayConst bleMap = doc["bleKeyMap"];
|
||||||
|
if (!bleMap.isNull()) {
|
||||||
|
uint8_t slot = 0;
|
||||||
|
for (JsonObjectConst o : bleMap) {
|
||||||
|
if (slot >= CrossPointSettings::BLE_MAP_CAPACITY) break;
|
||||||
|
const uint8_t button = o["b"] | (uint8_t)0xFF;
|
||||||
|
if (button >= MappedInputManager::kButtonCount) continue; // drop invalid mappings
|
||||||
|
s.bleKeyMap[slot].keyKind = o["k"] | (uint8_t)0xFF;
|
||||||
|
s.bleKeyMap[slot].keyValue = o["v"] | (uint8_t)0;
|
||||||
|
s.bleKeyMap[slot].button = button;
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
||||||
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
||||||
@@ -269,149 +295,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- WifiCredentialStore ----
|
|
||||||
|
|
||||||
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
|
|
||||||
|
|
||||||
JsonArray arr = doc["credentials"].to<JsonArray>();
|
|
||||||
for (const auto& cred : store.getCredentials()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
obj["ssid"] = cred.ssid;
|
|
||||||
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
return Storage.writeFile(path, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) {
|
|
||||||
if (needsResave) *needsResave = false;
|
|
||||||
JsonDocument doc;
|
|
||||||
auto error = deserializeJson(doc, json);
|
|
||||||
if (error) {
|
|
||||||
LOG_ERR("WCS", "JSON parse error: %s", error.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string("");
|
|
||||||
|
|
||||||
store.credentials.clear();
|
|
||||||
JsonArray arr = doc["credentials"].as<JsonArray>();
|
|
||||||
for (JsonObject obj : arr) {
|
|
||||||
if (store.credentials.size() >= store.MAX_NETWORKS) break;
|
|
||||||
WifiCredential cred;
|
|
||||||
cred.ssid = obj["ssid"] | std::string("");
|
|
||||||
bool ok = false;
|
|
||||||
cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
|
||||||
if (!ok || cred.password.empty()) {
|
|
||||||
cred.password = obj["password"] | std::string("");
|
|
||||||
if (!cred.password.empty() && needsResave) *needsResave = true;
|
|
||||||
}
|
|
||||||
store.credentials.push_back(cred);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- RecentBooksStore ----
|
|
||||||
|
|
||||||
bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) {
|
|
||||||
JsonDocument doc;
|
|
||||||
JsonArray arr = doc["books"].to<JsonArray>();
|
|
||||||
for (const auto& book : store.getBooks()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
obj["path"] = book.path;
|
|
||||||
obj["title"] = book.title;
|
|
||||||
obj["author"] = book.author;
|
|
||||||
obj["coverBmpPath"] = book.coverBmpPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
return Storage.writeFile(path, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) {
|
|
||||||
JsonDocument doc;
|
|
||||||
auto error = deserializeJson(doc, json);
|
|
||||||
if (error) {
|
|
||||||
LOG_ERR("RBS", "JSON parse error: %s", error.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.recentBooks.clear();
|
|
||||||
JsonArray arr = doc["books"].as<JsonArray>();
|
|
||||||
for (JsonObject obj : arr) {
|
|
||||||
if (store.getCount() >= 10) break;
|
|
||||||
RecentBook book;
|
|
||||||
book.path = obj["path"] | std::string("");
|
|
||||||
book.title = obj["title"] | std::string("");
|
|
||||||
book.author = obj["author"] | std::string("");
|
|
||||||
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
|
|
||||||
store.recentBooks.push_back(book);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- OpdsServerStore ----
|
|
||||||
// Follows the same save/load pattern as WifiCredentialStore above.
|
|
||||||
// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key).
|
|
||||||
|
|
||||||
bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) {
|
|
||||||
JsonDocument doc;
|
|
||||||
|
|
||||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
|
||||||
for (const auto& server : store.getServers()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
obj["name"] = server.name;
|
|
||||||
obj["url"] = server.url;
|
|
||||||
obj["username"] = server.username;
|
|
||||||
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
return Storage.writeFile(path, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) {
|
|
||||||
if (needsResave) *needsResave = false;
|
|
||||||
JsonDocument doc;
|
|
||||||
auto error = deserializeJson(doc, json);
|
|
||||||
if (error) {
|
|
||||||
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.servers.clear();
|
|
||||||
JsonArray arr = doc["servers"].as<JsonArray>();
|
|
||||||
for (JsonObject obj : arr) {
|
|
||||||
if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
|
||||||
OpdsServer server;
|
|
||||||
server.name = obj["name"] | std::string("");
|
|
||||||
server.url = obj["url"] | std::string("");
|
|
||||||
server.username = obj["username"] | std::string("");
|
|
||||||
// Try the obfuscated key first; fall back to plaintext "password" for
|
|
||||||
// files written before obfuscation was added (or hand-edited JSON).
|
|
||||||
bool ok = false;
|
|
||||||
server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
|
||||||
if (!ok || server.password.empty()) {
|
|
||||||
server.password = obj["password"] | std::string("");
|
|
||||||
if (!server.password.empty() && needsResave) *needsResave = true;
|
|
||||||
}
|
|
||||||
store.servers.push_back(std::move(server));
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Bookmarks ----
|
// ---- Bookmarks ----
|
||||||
|
|
||||||
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = n
|
|||||||
bool saveState(const CrossPointState& s, const char* path);
|
bool saveState(const CrossPointState& s, const char* path);
|
||||||
bool loadState(CrossPointState& s, const char* json);
|
bool loadState(CrossPointState& s, const char* json);
|
||||||
|
|
||||||
// WifiCredentialStore
|
|
||||||
bool saveWifi(const WifiCredentialStore& store, const char* path);
|
|
||||||
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr);
|
|
||||||
|
|
||||||
// RecentBooksStore
|
|
||||||
bool saveRecentBooks(const RecentBooksStore& store, const char* path);
|
|
||||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
|
||||||
|
|
||||||
// OpdsServerStore
|
|
||||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
|
||||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
|
||||||
|
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
||||||
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||||
@@ -74,17 +75,105 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
bool MappedInputManager::bleEdge(const bool* arr, const Button button) const {
|
||||||
|
// Mirror mapButton()'s composite navigation handling so a BLE key bound to a
|
||||||
|
// physical direction also satisfies the derived NavNext / NavPrevious logical
|
||||||
|
// buttons (used by list navigation), respecting the orientation axis flip.
|
||||||
|
switch (button) {
|
||||||
|
case Button::NavNext:
|
||||||
|
return isNavDirectionSwapped() ? (arr[(int)Button::Up] || arr[(int)Button::Left])
|
||||||
|
: (arr[(int)Button::Down] || arr[(int)Button::Right]);
|
||||||
|
case Button::NavPrevious:
|
||||||
|
return isNavDirectionSwapped() ? (arr[(int)Button::Down] || arr[(int)Button::Right])
|
||||||
|
: (arr[(int)Button::Up] || arr[(int)Button::Left]);
|
||||||
|
default:
|
||||||
|
return arr[(int)button];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
bool MappedInputManager::wasPressed(const Button button) const {
|
||||||
|
return mapButton(button, &HalGPIO::wasPressed) || bleEdge(blePressEdge, button);
|
||||||
|
}
|
||||||
|
|
||||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
bool MappedInputManager::wasReleased(const Button button) const {
|
||||||
|
return mapButton(button, &HalGPIO::wasReleased) || bleEdge(bleReleaseEdge, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MappedInputManager::isPressed(const Button button) const {
|
||||||
|
// A BLE tap is momentary: report "pressed" only on the press-edge frame.
|
||||||
|
return mapButton(button, &HalGPIO::isPressed) || bleEdge(blePressEdge, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MappedInputManager::setBleCaptureMode(const bool on) {
|
||||||
|
bleCaptureMode = on;
|
||||||
|
bleHasCaptured = false;
|
||||||
|
if (on) {
|
||||||
|
// Clear any stale overlay so a held remote key doesn't leak into the UI.
|
||||||
|
for (uint8_t i = 0; i < kButtonCount; i++) {
|
||||||
|
blePressEdge[i] = false;
|
||||||
|
bleReleaseEdge[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MappedInputManager::takeCapturedBleKey(uint8_t& kind, uint8_t& value) {
|
||||||
|
if (!bleHasCaptured) return false;
|
||||||
|
kind = bleCapturedKind;
|
||||||
|
value = bleCapturedValue;
|
||||||
|
bleHasCaptured = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MappedInputManager::pollBle() {
|
||||||
|
bleActivityThisFrame = false;
|
||||||
|
// Age last frame's press edges into this frame's release edges (the FreeInk host
|
||||||
|
// surfaces presses + synthetic repeats but never releases), then clear presses. A
|
||||||
|
// pending release also counts as BLE activity this frame so getHeldTime() reports
|
||||||
|
// zero on the release frame too (page-turn handlers often fire on release).
|
||||||
|
for (uint8_t i = 0; i < kButtonCount; i++) {
|
||||||
|
bleReleaseEdge[i] = blePressEdge[i];
|
||||||
|
blePressEdge[i] = false;
|
||||||
|
if (bleReleaseEdge[i]) bleActivityThisFrame = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::KeyEvent ev;
|
||||||
|
while (BleHid.popKey(ev)) {
|
||||||
|
uint8_t kind = 0xFF;
|
||||||
|
uint8_t value = 0;
|
||||||
|
if (!bleinput::encodeKey(ev, kind, value)) continue;
|
||||||
|
|
||||||
|
if (bleCaptureMode) {
|
||||||
|
bleCapturedKind = kind;
|
||||||
|
bleCapturedValue = value;
|
||||||
|
bleHasCaptured = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the key identity against the persisted mapping table.
|
||||||
|
for (const auto& e : SETTINGS.bleKeyMap) {
|
||||||
|
if (e.button == 0xFF || e.keyKind != kind || e.keyValue != value) continue;
|
||||||
|
if (e.button < kButtonCount) {
|
||||||
|
blePressEdge[e.button] = true;
|
||||||
|
bleActivityThisFrame = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||||
|
|
||||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
unsigned long MappedInputManager::getHeldTime() const {
|
||||||
|
// A BLE-mapped key is a momentary tap with no physical hold (we don't model BLE
|
||||||
|
// press-and-hold). gpio.getHeldTime() returns the *last physical* button's hold
|
||||||
|
// duration, which is stale — if a BLE edge drove input this frame, reporting that
|
||||||
|
// stale value makes a tap look like a long-press (e.g. page tap -> chapter skip).
|
||||||
|
// Report zero in that case so BLE taps are always treated as short presses.
|
||||||
|
if (bleActivityThisFrame) return 0;
|
||||||
|
return gpio.getHeldTime();
|
||||||
|
}
|
||||||
|
|
||||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||||
const char* next) const {
|
const char* next) const {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ class GfxRenderer;
|
|||||||
class MappedInputManager {
|
class MappedInputManager {
|
||||||
public:
|
public:
|
||||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||||
|
// Number of values in Button (Back..NavPrevious). Used to size the BLE overlay and
|
||||||
|
// to clamp persisted BLE mappings. Keep in sync with the enum above.
|
||||||
|
static constexpr uint8_t kButtonCount = 11;
|
||||||
|
|
||||||
struct Labels {
|
struct Labels {
|
||||||
const char* btn1;
|
const char* btn1;
|
||||||
@@ -28,6 +31,23 @@ class MappedInputManager {
|
|||||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||||
int getPressedFrontButton() const;
|
int getPressedFrontButton() const;
|
||||||
|
|
||||||
|
// --- BLE page-turner overlay -------------------------------------------------
|
||||||
|
// Drain decoded key events from the FreeInk BLE HID host and translate the ones
|
||||||
|
// bound in SETTINGS.bleKeyMap into per-frame logical-button edges that OR into
|
||||||
|
// wasPressed()/isPressed()/wasReleased(). Call once per main-loop iteration,
|
||||||
|
// right after gpio.update() and BleHid.poll(). No-ops when BLE is compiled out.
|
||||||
|
void pollBle();
|
||||||
|
// True when a mapped BLE key produced an edge this frame — keeps the inactivity
|
||||||
|
// / auto-sleep timer alive while a remote is the only input device in use.
|
||||||
|
bool bleHadActivityThisFrame() const { return bleActivityThisFrame; }
|
||||||
|
// Capture mode: while on, pollBle() stops mapping events and instead stashes the
|
||||||
|
// raw decoded key identity so the button-mapping UI can read it without racing the
|
||||||
|
// live mapping over the single popKey() queue.
|
||||||
|
void setBleCaptureMode(bool on);
|
||||||
|
// Pop a captured (kind, value) key identity grabbed while in capture mode.
|
||||||
|
// Returns false when nothing has been captured since the last call.
|
||||||
|
bool takeCapturedBleKey(uint8_t& kind, uint8_t& value);
|
||||||
|
|
||||||
// True when the control axis is flipped relative to the physical buttons: the user opted into
|
// True when the control axis is flipped relative to the physical buttons: the user opted into
|
||||||
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
|
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
|
||||||
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
|
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
|
||||||
@@ -44,4 +64,17 @@ class MappedInputManager {
|
|||||||
const GfxRenderer& renderer;
|
const GfxRenderer& renderer;
|
||||||
|
|
||||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||||
|
// OR-in the BLE overlay for a logical button, mirroring mapButton()'s composite
|
||||||
|
// handling of NavNext/NavPrevious so a remote key bound to Up/Down/Left/Right also
|
||||||
|
// drives list navigation.
|
||||||
|
bool bleEdge(const bool* arr, Button button) const;
|
||||||
|
|
||||||
|
// Per-frame BLE overlay, indexed by (uint8_t)Button.
|
||||||
|
bool blePressEdge[kButtonCount] = {}; // press edge this frame -> wasPressed / isPressed
|
||||||
|
bool bleReleaseEdge[kButtonCount] = {}; // release edge this frame -> wasReleased
|
||||||
|
bool bleActivityThisFrame = false;
|
||||||
|
bool bleCaptureMode = false;
|
||||||
|
bool bleHasCaptured = false;
|
||||||
|
uint8_t bleCapturedKind = 0xFF;
|
||||||
|
uint8_t bleCapturedValue = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-62
@@ -1,74 +1,48 @@
|
|||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
void OpdsServerStore::toJson(JsonDocument& doc) const {
|
||||||
|
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||||
OpdsServerStore OpdsServerStore::instance;
|
for (const auto& server : servers) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
namespace {
|
obj["name"] = server.name;
|
||||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
obj["url"] = server.url;
|
||||||
} // namespace
|
obj["username"] = server.username;
|
||||||
|
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
||||||
bool OpdsServerStore::saveToFile() const {
|
}
|
||||||
Storage.mkdir("/.crosspoint");
|
|
||||||
return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OpdsServerStore::loadFromFile() {
|
bool OpdsServerStore::fromJson(JsonVariantConst doc) {
|
||||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
// Tolerate a missing/invalid 'servers' key (treat as empty list); only a
|
||||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
// JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||||
if (!json.isEmpty()) {
|
|
||||||
// resave flag is set when passwords were stored in plaintext and need re-obfuscation
|
|
||||||
bool resave = false;
|
|
||||||
bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave);
|
|
||||||
if (result && resave) {
|
|
||||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
|
||||||
saveToFile();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No opds.json found — attempt one-time migration from the legacy single-server
|
|
||||||
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
|
|
||||||
if (migrateFromSettings()) {
|
|
||||||
LOG_DBG("OPS", "Migrated legacy OPDS settings");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool OpdsServerStore::migrateFromSettings() {
|
|
||||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpdsServer server;
|
|
||||||
server.name = "OPDS Server";
|
|
||||||
server.url = SETTINGS.opdsServerUrl;
|
|
||||||
server.username = SETTINGS.opdsUsername;
|
|
||||||
server.password = SETTINGS.opdsPassword;
|
|
||||||
servers.push_back(std::move(server));
|
|
||||||
|
|
||||||
if (saveToFile()) {
|
|
||||||
// Clear legacy fields so migration won't run again on next boot
|
|
||||||
SETTINGS.opdsServerUrl[0] = '\0';
|
|
||||||
SETTINGS.opdsUsername[0] = '\0';
|
|
||||||
SETTINGS.opdsPassword[0] = '\0';
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save failed — roll back in-memory state so we don't have a partial migration
|
|
||||||
servers.clear();
|
servers.clear();
|
||||||
return false;
|
JsonArrayConst arr = doc["servers"].as<JsonArrayConst>();
|
||||||
|
servers.reserve(std::min(arr.size(), MAX_SERVERS));
|
||||||
|
bool needsResave = false;
|
||||||
|
|
||||||
|
for (JsonObjectConst obj : arr) {
|
||||||
|
if (servers.size() >= OpdsServerStore::MAX_SERVERS) break;
|
||||||
|
OpdsServer server;
|
||||||
|
server.name = obj["name"] | "";
|
||||||
|
server.url = obj["url"] | "";
|
||||||
|
server.username = obj["username"] | "";
|
||||||
|
server.password = extractPassword(obj, needsResave);
|
||||||
|
servers.push_back(std::move(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||||
|
|
||||||
|
if (needsResave) {
|
||||||
|
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||||
|
|||||||
+8
-23
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -9,37 +12,25 @@ struct OpdsServer {
|
|||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||||
};
|
};
|
||||||
|
|
||||||
class OpdsServerStore;
|
|
||||||
namespace JsonSettingsIO {
|
|
||||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
|
||||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave);
|
|
||||||
} // namespace JsonSettingsIO
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton class for storing OPDS server configurations on the SD card.
|
* Singleton class for storing OPDS server configurations on the SD card.
|
||||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||||
* and base64-encoded before writing to JSON.
|
* and base64-encoded before writing to JSON.
|
||||||
*/
|
*/
|
||||||
class OpdsServerStore {
|
class OpdsServerStore : public PersistableStore<OpdsServerStore> {
|
||||||
private:
|
private:
|
||||||
static OpdsServerStore instance;
|
|
||||||
std::vector<OpdsServer> servers;
|
std::vector<OpdsServer> servers;
|
||||||
|
|
||||||
static constexpr size_t MAX_SERVERS = 8;
|
static constexpr size_t MAX_SERVERS = 8;
|
||||||
|
|
||||||
OpdsServerStore() = default;
|
OpdsServerStore() = default;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
|
friend class PersistableStore<OpdsServerStore>;
|
||||||
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
static const char* getFilePath() { return "/.crosspoint/opds.json"; }
|
||||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
|
bool fromJson(JsonVariantConst doc);
|
||||||
static OpdsServerStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
bool addServer(const OpdsServer& server);
|
bool addServer(const OpdsServer& server);
|
||||||
bool updateServer(size_t index, const OpdsServer& server);
|
bool updateServer(size_t index, const OpdsServer& server);
|
||||||
@@ -49,12 +40,6 @@ class OpdsServerStore {
|
|||||||
const OpdsServer* getServer(size_t index) const;
|
const OpdsServer* getServer(size_t index) const;
|
||||||
size_t getCount() const { return servers.size(); }
|
size_t getCount() const { return servers.size(); }
|
||||||
bool hasServers() const { return !servers.empty(); }
|
bool hasServers() const { return !servers.empty(); }
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate from legacy single-server settings in CrossPointSettings.
|
|
||||||
* Called once during first load if no opds.json exists.
|
|
||||||
*/
|
|
||||||
bool migrateFromSettings();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||||
|
|||||||
+29
-107
@@ -3,23 +3,42 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
|
|
||||||
namespace {
|
void RecentBooksStore::toJson(JsonDocument& doc) const {
|
||||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
JsonArray arr = doc["books"].to<JsonArray>();
|
||||||
constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin";
|
for (const auto& book : recentBooks) {
|
||||||
constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json";
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak";
|
obj["path"] = book.path;
|
||||||
constexpr int MAX_RECENT_BOOKS = 10;
|
obj["title"] = book.title;
|
||||||
} // namespace
|
obj["author"] = book.author;
|
||||||
|
obj["coverBmpPath"] = book.coverBmpPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
RecentBooksStore RecentBooksStore::instance;
|
bool RecentBooksStore::fromJson(JsonVariantConst doc) {
|
||||||
|
// Tolerate a missing/invalid 'books' key (treat as empty list); only a
|
||||||
|
// JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||||
|
recentBooks.clear();
|
||||||
|
JsonArrayConst arr = doc["books"].as<JsonArrayConst>();
|
||||||
|
recentBooks.reserve(std::min(arr.size(), static_cast<size_t>(MAX_RECENT_BOOKS)));
|
||||||
|
for (JsonObjectConst obj : arr) {
|
||||||
|
if (getCount() >= MAX_RECENT_BOOKS) break;
|
||||||
|
RecentBook book;
|
||||||
|
book.path = obj["path"] | "";
|
||||||
|
book.title = obj["title"] | "";
|
||||||
|
book.author = obj["author"] | "";
|
||||||
|
book.coverBmpPath = obj["coverBmpPath"] | "";
|
||||||
|
recentBooks.push_back(book);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", getCount());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||||
const std::string& coverBmpPath) {
|
const std::string& coverBmpPath) {
|
||||||
@@ -92,11 +111,6 @@ bool RecentBooksStore::pruneMissing() {
|
|||||||
return recentBooks.size() != before;
|
return recentBooks.size() != before;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RecentBooksStore::saveToFile() const {
|
|
||||||
Storage.mkdir("/.crosspoint");
|
|
||||||
return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||||
std::string lastBookFileName = "";
|
std::string lastBookFileName = "";
|
||||||
const size_t lastSlash = path.find_last_of('/');
|
const size_t lastSlash = path.find_last_of('/');
|
||||||
@@ -124,95 +138,3 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
|||||||
}
|
}
|
||||||
return RecentBook{path, "", "", ""};
|
return RecentBook{path, "", "", ""};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RecentBooksStore::loadFromFile() {
|
|
||||||
// Try JSON first
|
|
||||||
if (Storage.exists(RECENT_BOOKS_FILE_JSON)) {
|
|
||||||
String json = Storage.readFile(RECENT_BOOKS_FILE_JSON);
|
|
||||||
if (!json.isEmpty()) {
|
|
||||||
return JsonSettingsIO::loadRecentBooks(*this, json.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to binary migration
|
|
||||||
if (Storage.exists(RECENT_BOOKS_FILE_BIN)) {
|
|
||||||
if (loadFromBinaryFile()) {
|
|
||||||
saveToFile();
|
|
||||||
Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK);
|
|
||||||
LOG_DBG("RBS", "Migrated recent.bin to recent.json");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool RecentBooksStore::loadFromBinaryFile() {
|
|
||||||
HalFile inputFile;
|
|
||||||
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t version;
|
|
||||||
serialization::readPod(inputFile, version);
|
|
||||||
if (version == 1 || version == 2) {
|
|
||||||
// Old version, just read paths
|
|
||||||
uint8_t count;
|
|
||||||
serialization::readPod(inputFile, count);
|
|
||||||
recentBooks.clear();
|
|
||||||
recentBooks.reserve(count);
|
|
||||||
for (uint8_t i = 0; i < count; i++) {
|
|
||||||
std::string path;
|
|
||||||
serialization::readString(inputFile, path);
|
|
||||||
|
|
||||||
// load book to get missing data
|
|
||||||
RecentBook book = getDataFromBook(path);
|
|
||||||
if (book.title.empty() && book.author.empty() && version == 2) {
|
|
||||||
// Fall back to loading what we can from the store
|
|
||||||
std::string title, author;
|
|
||||||
serialization::readString(inputFile, title);
|
|
||||||
serialization::readString(inputFile, author);
|
|
||||||
recentBooks.push_back({path, title, author, ""});
|
|
||||||
} else {
|
|
||||||
recentBooks.push_back(book);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (version == 3) {
|
|
||||||
uint8_t count;
|
|
||||||
serialization::readPod(inputFile, count);
|
|
||||||
|
|
||||||
recentBooks.clear();
|
|
||||||
recentBooks.reserve(count);
|
|
||||||
uint8_t omitted = 0;
|
|
||||||
|
|
||||||
for (uint8_t i = 0; i < count; i++) {
|
|
||||||
std::string path, title, author, coverBmpPath;
|
|
||||||
serialization::readString(inputFile, path);
|
|
||||||
serialization::readString(inputFile, title);
|
|
||||||
serialization::readString(inputFile, author);
|
|
||||||
serialization::readString(inputFile, coverBmpPath);
|
|
||||||
|
|
||||||
// Omit books with missing title (e.g. saved before metadata was available)
|
|
||||||
if (title.empty()) {
|
|
||||||
omitted++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
recentBooks.push_back({path, title, author, coverBmpPath});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (omitted > 0) {
|
|
||||||
// Explicitly close() file before saveToFile() rewrites the same file
|
|
||||||
inputFile.close();
|
|
||||||
saveToFile();
|
|
||||||
LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast<int>(recentBooks.size()));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-19
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -11,24 +14,21 @@ struct RecentBook {
|
|||||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||||
};
|
};
|
||||||
|
|
||||||
class RecentBooksStore;
|
class RecentBooksStore : public PersistableStore<RecentBooksStore> {
|
||||||
namespace JsonSettingsIO {
|
private:
|
||||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
|
||||||
} // namespace JsonSettingsIO
|
|
||||||
|
|
||||||
class RecentBooksStore {
|
|
||||||
// Static instance
|
|
||||||
static RecentBooksStore instance;
|
|
||||||
|
|
||||||
std::vector<RecentBook> recentBooks;
|
std::vector<RecentBook> recentBooks;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*);
|
static constexpr int MAX_RECENT_BOOKS = 10;
|
||||||
|
|
||||||
public:
|
RecentBooksStore() = default;
|
||||||
~RecentBooksStore() = default;
|
~RecentBooksStore() = default;
|
||||||
|
|
||||||
// Get singleton instance
|
friend class PersistableStore<RecentBooksStore>;
|
||||||
static RecentBooksStore& getInstance() { return instance; }
|
|
||||||
|
public:
|
||||||
|
static const char* getFilePath() { return "/.crosspoint/recent.json"; }
|
||||||
|
void toJson(JsonDocument& doc) const;
|
||||||
|
bool fromJson(JsonVariantConst doc);
|
||||||
|
|
||||||
// Add a book to the recent list (moves to front if already exists)
|
// Add a book to the recent list (moves to front if already exists)
|
||||||
void addBook(const std::string& path, const std::string& title, const std::string& author,
|
void addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||||
@@ -61,13 +61,7 @@ class RecentBooksStore {
|
|||||||
// Get the count of recent books
|
// Get the count of recent books
|
||||||
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
||||||
|
|
||||||
bool saveToFile() const;
|
|
||||||
|
|
||||||
bool loadFromFile();
|
|
||||||
RecentBook getDataFromBook(std::string path) const;
|
RecentBook getDataFromBook(std::string path) const;
|
||||||
|
|
||||||
private:
|
|
||||||
bool loadFromBinaryFile();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper macro to access recent books store
|
// Helper macro to access recent books store
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
|||||||
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
||||||
manager_.unloadAll(renderer);
|
manager_.unloadAll(renderer);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||||
@@ -96,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -104,8 +104,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
std::vector<SettingInfo> v = {
|
std::vector<SettingInfo> v = {
|
||||||
// --- Display ---
|
// --- Display ---
|
||||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
|
||||||
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
|
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
|
||||||
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
||||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
||||||
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
||||||
|
|||||||
@@ -6,3 +6,7 @@
|
|||||||
|
|
||||||
void silentRestart(); // home screen
|
void silentRestart(); // home screen
|
||||||
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
|
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
|
||||||
|
// True when this boot itself came from a silent restart. Callers that restart
|
||||||
|
// as a last-resort defrag must check this so a failure that survives the
|
||||||
|
// restart degrades to an error instead of a reboot loop.
|
||||||
|
bool bootWasSilentRestart();
|
||||||
|
|||||||
+27
-84
@@ -1,103 +1,46 @@
|
|||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
#include <Serialization.h>
|
|
||||||
|
|
||||||
// Initialize the static instance
|
#include <algorithm>
|
||||||
WifiCredentialStore WifiCredentialStore::instance;
|
|
||||||
|
|
||||||
namespace {
|
void WifiCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
// File format version (for binary migration)
|
doc["lastConnectedSsid"] = lastConnectedSsid;
|
||||||
constexpr uint8_t WIFI_FILE_VERSION = 2;
|
|
||||||
|
|
||||||
// File paths
|
JsonArray arr = doc["credentials"].to<JsonArray>();
|
||||||
constexpr char WIFI_FILE_BIN[] = "/.crosspoint/wifi.bin";
|
for (const auto& cred : credentials) {
|
||||||
constexpr char WIFI_FILE_JSON[] = "/.crosspoint/wifi.json";
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
constexpr char WIFI_FILE_BAK[] = "/.crosspoint/wifi.bin.bak";
|
obj["ssid"] = cred.ssid;
|
||||||
|
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
||||||
// Legacy obfuscation key - "CrossPoint" in ASCII (only used for binary migration)
|
|
||||||
constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74};
|
|
||||||
constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY);
|
|
||||||
|
|
||||||
void legacyDeobfuscate(std::string& data) {
|
|
||||||
for (size_t i = 0; i < data.size(); i++) {
|
|
||||||
data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool WifiCredentialStore::saveToFile() const {
|
bool WifiCredentialStore::fromJson(JsonVariantConst doc) {
|
||||||
Storage.mkdir("/.crosspoint");
|
lastConnectedSsid = doc["lastConnectedSsid"] | "";
|
||||||
return JsonSettingsIO::saveWifi(*this, WIFI_FILE_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WifiCredentialStore::loadFromFile() {
|
|
||||||
// Try JSON first
|
|
||||||
if (Storage.exists(WIFI_FILE_JSON)) {
|
|
||||||
String json = Storage.readFile(WIFI_FILE_JSON);
|
|
||||||
if (!json.isEmpty()) {
|
|
||||||
bool resave = false;
|
|
||||||
bool result = JsonSettingsIO::loadWifi(*this, json.c_str(), &resave);
|
|
||||||
if (result && resave) {
|
|
||||||
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
|
|
||||||
saveToFile();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to binary migration
|
|
||||||
if (Storage.exists(WIFI_FILE_BIN)) {
|
|
||||||
if (loadFromBinaryFile()) {
|
|
||||||
if (saveToFile()) {
|
|
||||||
Storage.rename(WIFI_FILE_BIN, WIFI_FILE_BAK);
|
|
||||||
LOG_DBG("WCS", "Migrated wifi.bin to wifi.json");
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
LOG_ERR("WCS", "Failed to save wifi during migration");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WifiCredentialStore::loadFromBinaryFile() {
|
|
||||||
HalFile file;
|
|
||||||
if (!Storage.openFileForRead("WCS", WIFI_FILE_BIN, file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t version;
|
|
||||||
serialization::readPod(file, version);
|
|
||||||
if (version > WIFI_FILE_VERSION) {
|
|
||||||
LOG_DBG("WCS", "Unknown file version: %u", version);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version >= 2) {
|
|
||||||
serialization::readString(file, lastConnectedSsid);
|
|
||||||
} else {
|
|
||||||
lastConnectedSsid.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t count;
|
|
||||||
serialization::readPod(file, count);
|
|
||||||
|
|
||||||
|
// Tolerate a missing/invalid 'credentials' key (treat as empty list); only
|
||||||
|
// a JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||||
credentials.clear();
|
credentials.clear();
|
||||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
JsonArrayConst arr = doc["credentials"].as<JsonArrayConst>();
|
||||||
|
credentials.reserve(std::min(arr.size(), MAX_NETWORKS));
|
||||||
|
bool needsResave = false;
|
||||||
|
|
||||||
|
for (JsonObjectConst obj : arr) {
|
||||||
|
if (credentials.size() >= MAX_NETWORKS) break;
|
||||||
WifiCredential cred;
|
WifiCredential cred;
|
||||||
serialization::readString(file, cred.ssid);
|
cred.ssid = obj["ssid"] | "";
|
||||||
serialization::readString(file, cred.password);
|
cred.password = extractPassword(obj, needsResave);
|
||||||
legacyDeobfuscate(cred.password);
|
|
||||||
credentials.push_back(cred);
|
credentials.push_back(cred);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size());
|
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
|
||||||
|
|
||||||
|
if (needsResave) {
|
||||||
|
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -7,21 +10,14 @@ struct WifiCredential {
|
|||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||||
};
|
};
|
||||||
|
|
||||||
class WifiCredentialStore;
|
|
||||||
namespace JsonSettingsIO {
|
|
||||||
bool saveWifi(const WifiCredentialStore& store, const char* path);
|
|
||||||
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave);
|
|
||||||
} // namespace JsonSettingsIO
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton class for storing WiFi credentials on the SD card.
|
* Singleton class for storing WiFi credentials on the SD card.
|
||||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||||
* but prevents casual reading and ties credentials to the specific device).
|
* but prevents casual reading and ties credentials to the specific device).
|
||||||
*/
|
*/
|
||||||
class WifiCredentialStore {
|
class WifiCredentialStore : public PersistableStore<WifiCredentialStore> {
|
||||||
private:
|
private:
|
||||||
static WifiCredentialStore instance;
|
|
||||||
std::vector<WifiCredential> credentials;
|
std::vector<WifiCredential> credentials;
|
||||||
std::string lastConnectedSsid;
|
std::string lastConnectedSsid;
|
||||||
|
|
||||||
@@ -30,22 +26,12 @@ class WifiCredentialStore {
|
|||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
WifiCredentialStore() = default;
|
WifiCredentialStore() = default;
|
||||||
|
|
||||||
bool loadFromBinaryFile();
|
friend class PersistableStore<WifiCredentialStore>;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*);
|
|
||||||
friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Delete copy constructor and assignment
|
static const char* getFilePath() { return "/.crosspoint/wifi.json"; }
|
||||||
WifiCredentialStore(const WifiCredentialStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
|
bool fromJson(JsonVariantConst doc);
|
||||||
|
|
||||||
// Get singleton instance
|
|
||||||
static WifiCredentialStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
// Save/load from SD card
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
// Credential management
|
// Credential management
|
||||||
bool addCredential(const std::string& ssid, const std::string& password);
|
bool addCredential(const std::string& ssid, const std::string& password);
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ class Activity {
|
|||||||
virtual bool skipLoopDelay() { return false; }
|
virtual bool skipLoopDelay() { return false; }
|
||||||
virtual bool preventAutoSleep() { return false; }
|
virtual bool preventAutoSleep() { return false; }
|
||||||
virtual bool isReaderActivity() const { return false; }
|
virtual bool isReaderActivity() const { return false; }
|
||||||
|
// True if this activity needs the BLE stack resident (beyond the readers, which are
|
||||||
|
// covered by isReaderActivity()). The Bluetooth settings screen overrides this so
|
||||||
|
// pairing/scanning works there. Everywhere else BLE is torn down to free heap.
|
||||||
|
virtual bool keepsBluetoothAlive() const { return false; }
|
||||||
|
// True while the current activity is doing heap-heavy work that must finish
|
||||||
|
// before the BLE stack (~52 KB) may start.
|
||||||
|
virtual bool deferBluetoothStart() const { return false; }
|
||||||
|
// Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather
|
||||||
|
// than a fast/partial one. Used after drawing a transient popup over grayscale content
|
||||||
|
// (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting.
|
||||||
|
virtual void requestGhostCleanup() {}
|
||||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||||
|
|
||||||
// Start a new activity without destroying the current one
|
// Start a new activity without destroying the current one
|
||||||
|
|||||||
@@ -19,12 +19,15 @@
|
|||||||
#include "settings/SettingsActivity.h"
|
#include "settings/SettingsActivity.h"
|
||||||
#include "util/FullScreenMessageActivity.h"
|
#include "util/FullScreenMessageActivity.h"
|
||||||
|
|
||||||
|
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
|
||||||
void ActivityManager::begin() {
|
void ActivityManager::begin() {
|
||||||
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||||
8192, // Stack size
|
8192, // Stack size
|
||||||
this, // Parameters
|
this, // Parameters
|
||||||
1, // Priority
|
1, // Priority
|
||||||
&renderTaskHandle // Task handle
|
&renderTaskHandle, // Task handle
|
||||||
|
0 // Pin to core 0 (PRO_CPU)
|
||||||
);
|
);
|
||||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||||
}
|
}
|
||||||
@@ -46,10 +49,10 @@ void ActivityManager::renderTaskLoop() {
|
|||||||
}
|
}
|
||||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||||
TaskHandle_t waiter = nullptr;
|
TaskHandle_t waiter = nullptr;
|
||||||
taskENTER_CRITICAL(nullptr);
|
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||||
waiter = waitingTaskHandle;
|
waiter = waitingTaskHandle;
|
||||||
waitingTaskHandle = nullptr;
|
waitingTaskHandle = nullptr;
|
||||||
taskEXIT_CRITICAL(nullptr);
|
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||||
if (waiter) {
|
if (waiter) {
|
||||||
xTaskNotify(waiter, 1, eIncrement);
|
xTaskNotify(waiter, 1, eIncrement);
|
||||||
}
|
}
|
||||||
@@ -137,8 +140,7 @@ void ActivityManager::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedUpdate) {
|
if (requestedUpdate.exchange(false)) {
|
||||||
requestedUpdate = false;
|
|
||||||
// Using direct notification to signal the render task to update
|
// Using direct notification to signal the render task to update
|
||||||
// Increment counter so multiple rapid calls won't be lost
|
// Increment counter so multiple rapid calls won't be lost
|
||||||
if (renderTaskHandle) {
|
if (renderTaskHandle) {
|
||||||
@@ -254,6 +256,25 @@ bool ActivityManager::isReaderActivity() const {
|
|||||||
(currentActivity && currentActivity->isReaderActivity());
|
(currentActivity && currentActivity->isReaderActivity());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::currentKeepsBluetoothAlive() const {
|
||||||
|
return currentActivity && currentActivity->keepsBluetoothAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActivityManager::requestGhostCleanup() {
|
||||||
|
if (currentActivity) currentActivity->requestGhostCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::bluetoothShouldBeActive() const {
|
||||||
|
const auto wants = [](const auto& activity) {
|
||||||
|
return activity && (activity->isReaderActivity() || activity->keepsBluetoothAlive());
|
||||||
|
};
|
||||||
|
return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::bluetoothStartDeferred() const {
|
||||||
|
return currentActivity && currentActivity->deferBluetoothStart();
|
||||||
|
}
|
||||||
|
|
||||||
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
||||||
|
|
||||||
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
|
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
|
||||||
@@ -280,7 +301,7 @@ void ActivityManager::requestUpdateAndWait() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atomic section to perform checks
|
// Atomic section to perform checks
|
||||||
taskENTER_CRITICAL(nullptr);
|
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||||
@@ -289,7 +310,7 @@ void ActivityManager::requestUpdateAndWait() {
|
|||||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||||
waitingTaskHandle = currTaskHandler;
|
waitingTaskHandle = currTaskHandler;
|
||||||
}
|
}
|
||||||
taskEXIT_CRITICAL(nullptr);
|
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||||
|
|
||||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -63,7 +64,7 @@ class ActivityManager {
|
|||||||
|
|
||||||
// Whether to trigger a render after the current loop()
|
// Whether to trigger a render after the current loop()
|
||||||
// This variable must only be set by the main loop, to avoid race conditions
|
// This variable must only be set by the main loop, to avoid race conditions
|
||||||
bool requestedUpdate = false;
|
std::atomic<bool> requestedUpdate{false};
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
@@ -101,6 +102,16 @@ class ActivityManager {
|
|||||||
|
|
||||||
bool preventAutoSleep() const;
|
bool preventAutoSleep() const;
|
||||||
bool isReaderActivity() const;
|
bool isReaderActivity() const;
|
||||||
|
bool currentKeepsBluetoothAlive() const;
|
||||||
|
// True if BLE should be resident for the current context: any reader (page-turner
|
||||||
|
// input) or the Bluetooth settings screen (pairing) is on the stack.
|
||||||
|
bool bluetoothShouldBeActive() const;
|
||||||
|
// True while the CURRENT activity is mid heap-heavy work that must complete before
|
||||||
|
// NimBLE may start (see Activity::deferBluetoothStart). Current only, not the
|
||||||
|
// stack: a reader stacked under a menu has its loop() paused, so its build never
|
||||||
|
// advances — a stack-wide check would hold BLE off for as long as the menu stays
|
||||||
|
// open.
|
||||||
|
bool bluetoothStartDeferred() const;
|
||||||
bool skipLoopDelay() const;
|
bool skipLoopDelay() const;
|
||||||
ScreenshotInfo getScreenshotInfo() const;
|
ScreenshotInfo getScreenshotInfo() const;
|
||||||
|
|
||||||
@@ -108,6 +119,9 @@ class ActivityManager {
|
|||||||
// Otherwise, it will be deferred until the end of the current loop iteration.
|
// Otherwise, it will be deferred until the end of the current loop iteration.
|
||||||
void requestUpdate(bool immediate = false);
|
void requestUpdate(bool immediate = false);
|
||||||
|
|
||||||
|
// Ask the current activity to make its next render a ghost-cleanup (HALF) refresh.
|
||||||
|
void requestGhostCleanup();
|
||||||
|
|
||||||
// Trigger a render and block until it completes.
|
// Trigger a render and block until it completes.
|
||||||
// Must NOT be called from the render task or while holding a RenderLock.
|
// Must NOT be called from the render task or while holding a RenderLock.
|
||||||
void requestUpdateAndWait();
|
void requestUpdateAndWait();
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ struct PageResult {
|
|||||||
struct ProgressChangeResult {
|
struct ProgressChangeResult {
|
||||||
int spineIndex = 0;
|
int spineIndex = 0;
|
||||||
int page = 0;
|
int page = 0;
|
||||||
|
int totalPages = 0;
|
||||||
|
std::string xpath;
|
||||||
|
float percentage = 0.0f;
|
||||||
|
bool hasSavedProgress = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class NetworkMode;
|
enum class NetworkMode;
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||||
@@ -219,13 +219,11 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
// OEM grayscale pipeline base: on X3 this displays the frame with the
|
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
|
||||||
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
|
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
|
||||||
// in the calibrated state the gray nudge refresh expects; on X4 it is a
|
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
|
||||||
// plain HALF refresh (previous behavior).
|
|
||||||
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
|
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
@@ -333,5 +331,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
|
|||||||
|
|
||||||
void SleepActivity::renderBlankSleepScreen() const {
|
void SleepActivity::renderBlankSleepScreen() const {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ std::string getFileName(std::string filename) {
|
|||||||
return filename.substr(0, pos);
|
return filename.substr(0, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string getFileExtension(std::string filename) {
|
std::string getFileExtension(const std::string& filename) {
|
||||||
if (filename.back() == '/') {
|
if (filename.back() == '/') {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user