Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e12083566 | ||
|
|
cf4f7be8af | ||
|
|
e69241ef54 | ||
|
|
72b99a2a9c | ||
|
|
62448b3c08 | ||
|
|
3e1dd31e53 | ||
|
|
685d4e88f9 | ||
|
|
79b4d63ee2 | ||
|
|
57447a56a6 | ||
|
|
5c86bfe1fd | ||
|
|
4cdefb137c | ||
|
|
e7f3406704 | ||
|
|
6f08a88b12 | ||
|
|
e4d3981469 | ||
|
|
c9d27826f9 | ||
|
|
13bd00bf9d | ||
|
|
fef39da23c | ||
|
|
fd43ca2fe1 | ||
|
|
28255061fb | ||
|
|
43ff1b6644 | ||
|
|
2754a5ff01 | ||
|
|
6edeed73c5 | ||
|
|
8ac302018c | ||
|
|
a11517a62d | ||
|
|
cbaa498ccc | ||
|
|
ebebc6f202 | ||
|
|
970b2c6ca1 | ||
|
|
b6ce599b20 | ||
|
|
f54eab2725 | ||
|
|
0d4c9ab91b | ||
|
|
0daa9db243 | ||
|
|
a2f2eea79e | ||
|
|
86a9b9c4a2 | ||
|
|
ba44978ac4 | ||
|
|
555f76da88 | ||
|
|
0a57c0a5a7 | ||
|
|
a09aef0889 | ||
|
|
8626d69f46 | ||
|
|
fc89e57e69 | ||
|
|
487613b082 | ||
|
|
8d5b119644 | ||
|
|
7271c00d35 | ||
|
|
6e8dbd7f23 | ||
|
|
c4b1d9644a | ||
|
|
f6e59aab72 | ||
|
|
9ad2da0950 | ||
|
|
1db1442319 | ||
|
|
362dcb2a65 | ||
|
|
d1abcc00a2 | ||
|
|
0ce874f8d4 | ||
|
|
282514f755 | ||
|
|
d1e4650e19 | ||
|
|
370f87ea01 | ||
|
|
8b9a73a735 | ||
|
|
29c69b3d8f | ||
|
|
4b5a84dd81 | ||
|
|
b9874c8114 | ||
|
|
b1131795d8 | ||
|
|
5e990b3991 | ||
|
|
ff1951c715 | ||
|
|
121c0690b0 | ||
|
|
16b0853654 | ||
|
|
7d639cf880 | ||
|
|
22f3575064 | ||
|
|
d4069aeae5 | ||
|
|
54d5a788a5 | ||
|
|
f66220a245 | ||
|
|
2266060e84 | ||
|
|
90039d7f4d | ||
|
|
16eb66d7cf | ||
|
|
02bab00be6 | ||
|
|
55a56914d7 |
+4
-3
@@ -1,3 +1,4 @@
|
||||
[submodule "open-x4-sdk"]
|
||||
path = open-x4-sdk
|
||||
url = https://github.com/crosspoint-reader/community-sdk.git
|
||||
[submodule "freeink-sdk"]
|
||||
path = freeink-sdk
|
||||
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).
|
||||
* 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.
|
||||
* 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).
|
||||
* 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).
|
||||
@@ -127,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
||||
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
||||
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
||||
* 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
|
||||
|
||||
### Hardware Abstraction Layer (HAL)
|
||||
|
||||
@@ -67,10 +67,6 @@ USB port or browser before assuming the device is locked. Only reach for the unl
|
||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
||||
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
||||
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
||||
>
|
||||
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
|
||||
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
|
||||
> Papyrix (or any other unsupported firmware) on a locked device.**
|
||||
|
||||
## Install firmware
|
||||
|
||||
@@ -249,7 +245,7 @@ One of the best things about open source is that anyone can take the code in a d
|
||||
|
||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||
|
||||
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
||||
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
|
||||
|
||||
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
||||
|
||||
|
||||
+54
-11
@@ -5,6 +5,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [CrossPoint User Guide](#crosspoint-user-guide)
|
||||
- [1. Hardware Overview](#1-hardware-overview)
|
||||
- [Button Layout](#button-layout)
|
||||
- [Taking a Screenshot](#taking-a-screenshot)
|
||||
- [2. Power \& Startup](#2-power--startup)
|
||||
- [Power On / Off](#power-on--off)
|
||||
- [First Launch](#first-launch)
|
||||
@@ -15,6 +16,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
||||
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
|
||||
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
|
||||
- [Uploading Books](#uploading-books)
|
||||
- [Removing a Book](#removing-a-book)
|
||||
- [3.6 Settings](#36-settings)
|
||||
- [3.6.1 Display](#361-display)
|
||||
- [3.6.2 Reader](#362-reader)
|
||||
@@ -23,7 +28,11 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [Cover settings](#cover-settings)
|
||||
- [Custom images](#custom-images)
|
||||
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
@@ -36,8 +45,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [5. Reader Menu](#5-reader-menu)
|
||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||
- [5.2 Bookmarks](#52-bookmarks)
|
||||
- [6. Current Limitations & Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
|
||||
## 1. Hardware Overview
|
||||
|
||||
@@ -122,19 +131,43 @@ A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined
|
||||
|
||||
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
|
||||
|
||||
1. Install the plugin in Calibre:
|
||||
#### Installing the Plugin in Calibre
|
||||
|
||||
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
|
||||
If you don't already have the plugin installed:
|
||||
|
||||
- Download the zip file.
|
||||
1. Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
|
||||
2. Download the zip file.
|
||||
3. Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
||||
4. Restart Calibre.
|
||||
|
||||
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
||||
#### Configuring the CrossPoint Plugin in Calibre
|
||||
1. In Calibre select Preferences.
|
||||
2. In the Preferences dialog select Plugins.
|
||||
3. In Plugins search for "crosspoint".
|
||||
4. Click on "Customize plugin".
|
||||
5. Update the value for "Host" to match the IP for your device.
|
||||
6. Leave the other settings as they are.
|
||||
7. [optional] Modify the "Upload path" to point to a subfolder other than the root "/" folder. Enter this as a path relative to the root folder. Example: `/mybooks`
|
||||
8. Restart Calibre.
|
||||
|
||||
2. On the device: File Transfer -> Calibre Wireless, then join a network.
|
||||
<img width="420" height="385" alt="Image" src="https://github.com/user-attachments/assets/01fc7e33-a9a7-48ba-9e26-2e68d1f9daec" />
|
||||
|
||||
3. Make sure your computer is on the same Wi-Fi network.
|
||||
#### Uploading Books
|
||||
|
||||
4. In Calibre, click "Send to device" to transfer books.
|
||||
To upload a book using the CrossPoint plugin in Calibre:
|
||||
|
||||
1. On the device: File Transfer -> Calibre Wireless, then join a network.
|
||||
2. Select one or more books.
|
||||
3. Right-click on that selection.
|
||||
4. Select "Send to Device" > "Send to main memory"
|
||||
|
||||
The CrossPoint plugin will connect to your device, create a folder for the book's author in the root folder (or the folder you configured for the plugin), then copy the book into that folder.
|
||||
|
||||
<img width="783" height="310" alt="Image" src="https://github.com/user-attachments/assets/741b0909-2e1d-4f16-8af0-2c43fbda5ce6" />
|
||||
|
||||
#### Removing a Book
|
||||
|
||||
Books cannot be removed from your device through Calibre. Use the web interface instead.
|
||||
|
||||
### 3.6 Settings
|
||||
|
||||
@@ -150,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)
|
||||
- "None" - A blank screen
|
||||
- "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:
|
||||
|
||||
@@ -162,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
|
||||
- "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:
|
||||
|
||||
- "None" - No status bar
|
||||
@@ -241,6 +277,10 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
|
||||
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
|
||||
- "Page Scroll" - Long-pressing scrolls a page up/down
|
||||
- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal:
|
||||
- "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page.
|
||||
- "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly.
|
||||
- "Disabled" - Long-press is ignored; only short-press opens the reader menu.
|
||||
|
||||
- **Short Power Button Click**: Controls the effect of a short click of the power button:
|
||||
|
||||
@@ -523,11 +563,14 @@ On the **Xteink X3**, the gyroscope can be used to turn pages by tilting the dev
|
||||
|
||||
When reading an EPUB that contains footnotes, you can navigate to the footnote text by selecting the footnote reference in the book. From the footnote, you can return to your original reading position.
|
||||
|
||||
If the device goes to sleep or you close the book while viewing a footnote, the book reopens to your original reading position, not the footnote.
|
||||
|
||||
### System Navigation
|
||||
|
||||
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
||||
* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen.
|
||||
* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more.
|
||||
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu.
|
||||
|
||||
### Supported Languages
|
||||
|
||||
@@ -574,9 +617,9 @@ Accessible by selecting **Chapters** from the Reader Menu.
|
||||
|
||||
Bookmarks can be created to quickly save and restore your place in a book.
|
||||
|
||||
To create a bookmark, hold **Confirm** for 1 second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
|
||||
To create a bookmark, hold **Confirm** for about half a second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
|
||||
|
||||
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for 1 second, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
|
||||
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for about 0.7 seconds, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
|
||||
|
||||
Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
.DESCRIPTION
|
||||
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).
|
||||
|
||||
The clang-format binary path is resolved once and cached in
|
||||
@@ -92,7 +92,7 @@ function Resolve-ClangFormat {
|
||||
$clangFormat = Resolve-ClangFormat
|
||||
|
||||
$exclude = @(
|
||||
'open-x4-sdk'
|
||||
'freeink-sdk'
|
||||
'lib\EpdFont\builtinFonts'
|
||||
'lib\Epub\Epub\hyphenation\generated'
|
||||
'lib\uzlib'
|
||||
|
||||
@@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite
|
||||
|
||||
```mermaid
|
||||
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]
|
||||
C --> D[src/main.cpp runtime loop]
|
||||
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/network/`: web server and OTA/update networking
|
||||
- `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/`: 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
|
||||
|
||||
## Embedded constraints that shape design
|
||||
|
||||
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
|
||||
|
||||
## `section.bin`
|
||||
|
||||
### Version 25
|
||||
### Version 28
|
||||
|
||||
Each file in `sections/*.bin` stores one laid-out spine section. The header is
|
||||
also the cache-busting key: if any layout-affecting setting differs from the
|
||||
current reader settings, the section is discarded and rebuilt.
|
||||
|
||||
Version 25 includes:
|
||||
Version 28 includes:
|
||||
|
||||
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
||||
image rendering mode, and Focus Reading
|
||||
@@ -105,6 +105,8 @@ Version 25 includes:
|
||||
- paragraph and list-item LUTs used by KOReader sync page refinement
|
||||
- optional per-word Focus Reading split metadata
|
||||
- per-page footnote entries
|
||||
- serialized word style bits for underline, strikethrough, superscript, and
|
||||
subscript
|
||||
|
||||
ImHex pattern:
|
||||
|
||||
@@ -113,7 +115,7 @@ import std.mem;
|
||||
import std.string;
|
||||
import std.core;
|
||||
|
||||
#define EXPECTED_VERSION 25
|
||||
#define EXPECTED_VERSION 28
|
||||
#define MAX_STRING_LENGTH 65535
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Submodule
+1
Submodule freeink-sdk added at f611d71d8e
@@ -101,6 +101,9 @@ static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t
|
||||
}
|
||||
|
||||
int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const {
|
||||
if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) {
|
||||
return 0;
|
||||
}
|
||||
if (!data->kernMatrix) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "EpdFontFamily.h"
|
||||
|
||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
||||
// Extract font style bits; render-time overlay bits do not affect font selection.
|
||||
const bool hasBold = (style & BOLD) != 0;
|
||||
const bool hasItalic = (style & ITALIC) != 0;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class EpdFontFamily {
|
||||
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
||||
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
|
||||
};
|
||||
static constexpr uint8_t TEXT_DECORATION_MASK = static_cast<uint8_t>(UNDERLINE | STRIKETHROUGH);
|
||||
|
||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||
const EpdFont* boldItalic = nullptr)
|
||||
@@ -27,6 +28,9 @@ class EpdFontFamily {
|
||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
||||
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
|
||||
static constexpr bool hasTextDecoration(const Style style) {
|
||||
return (static_cast<uint8_t>(style) & TEXT_DECORATION_MASK) != 0;
|
||||
}
|
||||
|
||||
private:
|
||||
const EpdFont* regular;
|
||||
|
||||
+61
-21
@@ -115,6 +115,9 @@ void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
freeStyleMiniData(s);
|
||||
delete[] s.fullIntervals;
|
||||
s.fullIntervals = nullptr;
|
||||
delete[] s.bmpIntervals;
|
||||
s.bmpIntervals = nullptr;
|
||||
s.intervalsAreBmp16 = false;
|
||||
freeStyleKernLigatureData(s);
|
||||
s.present = false;
|
||||
}
|
||||
@@ -516,38 +519,33 @@ bool SdCardFont::load(const char* path) {
|
||||
styleCount_ = styleCount;
|
||||
contentHash_ = hash;
|
||||
|
||||
// Load full intervals into RAM for each present style
|
||||
// Load full intervals into RAM for each present style. BMP-only fonts with
|
||||
// fewer than 65536 glyphs use a compact 6-byte interval table instead of the
|
||||
// on-disk 12-byte table; large sparse CJK subsets otherwise keep tens of KB
|
||||
// of always-resident heap just for lookup metadata.
|
||||
for (uint8_t i = 0; i < MAX_STYLES; i++) {
|
||||
auto& s = styles_[i];
|
||||
if (!s.present) continue;
|
||||
|
||||
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
|
||||
if (!s.fullIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.seekSet(s.intervalsFileOffset)) {
|
||||
LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
|
||||
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate interval contents before any later code (findGlobalGlyphIndex,
|
||||
// glyph reads) trusts them. A malformed file could otherwise drive
|
||||
// out-of-range glyph indices into bogus on-disk reads.
|
||||
{
|
||||
bool canUseBmp16 = s.header.glyphCount <= UINT16_MAX;
|
||||
uint32_t expectedOffset = 0;
|
||||
uint32_t prevLast = 0;
|
||||
EpdUnicodeInterval iv{};
|
||||
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
|
||||
const auto& iv = s.fullIntervals[j];
|
||||
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
|
||||
LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
if (iv.first > iv.last) {
|
||||
LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j,
|
||||
static_cast<unsigned long>(iv.first), static_cast<unsigned long>(iv.last));
|
||||
@@ -567,9 +565,49 @@ bool SdCardFont::load(const char* path) {
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) {
|
||||
canUseBmp16 = false;
|
||||
}
|
||||
expectedOffset += span;
|
||||
prevLast = iv.last;
|
||||
}
|
||||
|
||||
if (!file.seekSet(s.intervalsFileOffset)) {
|
||||
LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (canUseBmp16) {
|
||||
s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount];
|
||||
if (!s.bmpIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
|
||||
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
|
||||
LOG_ERR("SDCF", "Failed to read compact interval %u for style %u", j, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
s.bmpIntervals[j] = {static_cast<uint16_t>(iv.first), static_cast<uint16_t>(iv.last),
|
||||
static_cast<uint16_t>(iv.offset)};
|
||||
}
|
||||
s.intervalsAreBmp16 = true;
|
||||
} else {
|
||||
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
|
||||
if (!s.fullIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
|
||||
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize stub data
|
||||
@@ -603,13 +641,15 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint)
|
||||
int right = static_cast<int>(s.header.intervalCount) - 1;
|
||||
while (left <= right) {
|
||||
int mid = left + (right - left) / 2;
|
||||
const auto& interval = s.fullIntervals[mid];
|
||||
if (codepoint < interval.first) {
|
||||
const uint32_t first = s.intervalsAreBmp16 ? s.bmpIntervals[mid].first : s.fullIntervals[mid].first;
|
||||
const uint32_t last = s.intervalsAreBmp16 ? s.bmpIntervals[mid].last : s.fullIntervals[mid].last;
|
||||
if (codepoint < first) {
|
||||
right = mid - 1;
|
||||
} else if (codepoint > interval.last) {
|
||||
} else if (codepoint > last) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
return static_cast<int32_t>(interval.offset + (codepoint - interval.first));
|
||||
const uint32_t offset = s.intervalsAreBmp16 ? s.bmpIntervals[mid].offset : s.fullIntervals[mid].offset;
|
||||
return static_cast<int32_t>(offset + (codepoint - first));
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
@@ -1257,7 +1297,7 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) {
|
||||
|
||||
if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr;
|
||||
const auto& s = self->styles_[styleIdx];
|
||||
if (!s.fullIntervals) return nullptr;
|
||||
if (!s.fullIntervals && !s.bmpIntervals) return nullptr;
|
||||
|
||||
// Check overflow cache first (matching both codepoint and style)
|
||||
for (uint32_t i = 0; i < self->overflowCount_; i++) {
|
||||
|
||||
@@ -58,9 +58,9 @@ class SdCardFont {
|
||||
// Returns true if advance table is populated for at least one style.
|
||||
bool hasAdvanceTable() const;
|
||||
|
||||
// Free mini data for all styles, restore stub EpdFontData.
|
||||
// Also clears the temporary advance table (built per layout pass) but
|
||||
// preserves the persistent advance cache (reused across passes).
|
||||
// Free mini data for all styles and restore stub EpdFontData.
|
||||
// Preserves the persistent advance cache so repeated layout passes can reuse
|
||||
// previously fetched metrics.
|
||||
void clearCache();
|
||||
|
||||
// Drop the persistent advance cache. Call when unloading the SD font or
|
||||
@@ -140,6 +140,14 @@ class SdCardFont {
|
||||
|
||||
// Full intervals loaded from file (kept in RAM for codepoint lookup)
|
||||
EpdUnicodeInterval* fullIntervals = nullptr;
|
||||
struct BmpInterval16 {
|
||||
uint16_t first;
|
||||
uint16_t last;
|
||||
uint16_t offset;
|
||||
} __attribute__((packed));
|
||||
static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact");
|
||||
BmpInterval16* bmpIntervals = nullptr;
|
||||
bool intervalsAreBmp16 = false;
|
||||
|
||||
// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
|
||||
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
|
||||
|
||||
@@ -34,19 +34,15 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender
|
||||
unloadAll(renderer);
|
||||
}
|
||||
|
||||
// Select by ordinal position: sort available sizes, then map the font size
|
||||
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the
|
||||
// family has fewer sizes than 4, clamp to the last available size.
|
||||
auto sizes = family.availableSizes();
|
||||
if (sizes.empty()) {
|
||||
// Select the physical point size closest to the built-in reader sizes. Some
|
||||
// CJK font packs only ship larger sizes, so ordinal selection can make
|
||||
// MEDIUM load 18pt+ and produce oversized pages on small devices.
|
||||
const SdCardFontFileInfo* selected = family.findClosestReaderSize(fontSizeEnum);
|
||||
if (!selected) {
|
||||
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t idx = fontSizeEnum;
|
||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
||||
const SdCardFontFileInfo* selected = family.findFile(sizes[idx]);
|
||||
|
||||
auto* font = new (std::nothrow) SdCardFont();
|
||||
if (!font) {
|
||||
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str());
|
||||
|
||||
@@ -15,10 +15,10 @@ class SdCardFontManager {
|
||||
SdCardFontManager(const SdCardFontManager&) = delete;
|
||||
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
|
||||
|
||||
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by
|
||||
// ordinal position in the family's sorted size list. Only one .cpfont file
|
||||
// is loaded; other sizes remain on disk. This keeps resident interval +
|
||||
// kern/ligature tables to one size's worth of memory.
|
||||
// Load the font file whose physical point size is closest to the reader
|
||||
// fontSizeEnum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18). Only one
|
||||
// .cpfont file is loaded; other sizes remain on disk. This keeps resident
|
||||
// interval + kern/ligature tables to one size's worth of memory.
|
||||
// Returns true on success.
|
||||
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
|
||||
|
||||
@@ -32,7 +32,7 @@ class SdCardFontManager {
|
||||
// Get name of currently loaded family (empty if none).
|
||||
const std::string& currentFamilyName() const { return loadedFamilyName_; };
|
||||
|
||||
// Point size that was actually loaded (closest match to targetPtSize).
|
||||
// Point size that was actually loaded.
|
||||
// 0 if nothing loaded.
|
||||
uint8_t currentPointSize() const { return loadedPointSize_; };
|
||||
|
||||
|
||||
@@ -15,6 +15,60 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t s
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const SdCardFontFileInfo* SdCardFontFamilyInfo::findClosestReaderSize(const uint8_t fontSizeEnum,
|
||||
const uint8_t style) const {
|
||||
if (files.empty()) return nullptr;
|
||||
|
||||
// Collect sizes matching the requested style, sorted ascending.
|
||||
std::vector<uint8_t> sizes;
|
||||
for (const auto& f : files) {
|
||||
if (f.style != style) continue;
|
||||
sizes.push_back(f.pointSize);
|
||||
}
|
||||
if (sizes.empty()) return nullptr;
|
||||
std::sort(sizes.begin(), sizes.end());
|
||||
|
||||
// When the family provides at least 4 sizes, use ordinal (index-based)
|
||||
// selection so custom-built font sets (e.g. 10/12/14/16) map SMALL to
|
||||
// the smallest file, not to a hardcoded 12pt target.
|
||||
if (sizes.size() >= 4) {
|
||||
uint8_t idx = fontSizeEnum;
|
||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
||||
return findFile(sizes[idx], style);
|
||||
}
|
||||
|
||||
// Fewer sizes than enum slots (e.g. CJK packs with only 2-3 sizes):
|
||||
// fall back to closest-match against the built-in reader targets.
|
||||
uint8_t target = 14;
|
||||
switch (fontSizeEnum) {
|
||||
case 0:
|
||||
target = 12;
|
||||
break;
|
||||
case 2:
|
||||
target = 16;
|
||||
break;
|
||||
case 3:
|
||||
target = 18;
|
||||
break;
|
||||
case 1:
|
||||
default:
|
||||
target = 14;
|
||||
break;
|
||||
}
|
||||
|
||||
const SdCardFontFileInfo* best = nullptr;
|
||||
uint8_t bestDelta = 255;
|
||||
for (const auto& f : files) {
|
||||
if (f.style != style) continue;
|
||||
const uint8_t delta = f.pointSize > target ? f.pointSize - target : target - f.pointSize;
|
||||
if (!best || delta < bestDelta || (delta == bestDelta && f.pointSize < best->pointSize)) {
|
||||
best = &f;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
|
||||
for (const auto& f : files) {
|
||||
if (f.pointSize == size) return true;
|
||||
|
||||
@@ -18,6 +18,7 @@ struct SdCardFontFamilyInfo {
|
||||
std::vector<SdCardFontFileInfo> files;
|
||||
|
||||
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
|
||||
const SdCardFontFileInfo* findClosestReaderSize(uint8_t fontSizeEnum, uint8_t style = 0) const;
|
||||
bool hasSize(uint8_t size) const;
|
||||
std::vector<uint8_t> availableSizes() const;
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import socket
|
||||
import urllib.request
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -49,17 +50,44 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
|
||||
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path."""
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
||||
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
|
||||
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path.
|
||||
|
||||
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
|
||||
on a different mirror each request; a mirror may advertise an IPv6 address a
|
||||
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
|
||||
Retry on failure, forcing IPv4 resolution after the first attempt.
|
||||
"""
|
||||
if dest.exists():
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f" Downloading {dest.name}...")
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
force_ipv4 = attempt > 1
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except Exception as e:
|
||||
break
|
||||
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
|
||||
last_err = e
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||
if attempt < retries:
|
||||
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
|
||||
finally:
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _orig_getaddrinfo
|
||||
else:
|
||||
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
|
||||
size_kb = dest.stat().st_size / 1024
|
||||
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
||||
return dest
|
||||
|
||||
@@ -248,7 +248,7 @@ unmerged_intervals = sorted(intervals + add_ints)
|
||||
intervals = []
|
||||
unvalidated_intervals = []
|
||||
for i_start, i_end in unmerged_intervals:
|
||||
if len(unvalidated_intervals) > 0 and i_start + 1 <= unvalidated_intervals[-1][1]:
|
||||
if len(unvalidated_intervals) > 0 and i_start <= unvalidated_intervals[-1][1] + 1:
|
||||
unvalidated_intervals[-1] = (unvalidated_intervals[-1][0], max(unvalidated_intervals[-1][1], i_end))
|
||||
continue
|
||||
unvalidated_intervals.append((i_start, i_end))
|
||||
|
||||
@@ -40,7 +40,8 @@ INTERVAL_PRESETS = {
|
||||
"ascii": [(0x0020, 0x007E)],
|
||||
"latin1": [(0x0080, 0x00FF)],
|
||||
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
|
||||
(0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)],
|
||||
(0x02B0, 0x02FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||
(0xFB00, 0xFB06)],
|
||||
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
|
||||
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
|
||||
"hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)],
|
||||
@@ -62,7 +63,7 @@ INTERVAL_PRESETS = {
|
||||
# Composite preset for English-language literary fiction including scifi/popsci.
|
||||
# Greek for physics terms, math operators, geometric shapes, uncommon
|
||||
# dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats.
|
||||
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
|
||||
"reading": [(0x0020, 0x024F), (0x02B0, 0x02FF), (0x0300, 0x036F), (0x0370, 0x03FF),
|
||||
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||
|
||||
@@ -133,6 +133,15 @@ families:
|
||||
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
|
||||
|
||||
- name: Vollkorn
|
||||
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
# ── Sans-serif ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
#include <PngToBmpConverter.h>
|
||||
#include <Utf8.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
@@ -73,8 +74,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const
|
||||
return false;
|
||||
}
|
||||
|
||||
// Grab data from opfParser into epub
|
||||
bookMetadata.title = opfParser.title;
|
||||
// Grab data from opfParser into epub. Normalize titles to NFC so NFD (combining
|
||||
// mark) text renders correctly — the device fonts have no mark positioning.
|
||||
bookMetadata.title = utf8ComposeNfc(opfParser.title);
|
||||
bookMetadata.author = opfParser.author;
|
||||
bookMetadata.language = opfParser.language;
|
||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <deque>
|
||||
@@ -9,7 +10,7 @@
|
||||
#include "FsHelpers.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t BOOK_CACHE_VERSION = 7;
|
||||
constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-composed
|
||||
constexpr char bookBinFile[] = "/book.bin";
|
||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
||||
@@ -364,7 +365,9 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
||||
}
|
||||
}
|
||||
|
||||
const TocEntry entry(title, href, anchor, level, spineIndex);
|
||||
// Compose the title to NFC at index time so the cache stores precomposed glyphs;
|
||||
// device fonts have no combining-mark positioning, so NFD titles render broken.
|
||||
const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex);
|
||||
writeTocEntry(tocFile, entry);
|
||||
tocCount++;
|
||||
}
|
||||
|
||||
+20
-3
@@ -6,6 +6,20 @@
|
||||
|
||||
#include <new>
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename Predicate>
|
||||
void renderFilteredPageElements(const std::vector<std::shared_ptr<PageElement>>& elements, GfxRenderer& renderer,
|
||||
const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) {
|
||||
for (const auto& element : elements) {
|
||||
if (predicate(*element)) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
@@ -93,9 +107,12 @@ std::unique_ptr<PageHorizontalRule> PageHorizontalRule::deserialize(HalFile& fil
|
||||
}
|
||||
|
||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; });
|
||||
}
|
||||
|
||||
void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset,
|
||||
[](const PageElement& element) { return element.getTag() == TAG_PageImage; });
|
||||
}
|
||||
|
||||
bool Page::serialize(HalFile& file) const {
|
||||
|
||||
@@ -88,6 +88,7 @@ class Page {
|
||||
}
|
||||
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
bool serialize(HalFile& file) const;
|
||||
static std::unique_ptr<Page> deserialize(HalFile& file);
|
||||
|
||||
|
||||
+254
-30
@@ -24,6 +24,7 @@ constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3;
|
||||
// Per-word: scan enough chars to see through leading neutrals (quotes, numbers)
|
||||
// before giving up. 64 is a hedge for pathological cases like long numeric tokens.
|
||||
constexpr int RTL_PER_WORD_PROBE_DEPTH = 64;
|
||||
constexpr size_t MIN_JUSTIFY_GAPS = 1;
|
||||
|
||||
// Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB.
|
||||
bool mayContainRtlBytes(const char* str) {
|
||||
@@ -57,6 +58,134 @@ uint32_t lastCodepoint(const std::string& word) {
|
||||
|
||||
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
|
||||
|
||||
bool isNoBreakBeforeCjkPunctuation(const uint32_t cp) {
|
||||
switch (cp) {
|
||||
case '.':
|
||||
case ',':
|
||||
case ':':
|
||||
case ';':
|
||||
case '!':
|
||||
case '?':
|
||||
case ')':
|
||||
case ']':
|
||||
case '}':
|
||||
case 0x00BB: // »
|
||||
case 0x2019: // ’
|
||||
case 0x201D: // ”
|
||||
case 0x3001: // 、
|
||||
case 0x3002: // 。
|
||||
case 0x3009: // 〉
|
||||
case 0x300B: // 》
|
||||
case 0x300D: // 」
|
||||
case 0x300F: // 』
|
||||
case 0x3011: // 】
|
||||
case 0x3015: // 〕
|
||||
case 0x3017: // 〗
|
||||
case 0x3019: // 〙
|
||||
case 0x301B: // 〛
|
||||
case 0xFF01: // !
|
||||
case 0xFF09: // )
|
||||
case 0xFF0C: // ,
|
||||
case 0xFF0E: // .
|
||||
case 0xFF1A: // :
|
||||
case 0xFF1B: // ;
|
||||
case 0xFF1F: // ?
|
||||
case 0xFF3D: // ]
|
||||
case 0xFF5D: // }
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isNoBreakAfterCjkPunctuation(const uint32_t cp) {
|
||||
switch (cp) {
|
||||
case '(':
|
||||
case '[':
|
||||
case '{':
|
||||
case 0x00AB: // «
|
||||
case 0x2018: // ‘
|
||||
case 0x201C: // “
|
||||
case 0x3008: // 〈
|
||||
case 0x300A: // 《
|
||||
case 0x300C: // 「
|
||||
case 0x300E: // 『
|
||||
case 0x3010: // 【
|
||||
case 0x3014: // 〔
|
||||
case 0x3016: // 〖
|
||||
case 0x3018: // 〘
|
||||
case 0x301A: // 〚
|
||||
case 0xFF08: // (
|
||||
case 0xFF3B: // [
|
||||
case 0xFF5B: // {
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool containsCjkBreakableCodepoint(const std::string& text) {
|
||||
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
while (*ptr) {
|
||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
if (utf8IsCjkBreakable(cp)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) {
|
||||
if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false;
|
||||
if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false;
|
||||
if (utf8IsCombiningMark(rightCp)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<size_t> cjkCharacterBreakByteOffsets(const std::string& text) {
|
||||
struct CodepointBoundary {
|
||||
uint32_t cp;
|
||||
size_t endOffset;
|
||||
};
|
||||
|
||||
std::vector<CodepointBoundary> codepoints;
|
||||
codepoints.reserve(text.size());
|
||||
bool hasCjkBreakable = false;
|
||||
|
||||
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
const auto* const start = ptr;
|
||||
while (*ptr) {
|
||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
if (cp == 0) break;
|
||||
if (utf8IsCjkBreakable(cp)) {
|
||||
hasCjkBreakable = true;
|
||||
}
|
||||
codepoints.push_back({cp, static_cast<size_t>(ptr - start)});
|
||||
}
|
||||
|
||||
if (!hasCjkBreakable || codepoints.size() < 2) return {};
|
||||
|
||||
std::vector<size_t> allowedOffsets;
|
||||
allowedOffsets.reserve(codepoints.size() - 1);
|
||||
for (size_t i = 0; i + 1 < codepoints.size(); ++i) {
|
||||
const uint32_t current = codepoints[i].cp;
|
||||
const uint32_t next = codepoints[i + 1].cp;
|
||||
if (!hasCjkBreakOpportunityBetween(current, next)) continue;
|
||||
allowedOffsets.push_back(codepoints[i].endOffset);
|
||||
}
|
||||
return allowedOffsets;
|
||||
}
|
||||
|
||||
int computeJustifyExtra(const int spareSpace, const size_t gapCount) {
|
||||
if (gapCount < MIN_JUSTIFY_GAPS || spareSpace <= 0) return 0;
|
||||
// Distribute the spare space evenly across gaps. Do NOT bail out to 0 when the
|
||||
// per-gap stretch is large: a sparse line (few words on a wide page) legitimately
|
||||
// needs big gaps to reach the margin. Returning 0 there disables justification for
|
||||
// that line, leaving it right-aligned (RTL) / left-aligned (LTR) — the mismatched
|
||||
// alignment bug. Match the un-capped behavior of the old code.
|
||||
return spareSpace / static_cast<int>(gapCount);
|
||||
}
|
||||
|
||||
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
|
||||
void stripSoftHyphensInPlace(std::string& word) {
|
||||
size_t pos = 0;
|
||||
@@ -125,6 +254,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
const bool attachToPrevious) {
|
||||
if (word.empty()) return;
|
||||
|
||||
// The device fonts carry no combining-mark positioning, so EPUB text stored in NFD
|
||||
// (a base letter followed by separate combining accents -- common for Vietnamese,
|
||||
// and used for many EPUB <h1> chapter headings) renders with the marks detached or
|
||||
// misplaced. Compose to NFC here, the single funnel every word passes through, so a
|
||||
// precomposed glyph is used instead. This runs once per word at layout time (the
|
||||
// result is cached in the section file) and is a cheap no-op for mark-free text.
|
||||
word = utf8ComposeNfc(word);
|
||||
|
||||
EpdFontFamily::Style baseStyle = fontStyle;
|
||||
if (underline) {
|
||||
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::UNDERLINE);
|
||||
@@ -132,12 +269,54 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) &&
|
||||
BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH);
|
||||
|
||||
const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore,
|
||||
const bool isFocusSuffix) {
|
||||
words.push_back(std::move(token));
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(continues);
|
||||
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||
wordIsFocusSuffix.push_back(isFocusSuffix);
|
||||
};
|
||||
|
||||
bool effectiveAttachToPrevious = attachToPrevious;
|
||||
bool effectiveNoSpaceBefore = false;
|
||||
if (attachToPrevious && !words.empty() &&
|
||||
hasCjkBreakOpportunityBetween(lastCodepoint(words.back()), firstCodepoint(word))) {
|
||||
effectiveAttachToPrevious = false;
|
||||
effectiveNoSpaceBefore = true;
|
||||
}
|
||||
|
||||
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
|
||||
bool firstToken = true;
|
||||
size_t tokenStart = 0;
|
||||
for (const size_t breakOffset : breakOffsets) {
|
||||
if (breakOffset <= tokenStart || breakOffset > word.size()) continue;
|
||||
pushToken(word.substr(tokenStart, breakOffset - tokenStart), firstToken ? effectiveAttachToPrevious : false,
|
||||
firstToken ? effectiveNoSpaceBefore : true, false);
|
||||
firstToken = false;
|
||||
tokenStart = breakOffset;
|
||||
}
|
||||
if (tokenStart < word.size()) {
|
||||
pushToken(word.substr(tokenStart), firstToken ? effectiveAttachToPrevious : false,
|
||||
firstToken ? effectiveNoSpaceBefore : true, false);
|
||||
}
|
||||
if (wordStartsRtl) {
|
||||
hasRtlWord = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (containsCjkBreakableCodepoint(word)) {
|
||||
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
|
||||
if (wordStartsRtl) {
|
||||
hasRtlWord = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
|
||||
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
|
||||
words.push_back(std::move(word));
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(attachToPrevious);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
|
||||
if (wordStartsRtl) {
|
||||
hasRtlWord = true;
|
||||
}
|
||||
@@ -166,17 +345,19 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
words.reserve(newCapacity);
|
||||
wordStyles.reserve(newCapacity);
|
||||
wordContinues.reserve(newCapacity);
|
||||
wordNoSpaceBefore.reserve(newCapacity);
|
||||
wordIsFocusSuffix.reserve(newCapacity);
|
||||
}
|
||||
|
||||
// Lambda helper to process and push individual sub-segments of the string
|
||||
// Use std::string_view to avoid heap allocations when slicing
|
||||
auto processSegment = [&](std::string_view segment, bool isWord, bool attach) {
|
||||
auto processSegment = [&](std::string_view segment, bool isWord, bool attach, bool noSpaceBefore) {
|
||||
if (!isWord) {
|
||||
// Punctuation and Numbers stay regular
|
||||
words.emplace_back(segment);
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(attach);
|
||||
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
} else {
|
||||
size_t charCount = 0;
|
||||
@@ -198,6 +379,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
words.emplace_back(segment);
|
||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||
wordContinues.push_back(attach);
|
||||
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
} else {
|
||||
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
|
||||
@@ -210,12 +392,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
words.emplace_back(segment.substr(0, splitByteOffset));
|
||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||
wordContinues.push_back(attach);
|
||||
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
|
||||
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
|
||||
words.emplace_back(segment.substr(splitByteOffset));
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(true);
|
||||
wordNoSpaceBefore.push_back(false);
|
||||
wordIsFocusSuffix.push_back(true);
|
||||
}
|
||||
}
|
||||
@@ -243,7 +427,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
|
||||
// Only the very first segment inherits the original attachToPrevious flag.
|
||||
// Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
|
||||
isFirstSegment ? effectiveNoSpaceBefore : false);
|
||||
|
||||
// Setup for the next segment
|
||||
segmentStart = currentCpStart;
|
||||
@@ -255,7 +440,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
// Process the final remaining segment
|
||||
size_t segmentLen = end - segmentStart;
|
||||
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
|
||||
isFirstSegment ? effectiveNoSpaceBefore : false);
|
||||
if (wordStartsRtl) {
|
||||
hasRtlWord = true;
|
||||
}
|
||||
@@ -324,14 +510,16 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
if (hyphenationEnabled) {
|
||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
|
||||
lineBreakIndices =
|
||||
computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
|
||||
} else {
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
|
||||
}
|
||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
|
||||
extractLine(i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, lineBreakIndices, processLine, renderer,
|
||||
fontId);
|
||||
}
|
||||
|
||||
// Remove consumed words so size() reflects only remaining words
|
||||
@@ -340,6 +528,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
words.erase(words.begin(), words.begin() + consumed);
|
||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
||||
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
||||
wordNoSpaceBefore.erase(wordNoSpaceBefore.begin(), wordNoSpaceBefore.begin() + consumed);
|
||||
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
|
||||
}
|
||||
}
|
||||
@@ -356,7 +545,8 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
}
|
||||
|
||||
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
|
||||
std::vector<bool>& noSpaceBeforeVec) {
|
||||
if (words.empty()) {
|
||||
return {};
|
||||
}
|
||||
@@ -395,7 +585,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
for (size_t j = i; j < totalWordCount; ++j) {
|
||||
// Add space before word j, unless it's the first word on the line or a continuation
|
||||
int gap = 0;
|
||||
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
|
||||
if (j > static_cast<size_t>(i) && noSpaceBeforeVec[j]) {
|
||||
gap = 0;
|
||||
} else if (j > static_cast<size_t>(i) && !continuesVec[j]) {
|
||||
gap =
|
||||
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
|
||||
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
|
||||
@@ -470,7 +662,8 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
// Builds break indices while opportunistically splitting the word that would overflow the current line.
|
||||
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||
const int pageWidth, std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec) {
|
||||
std::vector<bool>& continuesVec,
|
||||
std::vector<bool>& noSpaceBeforeVec) {
|
||||
const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId);
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
@@ -488,7 +681,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
const bool isFirstWord = currentIndex == lineStart;
|
||||
int spacing = 0;
|
||||
if (!isFirstWord && !continuesVec[currentIndex]) {
|
||||
if (!isFirstWord && noSpaceBeforeVec[currentIndex]) {
|
||||
spacing = 0;
|
||||
} else if (!isFirstWord && !continuesVec[currentIndex]) {
|
||||
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
|
||||
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
|
||||
} else if (!isFirstWord && continuesVec[currentIndex]) {
|
||||
@@ -618,6 +813,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
// line, while "kilometer" moves to the next line.
|
||||
// wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment.
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
||||
wordNoSpaceBefore.insert(wordNoSpaceBefore.begin() + wordIndex + 1, false);
|
||||
|
||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||
@@ -627,7 +823,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
}
|
||||
|
||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
|
||||
const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||
const GfxRenderer& renderer, const int fontId) {
|
||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||
@@ -660,7 +857,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
||||
// Count gaps: each word after the first creates a gap, unless it's a continuation
|
||||
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
|
||||
if (wordIdx > 0 && noSpaceBeforeVec[lastBreakAt + wordIdx]) {
|
||||
// Unicode break opportunity with no inserted Latin-style space. It is still
|
||||
// a stretchable gap for justified CJK/Korean text.
|
||||
actualGapCount++;
|
||||
} else if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
|
||||
actualGapCount++;
|
||||
totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]),
|
||||
firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]);
|
||||
@@ -689,8 +890,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
|
||||
// For justified text, compute per-gap extra to distribute remaining space evenly
|
||||
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1)
|
||||
? spareSpace / static_cast<int>(actualGapCount)
|
||||
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||
? computeJustifyExtra(spareSpace, actualGapCount)
|
||||
: 0;
|
||||
|
||||
// BiDi processing: reorder words with UAX#9 in full-line context.
|
||||
@@ -709,11 +910,13 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
reorderedStylesScratch.clear();
|
||||
reorderedWidthsScratch.clear();
|
||||
reorderedContinuesScratch.clear();
|
||||
reorderedNoSpaceBeforeScratch.clear();
|
||||
reorderedFocusSuffixScratch.clear();
|
||||
reorderedWordsScratch.reserve(visualOrderScratch.size());
|
||||
reorderedStylesScratch.reserve(visualOrderScratch.size());
|
||||
reorderedWidthsScratch.reserve(visualOrderScratch.size());
|
||||
reorderedContinuesScratch.reserve(visualOrderScratch.size());
|
||||
reorderedNoSpaceBeforeScratch.reserve(visualOrderScratch.size());
|
||||
reorderedFocusSuffixScratch.reserve(visualOrderScratch.size());
|
||||
|
||||
for (size_t i = 0; i < visualOrderScratch.size(); ++i) {
|
||||
@@ -740,6 +943,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
}
|
||||
reorderedContinuesScratch.push_back(continues);
|
||||
reorderedNoSpaceBeforeScratch.push_back(!continues && noSpaceBeforeVec[lastBreakAt + src]);
|
||||
}
|
||||
|
||||
int reorderedWordWidthSum = 0;
|
||||
@@ -747,7 +951,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
int reorderedNaturalGaps = 0;
|
||||
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
|
||||
reorderedWordWidthSum += reorderedWidthsScratch[wordIdx];
|
||||
if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) {
|
||||
if (wordIdx > 0 && reorderedNoSpaceBeforeScratch[wordIdx]) {
|
||||
// Unicode break opportunity with no inserted Latin-style space. It is still
|
||||
// a stretchable gap for justified CJK/Korean text.
|
||||
reorderedGapCount++;
|
||||
} else if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) {
|
||||
reorderedGapCount++;
|
||||
reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
|
||||
firstCodepoint(reorderedWordsScratch[wordIdx]),
|
||||
@@ -763,9 +971,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
|
||||
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
|
||||
const int reorderedJustifyExtra =
|
||||
(effectiveAlignment == CssTextAlign::Justify && !isLastLine && reorderedGapCount >= 1)
|
||||
? reorderedSpare / static_cast<int>(reorderedGapCount)
|
||||
const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||
? computeJustifyExtra(reorderedSpare, reorderedGapCount)
|
||||
: 0;
|
||||
|
||||
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||
@@ -799,13 +1006,18 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
int advance =
|
||||
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
||||
firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]);
|
||||
if (reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] &&
|
||||
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
|
||||
// no-break space must not receive justifyExtra, or the line over-stretches by one
|
||||
// gap and the last word is pushed past the right margin (issue #2185).
|
||||
if (wordIdx > 0 && reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] &&
|
||||
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
advance += reorderedJustifyExtra;
|
||||
}
|
||||
xpos += advance;
|
||||
} else if (wordIdx + 1 < reorderedWidthsScratch.size()) {
|
||||
int gap = renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
||||
const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1];
|
||||
int gap = nextNoSpace ? 0
|
||||
: renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
||||
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
|
||||
reorderedStylesScratch[wordIdx]);
|
||||
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
@@ -839,18 +1051,23 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
// Cross-boundary kerning for continuation words
|
||||
int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
||||
if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
|
||||
// wordIdx > 0: see the LTR branch — a leading no-break space is not a justifiable gap.
|
||||
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
|
||||
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
advance += justifyExtra;
|
||||
}
|
||||
xpos -= advance;
|
||||
} else {
|
||||
int gap = 0;
|
||||
bool nextNoSpace = false;
|
||||
if (wordIdx + 1 < lineWordCount) {
|
||||
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
|
||||
gap = nextNoSpace
|
||||
? 0
|
||||
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
||||
}
|
||||
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
gap += justifyExtra;
|
||||
}
|
||||
xpos -= gap;
|
||||
@@ -873,18 +1090,25 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
int advance = wordWidths[lastBreakAt + wordIdx];
|
||||
advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
||||
if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
|
||||
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
|
||||
// no-break space must not receive justifyExtra, or the line over-stretches by one
|
||||
// gap and the last word is pushed past the right margin (issue #2185).
|
||||
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
|
||||
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
advance += justifyExtra;
|
||||
}
|
||||
xpos += advance;
|
||||
} else {
|
||||
int gap = 0;
|
||||
bool nextNoSpace = false;
|
||||
if (wordIdx + 1 < lineWordCount) {
|
||||
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
|
||||
gap = nextNoSpace
|
||||
? 0
|
||||
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
||||
}
|
||||
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||
gap += justifyExtra;
|
||||
}
|
||||
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
|
||||
|
||||
@@ -15,7 +15,8 @@ class GfxRenderer;
|
||||
class ParsedText {
|
||||
std::vector<std::string> words;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous with no break
|
||||
std::vector<bool> wordNoSpaceBefore; // true = may break before token, but no synthetic space when joined
|
||||
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
@@ -27,18 +28,22 @@ class ParsedText {
|
||||
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
|
||||
std::vector<uint16_t> reorderedWidthsScratch;
|
||||
std::vector<bool> reorderedContinuesScratch;
|
||||
std::vector<bool> reorderedNoSpaceBeforeScratch;
|
||||
std::vector<bool> reorderedFocusSuffixScratch;
|
||||
std::vector<uint16_t> visualOrderScratch;
|
||||
|
||||
int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const;
|
||||
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
|
||||
std::vector<bool>& noSpaceBeforeVec);
|
||||
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
|
||||
std::vector<bool>& noSpaceBeforeVec);
|
||||
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
|
||||
const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
|
||||
int fontId);
|
||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||
|
||||
+488
-83
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "Epub/css/CssParser.h"
|
||||
@@ -10,33 +11,59 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 26;
|
||||
// v28: text decoration bits now include line-through in serialized wordStyles.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 28;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
// as unknown and clears -- so an incomplete file is never mistaken for a valid one.
|
||||
constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
|
||||
// Written when a build is suspended partway (reader exited or device slept mid-build).
|
||||
// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse
|
||||
// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile
|
||||
// accepts it so a resume shows those pages instantly; the reader extends it by
|
||||
// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION,
|
||||
// so finalized files are untouched by this feature; older firmware treats the sentinel
|
||||
// as an unknown version and rebuilds, which is a safe downgrade.
|
||||
constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t);
|
||||
|
||||
struct PageLutEntry {
|
||||
uint32_t fileOffset;
|
||||
uint16_t paragraphIndex;
|
||||
uint16_t listItemIndex;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
|
||||
// constructed/destroyed where the parser's full definition is visible.
|
||||
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
: epub(epub),
|
||||
spineIndex(spineIndex),
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
|
||||
// Suspend any in-progress build so every section.reset() / navigation / sleep path
|
||||
// persists the pages already laid out as a partial .bin instead of discarding them
|
||||
// (no-op once a build has completed or never started).
|
||||
Section::~Section() { suspendBuild(); }
|
||||
|
||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
if (!file) {
|
||||
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
||||
LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t position = file.position();
|
||||
if (!page->serialize(file)) {
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
|
||||
return 0;
|
||||
}
|
||||
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
|
||||
|
||||
pageCount++;
|
||||
builtPageCount_++;
|
||||
// pageCount is the pages available to read: a rebuild over a partial only raises it
|
||||
// once it has laid out more pages than the partial already covers.
|
||||
if (builtPageCount_ > pageCount) {
|
||||
pageCount = builtPageCount_;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
@@ -55,7 +82,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
// Written as the incomplete sentinel; finalizeBuild() patches it to
|
||||
// SECTION_FILE_VERSION as the last step, committing the file.
|
||||
serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
serialization::writePod(file, lineCompression);
|
||||
serialization::writePod(file, extraParagraphSpacing);
|
||||
@@ -82,16 +111,18 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
}
|
||||
|
||||
// Match parameters
|
||||
bool filePartial = false;
|
||||
{
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||
|
||||
int fileFontId;
|
||||
uint16_t fileViewportWidth, fileViewportHeight;
|
||||
@@ -126,14 +157,42 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
}
|
||||
|
||||
serialization::readPod(file, pageCount);
|
||||
|
||||
if (filePartial) {
|
||||
// A partial's pageCount is the watermark of a suspended build. Read the watermark
|
||||
// trailer (appended after the li LUT) so estimatedTotalPages can extrapolate.
|
||||
uint32_t liLutOffset = 0;
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t));
|
||||
serialization::readPod(file, liLutOffset);
|
||||
const uint32_t trailerOffset = liLutOffset + static_cast<uint32_t>(pageCount) * sizeof(uint16_t);
|
||||
const bool trailerValid =
|
||||
pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size();
|
||||
if (!trailerValid) {
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: malformed partial section");
|
||||
clearCache();
|
||||
pageCount = 0;
|
||||
return false;
|
||||
}
|
||||
file.seek(trailerOffset);
|
||||
serialization::readPod(file, partialBytesConsumed_);
|
||||
serialization::readPod(file, partialTotalBytes_);
|
||||
partial_ = true;
|
||||
partialPageCount_ = pageCount;
|
||||
}
|
||||
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||
bool Section::clearCache() const {
|
||||
const std::string tmpBin = binTmpPath();
|
||||
if (Storage.exists(tmpBin.c_str())) {
|
||||
Storage.remove(tmpBin.c_str());
|
||||
}
|
||||
if (!Storage.exists(filePath.c_str())) {
|
||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
@@ -153,8 +212,43 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering, const bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn) {
|
||||
// One-shot build: start, then lay out the whole section in a single pass.
|
||||
if (!startBuild(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
|
||||
hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled, popupFn)) {
|
||||
return false;
|
||||
}
|
||||
if (!buildSomeMore(0)) { // 0 = build to completion
|
||||
return false;
|
||||
}
|
||||
return buildComplete_;
|
||||
}
|
||||
|
||||
bool Section::startBuild(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight,
|
||||
const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering,
|
||||
const bool focusReadingEnabled, const std::function<void()>& popupFn) {
|
||||
if (build_) {
|
||||
LOG_ERR("SCT", "startBuild called while a build is already active");
|
||||
return false;
|
||||
}
|
||||
buildComplete_ = false;
|
||||
builtPageCount_ = 0;
|
||||
// Pages from a loaded partial stay readable (from filePath) while this build writes
|
||||
// to the tmp .bin, so availability never drops below the partial's watermark.
|
||||
pageCount = partial_ ? partialPageCount_ : 0;
|
||||
|
||||
// Remove a stale tmp .bin from a crash-interrupted build; this build recreates it.
|
||||
{
|
||||
const std::string staleTmp = binTmpPath();
|
||||
if (Storage.exists(staleTmp.c_str())) {
|
||||
Storage.remove(staleTmp.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
const auto htmlDir = epub->getCachePath() + "/html";
|
||||
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
|
||||
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
{
|
||||
@@ -162,10 +256,23 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
Storage.mkdir(sectionsDir.c_str());
|
||||
}
|
||||
|
||||
// Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the
|
||||
// book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation
|
||||
// that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip
|
||||
// inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so
|
||||
// even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a
|
||||
// reopen skip the multi-second inflate. If htmlPath exists it is known-complete.
|
||||
const bool reusedHtml = Storage.exists(htmlPath.c_str());
|
||||
bool htmlCached = reusedHtml;
|
||||
if (reusedHtml) {
|
||||
LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str());
|
||||
} else {
|
||||
Storage.mkdir(htmlDir.c_str());
|
||||
|
||||
// Retry logic for SD card timing issues
|
||||
bool success = false;
|
||||
bool streamed = false;
|
||||
uint32_t fileSize = 0;
|
||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||
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
|
||||
@@ -180,46 +287,72 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
continue;
|
||||
}
|
||||
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||
// 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 (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
if (!streamed && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
if (!streamed) {
|
||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||
|
||||
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||
// Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are
|
||||
// valid regardless of whether the layout build finishes, so reopening (even a window-only spine
|
||||
// that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp.
|
||||
if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) {
|
||||
htmlCached = true;
|
||||
} else {
|
||||
LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp");
|
||||
}
|
||||
}
|
||||
|
||||
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
||||
std::vector<PageLutEntry> lut = {};
|
||||
|
||||
auto ctx = makeUniqueNoThrow<BuildContext>();
|
||||
if (!ctx) {
|
||||
LOG_ERR("SCT", "OOM: BuildContext");
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
// htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild
|
||||
// then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up.
|
||||
ctx->reusedHtml = htmlCached;
|
||||
ctx->htmlPath = htmlPath;
|
||||
ctx->tmpHtmlPath = tmpHtmlPath;
|
||||
ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath;
|
||||
|
||||
// Derive the content base directory and image cache path prefix for the parser
|
||||
size_t lastSlash = localPath.find_last_of('/');
|
||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
const size_t lastSlash = localPath.find_last_of('/');
|
||||
ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
|
||||
CssParser* cssParser = nullptr;
|
||||
if (embeddedStyle) {
|
||||
cssParser = epub->getCssParser();
|
||||
if (cssParser) {
|
||||
if (!cssParser->loadFromCache()) {
|
||||
ctx->cssParser = epub->getCssParser();
|
||||
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
|
||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect TOC anchors for this spine so the parser can insert page breaks at chapter boundaries
|
||||
std::vector<std::string> tocAnchors;
|
||||
@@ -234,104 +367,367 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
}
|
||||
}
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||
// The parser stores the path/contentBase/imageBasePath by reference, so they must
|
||||
// live in the BuildContext (which outlives the parser). The page-complete callback
|
||||
// captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the
|
||||
// context for the parser's whole lifetime.
|
||||
BuildContext* ctxPtr = ctx.get();
|
||||
ctx->parser = makeUniqueNoThrow<ChapterHtmlSlimParser>(
|
||||
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
||||
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, ctxPtr](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||
ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||
},
|
||||
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
if (!success) {
|
||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn,
|
||||
ctxPtr->cssParser);
|
||||
if (!ctx->parser) {
|
||||
LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser");
|
||||
if (ctx->cssParser) ctx->cssParser->clear();
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
build_ = std::move(ctx);
|
||||
|
||||
if (!build_->parser->beginParse()) {
|
||||
LOG_ERR("SCT", "Failed to begin parse");
|
||||
abandonBuild();
|
||||
return false;
|
||||
}
|
||||
build_->totalBytes = build_->parser->parseTotalBytes();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Section::buildSomeMore(const int maxPages) {
|
||||
if (!build_ || !build_->parser) {
|
||||
LOG_ERR("SCT", "buildSomeMore with no active build");
|
||||
return false;
|
||||
}
|
||||
// Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial,
|
||||
// pageCount stays pinned at the partial's watermark until the build passes it, which
|
||||
// would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark.
|
||||
const int startCount = builtPageCount_;
|
||||
for (;;) {
|
||||
const auto status = build_->parser->parseStep();
|
||||
if (status == ChapterHtmlSlimParser::ParseStatus::Error) {
|
||||
LOG_ERR("SCT", "Parse error during incremental build");
|
||||
abandonBuild();
|
||||
return false;
|
||||
}
|
||||
if (status == ChapterHtmlSlimParser::ParseStatus::Done) {
|
||||
return finalizeBuild();
|
||||
}
|
||||
// ParseStatus::More: yield once we've laid out the requested number of pages.
|
||||
if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) {
|
||||
build_->bytesConsumed = build_->parser->parseBytesConsumed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Section::hasHtmlCache() const {
|
||||
const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html";
|
||||
return Storage.exists(htmlPath.c_str());
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::findAnchorDuringBuild(const std::string& anchor) const {
|
||||
if (!build_ || !build_->parser) return std::nullopt;
|
||||
for (const auto& [key, page] : build_->parser->getAnchors()) {
|
||||
if (key == anchor) return page;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::findAnchor(const std::string& anchor) const {
|
||||
if (const auto page = findAnchorDuringBuild(anchor)) {
|
||||
return page;
|
||||
}
|
||||
// Fall back to the on-disk anchor map: a finalized section, or a partial whose map
|
||||
// covers everything up to its watermark (nullopt past it -- build further and retry).
|
||||
return getPageForAnchor(anchor);
|
||||
}
|
||||
|
||||
uint16_t Section::estimatedTotalPages() const {
|
||||
// Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA
|
||||
// damping is needed. Also the best guess while a rebuild is running but hasn't laid out
|
||||
// enough pages yet to extrapolate from its own progress.
|
||||
const auto partialEstimate = [this]() -> uint16_t {
|
||||
if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) {
|
||||
return pageCount;
|
||||
}
|
||||
const uint64_t est = static_cast<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
|
||||
if (est <= pageCount) return pageCount;
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||
};
|
||||
|
||||
if (!build_) {
|
||||
return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact
|
||||
}
|
||||
const uint32_t consumed = build_->bytesConsumed;
|
||||
const uint32_t total = build_->totalBytes;
|
||||
if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate();
|
||||
|
||||
// Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This
|
||||
// re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses
|
||||
// dense vs sparse regions of the chapter.
|
||||
const uint64_t raw = static_cast<uint64_t>(builtPageCount_) * total / consumed;
|
||||
|
||||
// Damp that jitter with an exponential moving average. Step it once per build advance (keyed on
|
||||
// bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how
|
||||
// often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so
|
||||
// the average settles onto the true count (and finalizeBuild then returns the exact pageCount).
|
||||
constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle
|
||||
if (build_->smoothedEstimate <= 0) {
|
||||
build_->smoothedEstimate = static_cast<float>(raw); // seed on the first estimate
|
||||
} else if (consumed != build_->smoothedAtConsumed) {
|
||||
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
|
||||
}
|
||||
build_->smoothedAtConsumed = consumed;
|
||||
|
||||
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
|
||||
if (est <= pageCount) return pageCount; // never fewer than the pages already available
|
||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
||||
}
|
||||
|
||||
// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built
|
||||
// page count and table offsets, stamp `version` as the commit point, then swap the tmp
|
||||
// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer
|
||||
// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate
|
||||
// the total page count. The parser must still be alive (anchors are read from it).
|
||||
// On failure the tmp is removed and any pre-existing file at filePath is left intact.
|
||||
bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) {
|
||||
const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||
|
||||
const auto failCommit = [this]() {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
return false;
|
||||
};
|
||||
|
||||
const uint32_t lutOffset = file.position();
|
||||
bool hasFailedLutRecords = false;
|
||||
// Write LUT
|
||||
for (const auto& entry : lut) {
|
||||
for (const auto& entry : build_->lut) {
|
||||
if (entry.fileOffset == 0) {
|
||||
hasFailedLutRecords = true;
|
||||
break;
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
return failCommit();
|
||||
}
|
||||
serialization::writePod(file, entry.fileOffset);
|
||||
}
|
||||
|
||||
if (hasFailedLutRecords) {
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a
|
||||
// partial, skip anchors that landed on the incomplete trailing page the suspend drops.
|
||||
const uint32_t anchorMapOffset = file.position();
|
||||
const auto& anchors = visitor.getAnchors();
|
||||
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
||||
const auto& anchors = build_->parser->getAnchors();
|
||||
uint16_t anchorCount = 0;
|
||||
for (const auto& [anchor, page] : anchors) {
|
||||
if (!asPartial || page < builtPageCount_) anchorCount++;
|
||||
}
|
||||
serialization::writePod(file, anchorCount);
|
||||
for (const auto& [anchor, page] : anchors) {
|
||||
if (asPartial && page >= builtPageCount_) continue;
|
||||
serialization::writeString(file, anchor);
|
||||
serialization::writePod(file, page);
|
||||
}
|
||||
|
||||
const uint32_t paragraphLutOffset = file.position();
|
||||
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
||||
for (const auto& entry : lut) {
|
||||
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
|
||||
for (const auto& entry : build_->lut) {
|
||||
serialization::writePod(file, entry.paragraphIndex);
|
||||
}
|
||||
|
||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
||||
for (const auto& entry : lut) {
|
||||
for (const auto& entry : build_->lut) {
|
||||
serialization::writePod(file, entry.listItemIndex);
|
||||
}
|
||||
|
||||
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
||||
serialization::writePod(file, pageCount);
|
||||
if (asPartial) {
|
||||
// Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t).
|
||||
serialization::writePod(file, bytesConsumed);
|
||||
serialization::writePod(file, totalBytes);
|
||||
}
|
||||
|
||||
// Patch header with the built page count and section offsets...
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_));
|
||||
serialization::writePod(file, builtPageCount_);
|
||||
serialization::writePod(file, lutOffset);
|
||||
serialization::writePod(file, anchorMapOffset);
|
||||
serialization::writePod(file, paragraphLutOffset);
|
||||
serialization::writePod(file, liLutFileOffset);
|
||||
// ...then commit by overwriting the sentinel version with the real one. Writing the
|
||||
// version last makes it the commit point: a crash before here leaves version 0.
|
||||
file.seek(0);
|
||||
serialization::writePod(file, version);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
|
||||
// Swap into place. A crash between remove and rename loses the old file but keeps a
|
||||
// fully-committed tmp; the next build just removes it and rebuilds.
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) {
|
||||
LOG_ERR("SCT", "Failed to move built section into place");
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
bool Section::finalizeBuild() {
|
||||
// Flush the trailing page (emits the last page via the completePageFn into the LUT).
|
||||
build_->parser->finishParse();
|
||||
|
||||
if (!build_->reusedHtml) {
|
||||
// Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future
|
||||
// rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded.
|
||||
if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) {
|
||||
LOG_DBG("SCT", "Failed to promote HTML cache, removing temp");
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0);
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
build_.reset();
|
||||
if (!committed) {
|
||||
// commitBuildFile removed filePath before the failed swap, so nothing valid remains.
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = 0;
|
||||
builtPageCount_ = 0;
|
||||
return false;
|
||||
}
|
||||
buildComplete_ = true;
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = builtPageCount_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Section::suspendBuild() {
|
||||
if (!build_) return;
|
||||
|
||||
// Only worth persisting if this build produced pages a pre-existing partial doesn't
|
||||
// already cover; otherwise keep the older (bigger) partial and just drop the tmp.
|
||||
const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_);
|
||||
|
||||
bool committed = false;
|
||||
if (worthKeeping) {
|
||||
// Capture the parse watermark and commit before tearing the parser down (the anchor
|
||||
// map is read from it). The incomplete trailing page is intentionally not flushed:
|
||||
// only fully laid-out pages are persisted, and the rebuild re-derives the rest.
|
||||
const uint32_t consumed = static_cast<uint32_t>(build_->parser->parseBytesConsumed());
|
||||
committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes);
|
||||
if (committed) {
|
||||
partial_ = true;
|
||||
partialPageCount_ = builtPageCount_;
|
||||
partialBytesConsumed_ = consumed;
|
||||
partialTotalBytes_ = build_->totalBytes;
|
||||
LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_);
|
||||
}
|
||||
}
|
||||
|
||||
if (build_->parser) build_->parser->abortParse();
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
if (!committed && file) {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
}
|
||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
build_.reset();
|
||||
buildComplete_ = false;
|
||||
pageCount = partial_ ? partialPageCount_ : 0;
|
||||
builtPageCount_ = 0;
|
||||
}
|
||||
|
||||
void Section::abandonBuild() {
|
||||
if (!build_) return;
|
||||
if (build_->parser) build_->parser->abortParse();
|
||||
if (build_->cssParser) build_->cssParser->clear();
|
||||
if (file) {
|
||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
||||
file.close();
|
||||
Storage.remove(binTmpPath().c_str());
|
||||
}
|
||||
// A parse error would recur against the same HTML, so drop any partial too -- resuming
|
||||
// from it would just re-enter the failing build every open.
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
||||
}
|
||||
build_.reset();
|
||||
buildComplete_ = false;
|
||||
partial_ = false;
|
||||
partialPageCount_ = 0;
|
||||
pageCount = 0;
|
||||
builtPageCount_ = 0;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageDuringBuild(const int page) {
|
||||
if (!build_ || page < 0 || page >= static_cast<int>(build_->lut.size()) || !file) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint32_t pos = build_->lut[page].fileOffset;
|
||||
if (pos == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
// The .bin is open O_RDWR for the build. Read the already-written page, then restore
|
||||
// the write cursor so the next onPageComplete keeps appending where it left off.
|
||||
const uint32_t writePos = file.position();
|
||||
file.seek(pos);
|
||||
auto p = Page::deserialize(file);
|
||||
file.seek(writePos);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Read a page from the committed file at filePath (finalized section or partial from a
|
||||
// previous session). Uses a local handle so it is safe while a build holds the member
|
||||
// `file` open on the tmp .bin.
|
||||
std::unique_ptr<Page> Section::loadPageAt(const int page) const {
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||
uint32_t lutOffset;
|
||||
serialization::readPod(file, lutOffset);
|
||||
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
||||
serialization::readPod(f, lutOffset);
|
||||
f.seek(lutOffset + sizeof(uint32_t) * page);
|
||||
uint32_t pagePos;
|
||||
serialization::readPod(file, pagePos);
|
||||
file.seek(pagePos);
|
||||
serialization::readPod(f, pagePos);
|
||||
f.seek(pagePos);
|
||||
|
||||
auto page = Page::deserialize(file);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
return page;
|
||||
return Page::deserialize(f);
|
||||
// No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPage(const int page) {
|
||||
if (page < 0) {
|
||||
return nullptr;
|
||||
}
|
||||
if (build_ && page < static_cast<int>(build_->lut.size())) {
|
||||
return loadPageDuringBuild(page);
|
||||
}
|
||||
// Not (yet) in the active build: serve from the file on disk -- a finalized section,
|
||||
// or a partial from a previous session whose pages the rebuild hasn't reached again.
|
||||
const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount);
|
||||
if (page >= onDisk) {
|
||||
return nullptr;
|
||||
}
|
||||
return loadPageAt(page);
|
||||
}
|
||||
|
||||
std::string Section::getTextFromSectionFile() {
|
||||
std::string fullText;
|
||||
auto p = this->loadPageFromSectionFile();
|
||||
auto p = loadPage(currentPage);
|
||||
if (p) {
|
||||
for (const auto& el : p->elements) {
|
||||
if (el->getTag() == TAG_PageLine) {
|
||||
@@ -360,6 +756,15 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Only a finalized section's count is the chapter total; a partial's count is just the
|
||||
// suspended build's watermark, which would skew progress mapping. Callers fall back to
|
||||
// their own estimates.
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
|
||||
+105
-7
@@ -3,11 +3,14 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub.h"
|
||||
|
||||
class Page;
|
||||
class GfxRenderer;
|
||||
class ChapterHtmlSlimParser;
|
||||
class CssParser;
|
||||
|
||||
class Section {
|
||||
std::shared_ptr<Epub> epub;
|
||||
@@ -21,16 +24,67 @@ class Section {
|
||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
// Page-offset table entry, kept in RAM while an incremental build is running so
|
||||
// already-built pages can be located in the partially-written .bin.
|
||||
struct PageLutEntry {
|
||||
uint32_t fileOffset;
|
||||
uint16_t paragraphIndex;
|
||||
uint16_t listItemIndex;
|
||||
};
|
||||
// Held only while an incremental build is in progress (see startBuild). Carries the
|
||||
// live parser plus the strings it references (the parser stores them by reference)
|
||||
// and the in-RAM page-offset table.
|
||||
struct BuildContext {
|
||||
std::unique_ptr<ChapterHtmlSlimParser> parser;
|
||||
std::vector<PageLutEntry> lut;
|
||||
std::string parsePath;
|
||||
std::string contentBase;
|
||||
std::string imageBasePath;
|
||||
std::string htmlPath;
|
||||
std::string tmpHtmlPath;
|
||||
bool reusedHtml = false;
|
||||
CssParser* cssParser = nullptr;
|
||||
// HTML byte progress, for estimating the section's total page count while it's still building.
|
||||
uint32_t bytesConsumed = 0;
|
||||
uint32_t totalBytes = 0;
|
||||
// Exponentially-smoothed page-count estimate (0 = not yet seeded) and the bytesConsumed at its
|
||||
// last update. The raw byte-ratio estimate jitters as the build crosses dense/sparse regions;
|
||||
// the EMA is stepped once per build advance (not per redraw) to damp that wobble.
|
||||
float smoothedEstimate = 0;
|
||||
uint32_t smoothedAtConsumed = 0;
|
||||
};
|
||||
std::unique_ptr<BuildContext> build_;
|
||||
bool buildComplete_ = false;
|
||||
// Pages laid out by the active build (== build_->lut.size()). Distinct from pageCount,
|
||||
// which is the pages *available to read* and also counts a loaded partial file's pages.
|
||||
uint16_t builtPageCount_ = 0;
|
||||
// A partial section file (suspended build from a previous session) is loaded at filePath.
|
||||
// Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them.
|
||||
bool partial_ = false;
|
||||
uint16_t partialPageCount_ = 0;
|
||||
// Parse watermark from the partial's trailer, for estimating the total page count.
|
||||
uint32_t partialBytesConsumed_ = 0;
|
||||
uint32_t partialTotalBytes_ = 0;
|
||||
bool finalizeBuild();
|
||||
// Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the
|
||||
// header, stamp the version byte, and swap the tmp .bin over filePath.
|
||||
bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes);
|
||||
// Builds write here and are swapped over filePath only on commit, so a prior
|
||||
// partial/finalized file stays readable while a rebuild is in progress.
|
||||
std::string binTmpPath() const { return filePath + ".part"; }
|
||||
std::unique_ptr<Page> loadPageAt(int page) const;
|
||||
// Read a page already laid out by the in-progress build (page < build LUT size), from
|
||||
// the partially-written tmp .bin without disturbing the build's write cursor.
|
||||
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
||||
|
||||
public:
|
||||
uint16_t pageCount = 0;
|
||||
int currentPage = 0;
|
||||
|
||||
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
: epub(epub),
|
||||
spineIndex(spineIndex),
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
~Section() = default;
|
||||
// Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the
|
||||
// forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp.
|
||||
explicit Section(const std::shared_ptr<Epub>& epub, int spineIndex, GfxRenderer& renderer);
|
||||
~Section();
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled);
|
||||
@@ -39,12 +93,56 @@ class Section {
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
|
||||
// Incremental build: lay out the section a few pages at a time so a large chapter
|
||||
// can show its first page immediately and keep the UI responsive while the rest
|
||||
// builds. createSectionFile() above is the one-shot wrapper over these.
|
||||
// if (!startBuild(...)) fail;
|
||||
// each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop.
|
||||
bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled, const std::function<void()>& popupFn = nullptr);
|
||||
// Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns
|
||||
// false on error (the build is abandoned). Sets isBuildComplete() when finished.
|
||||
bool buildSomeMore(int maxPages);
|
||||
bool isBuilding() const { return static_cast<bool>(build_); }
|
||||
bool isBuildComplete() const { return buildComplete_; }
|
||||
// Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based
|
||||
// estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine
|
||||
// is still building, so "page X of Y" / progress don't read off the small build watermark.
|
||||
uint16_t estimatedTotalPages() const;
|
||||
void abandonBuild();
|
||||
// Persist an in-progress build as a partial section file (version sentinel + LUTs +
|
||||
// watermark trailer) instead of discarding it, so the next open of this spine can show
|
||||
// its pages instantly and only rebuild in the background. Called by the destructor, so
|
||||
// any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a
|
||||
// pre-existing partial when it covers more pages than this build reached.
|
||||
void suspendBuild();
|
||||
// True when a partial file was loaded: pageCount is a watermark, not the chapter total.
|
||||
bool isPartial() const { return partial_; }
|
||||
|
||||
// Unified page read: from the active build if it has reached the page, otherwise from
|
||||
// the on-disk file (finalized section, or a partial the rebuild hasn't caught up to).
|
||||
std::unique_ptr<Page> loadPage(int page);
|
||||
|
||||
std::string getTextFromSectionFile();
|
||||
|
||||
// Resolve an anchor from the in-progress build first, then the on-disk anchor map
|
||||
// (covers finalized sections and partials from a previous session).
|
||||
std::optional<uint16_t> findAnchor(const std::string& anchor) const;
|
||||
|
||||
// True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a
|
||||
// giant spine) zip inflation. Lets the reader skip the indexing popup on a fast reopen/rebuild.
|
||||
bool hasHtmlCache() const;
|
||||
|
||||
// Look up the page number for an anchor id from the section cache file.
|
||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||
|
||||
// Look up an anchor among the pages built so far by the in-progress build, so an anchor jump
|
||||
// (TOC / chapter select, usually the chapter top = page 0) can resolve without laying out the
|
||||
// whole chapter. Returns nullopt if the anchor hasn't been reached yet (build more) or no build.
|
||||
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
||||
|
||||
// Get the page count from the section cache file without fully loading it.
|
||||
std::optional<uint16_t> getCachedPageCount() const;
|
||||
|
||||
|
||||
@@ -21,6 +21,39 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
|
||||
const bool scanning = renderer.isFontCacheScanning();
|
||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||
|
||||
struct DecorationLineTracker {
|
||||
EpdFontFamily::Style style;
|
||||
int yOffset;
|
||||
int startX = -1;
|
||||
int endX = -1;
|
||||
int yPos = 0;
|
||||
|
||||
bool active() const { return startX != -1; }
|
||||
void reset() {
|
||||
startX = -1;
|
||||
endX = -1;
|
||||
yPos = 0;
|
||||
}
|
||||
};
|
||||
|
||||
DecorationLineTracker decorationLines[] = {
|
||||
{EpdFontFamily::UNDERLINE, ascender + 2},
|
||||
{EpdFontFamily::STRIKETHROUGH, ascender * 4 / 5},
|
||||
};
|
||||
|
||||
const auto flushDecoration = [&](DecorationLineTracker& line) {
|
||||
if (line.active()) {
|
||||
renderer.drawLine(line.startX, line.yPos, line.endX, line.yPos, 2, true);
|
||||
line.reset();
|
||||
}
|
||||
};
|
||||
const auto flushDecorations = [&]() {
|
||||
for (auto& line : decorationLines) {
|
||||
flushDecoration(line);
|
||||
}
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
const int wordX = wordXpos[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||
@@ -59,18 +92,51 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
|
||||
}
|
||||
|
||||
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||
if (scanning) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
|
||||
const std::string& w = words[i];
|
||||
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
||||
const int underlineY = wordY + ascender + 2;
|
||||
int lineStartX = wordX;
|
||||
int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
||||
|
||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||
underlineWidth = (underlineWidth + 1) / 2;
|
||||
lineWidth = (lineWidth + 1) / 2;
|
||||
}
|
||||
|
||||
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
|
||||
// Do not decorate the synthetic em-space used for paragraph indentation.
|
||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||
const char* visibleText = w.c_str() + 3;
|
||||
lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
||||
lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir);
|
||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||
lineWidth = (lineWidth + 1) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& line : decorationLines) {
|
||||
if ((currentStyle & line.style) == 0) {
|
||||
flushDecoration(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const int lineY = wordY + line.yOffset;
|
||||
if (line.active() && line.yPos != lineY) {
|
||||
flushDecoration(line);
|
||||
}
|
||||
if (!line.active()) {
|
||||
line.startX = lineStartX;
|
||||
line.yPos = lineY;
|
||||
}
|
||||
line.endX = lineStartX + lineWidth;
|
||||
}
|
||||
} else {
|
||||
flushDecorations();
|
||||
}
|
||||
}
|
||||
flushDecorations();
|
||||
}
|
||||
|
||||
bool TextBlock::serialize(HalFile& file) const {
|
||||
|
||||
@@ -67,13 +67,6 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe
|
||||
[](char a, char b) { return asciiToLower(a) == b; });
|
||||
}
|
||||
|
||||
// Case-insensitive ASCII substring search. Only needed by text-decoration,
|
||||
// which accepts multi-value strings like "underline solid red".
|
||||
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
|
||||
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
|
||||
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
|
||||
}
|
||||
|
||||
// Walk s and invoke fn(token) for each non-empty run between delimiters.
|
||||
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
||||
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
||||
@@ -252,11 +245,20 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
||||
}
|
||||
|
||||
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
||||
// text-decoration can have multiple space-separated values
|
||||
if (icontainsAscii(val, "underline")) {
|
||||
return CssTextDecoration::Underline;
|
||||
// text-decoration can have multiple space-separated values. Compare whole tokens
|
||||
// so malformed values like "notunderline" do not accidentally enable a line.
|
||||
CssTextDecoration result = CssTextDecoration::None;
|
||||
bool explicitNone = false;
|
||||
forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) {
|
||||
if (iequalsAscii(token, "none")) {
|
||||
explicitNone = true;
|
||||
} else if (iequalsAscii(token, "underline")) {
|
||||
result = result | CssTextDecoration::Underline;
|
||||
} else if (iequalsAscii(token, "line-through")) {
|
||||
result = result | CssTextDecoration::LineThrough;
|
||||
}
|
||||
return CssTextDecoration::None;
|
||||
});
|
||||
return explicitNone ? CssTextDecoration::None : result;
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(std::string_view val) {
|
||||
@@ -868,7 +870,7 @@ bool CssParser::loadFromCache() {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
class CssParser {
|
||||
public:
|
||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 6;
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 7;
|
||||
|
||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
||||
~CssParser() = default;
|
||||
|
||||
@@ -52,8 +52,19 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
|
||||
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
||||
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
||||
|
||||
// Text decoration options
|
||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
||||
// Text decoration options. Values are bit flags so CSS can combine multiple line decorations.
|
||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 };
|
||||
|
||||
constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) {
|
||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) {
|
||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
constexpr uint8_t CSS_TEXT_DECORATION_MASK =
|
||||
static_cast<uint8_t>(CssTextDecoration::Underline) | static_cast<uint8_t>(CssTextDecoration::LineThrough);
|
||||
|
||||
// Display options - only None and Block are relevant for e-ink rendering
|
||||
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
||||
|
||||
@@ -25,42 +25,45 @@ static constexpr EntityPair ENTITY_LOOKUP[] = {
|
||||
{"Š", "Š"}, {"Σ", "Σ"}, {"Þ", "Þ"}, {"Τ", "Τ"}, {"Θ", "Θ"},
|
||||
{"Ú", "Ú"}, {"Û", "Û"}, {"Ù", "Ù"}, {"Υ", "Υ"}, {"Ü", "Ü"},
|
||||
{"Ξ", "Ξ"}, {"Ý", "Ý"}, {"Ÿ", "Ÿ"}, {"Ζ", "Ζ"}, {"á", "á"},
|
||||
{"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"α", "α"},
|
||||
{"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"}, {"≈", "≈"},
|
||||
{"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"}, {"¦", "¦"},
|
||||
{"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"}, {"¢", "¢"},
|
||||
{"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"}, {"©", "©"},
|
||||
{"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"†", "†"}, {"↓", "↓"},
|
||||
{"°", "°"}, {"δ", "δ"}, {"♦", "♦"}, {"÷", "÷"}, {"é", "é"},
|
||||
{"ê", "ê"}, {"è", "è"}, {"∅", "∅"}, {" ", " "}, {" ", " "},
|
||||
{"ε", "ε"}, {"≡", "≡"}, {"η", "η"}, {"ð", "ð"}, {"ë", "ë"},
|
||||
{"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"}, {"∀", "∀"}, {"½", "½"},
|
||||
{"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"}, {"γ", "γ"}, {"≥", "≥"},
|
||||
{">", ">"}, {"↔", "↔"}, {"♥", "♥"}, {"…", "…"}, {"í", "í"},
|
||||
{"î", "î"}, {"¡", "¡"}, {"ì", "ì"}, {"∞", "∞"}, {"∫", "∫"},
|
||||
{"ι", "ι"}, {"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"},
|
||||
{"λ", "λ"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"}, {"“", "\u201C"},
|
||||
{"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"}, {"‎", "\u200E"},
|
||||
{"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"}, {"—", "—"},
|
||||
{"µ", "µ"}, {"−", "−"}, {"μ", "μ"}, {"∇", "∇"}, {" ", "\xC2\xA0"},
|
||||
{"–", "–"}, {"≠", "≠"}, {"∋", "∋"}, {"¬", "¬"}, {"∉", "∉"},
|
||||
{"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"}, {"ó", "ó"}, {"ô", "ô"},
|
||||
{"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"}, {"ω", "ω"}, {"ο", "ο"},
|
||||
{"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"}, {"º", "º"}, {"ø", "ø"},
|
||||
{"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"}, {"¶", "¶"}, {"∂", "∂"},
|
||||
{"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"}, {"π", "π"}, {"ϖ", "ϖ"},
|
||||
{"±", "±"}, {"£", "£"}, {"′", "′"}, {"∏", "∏"}, {"∝", "∝"},
|
||||
{"ψ", "ψ"}, {""", "\""}, {"√", "√"}, {"»", "»"}, {"→", "→"},
|
||||
{"⌉", "⌉"}, {"”", "\u201D"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"},
|
||||
{"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"ℵ", "ℵ"},
|
||||
{"α", "α"}, {"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"},
|
||||
{"≈", "≈"}, {"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"},
|
||||
{"¦", "¦"}, {"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"},
|
||||
{"¢", "¢"}, {"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"},
|
||||
{"©", "©"}, {"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"⇓", "⇓"},
|
||||
{"†", "†"}, {"↓", "↓"}, {"°", "°"}, {"δ", "δ"}, {"♦", "♦"},
|
||||
{"÷", "÷"}, {"é", "é"}, {"ê", "ê"}, {"è", "è"}, {"∅", "∅"},
|
||||
{" ", " "}, {" ", " "}, {"ε", "ε"}, {"≡", "≡"}, {"η", "η"},
|
||||
{"ð", "ð"}, {"ë", "ë"}, {"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"},
|
||||
{"∀", "∀"}, {"½", "½"}, {"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"},
|
||||
{"γ", "γ"}, {"≥", "≥"}, {">", ">"}, {"⇔", "⇔"}, {"↔", "↔"},
|
||||
{"♥", "♥"}, {"…", "…"}, {"í", "í"}, {"î", "î"}, {"¡", "¡"},
|
||||
{"ì", "ì"}, {"ℑ", "ℑ"}, {"∞", "∞"}, {"∫", "∫"}, {"ι", "ι"},
|
||||
{"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"}, {"⇐", "⇐"},
|
||||
{"λ", "λ"}, {"⟨", "〈"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"},
|
||||
{"“", "\u201C"}, {"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"},
|
||||
{"‎", "\u200E"}, {"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"},
|
||||
{"—", "—"}, {"µ", "µ"}, {"·", "·"}, {"−", "−"}, {"μ", "μ"},
|
||||
{"∇", "∇"}, {" ", "\xC2\xA0"}, {"–", "–"}, {"≠", "≠"}, {"∋", "∋"},
|
||||
{"¬", "¬"}, {"∉", "∉"}, {"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"},
|
||||
{"ó", "ó"}, {"ô", "ô"}, {"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"},
|
||||
{"ω", "ω"}, {"ο", "ο"}, {"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"},
|
||||
{"º", "º"}, {"ø", "ø"}, {"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"},
|
||||
{"¶", "¶"}, {"∂", "∂"}, {"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"},
|
||||
{"π", "π"}, {"ϖ", "ϖ"}, {"±", "±"}, {"£", "£"}, {"′", "′"},
|
||||
{"∏", "∏"}, {"∝", "∝"}, {"ψ", "ψ"}, {""", "\""}, {"⇒", "⇒"},
|
||||
{"√", "√"}, {"⟩", "〉"}, {"»", "»"}, {"→", "→"}, {"⌉", "⌉"},
|
||||
{"”", "\u201D"}, {"ℜ", "\u211C"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"},
|
||||
{"‏", "\u200F"}, {"›", "›"}, {"’", "\u2019"}, {"‚", "‚"}, {"š", "š"},
|
||||
{"⋅", "⋅"}, {"§", "§"}, {"­", "\xC2\xAD"}, {"σ", "σ"}, {"ς", "ς"},
|
||||
{"∼", "∼"}, {"♠", "♠"}, {"⊂", "⊂"}, {"⊆", "⊆"}, {"∑", "∑"},
|
||||
{"¹", "¹"}, {"²", "²"}, {"³", "³"}, {"⊃", "⊃"}, {"⊇", "⊇"},
|
||||
{"ß", "ß"}, {"τ", "τ"}, {"∴", "∴"}, {"θ", "θ"}, {"ϑ", "ϑ"},
|
||||
{" ", " "}, {"þ", "þ"}, {"˜", "˜"}, {"×", "×"}, {"™", "™"},
|
||||
{"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"}, {"¨", "¨"},
|
||||
{"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"ξ", "ξ"}, {"ý", "ý"},
|
||||
{"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"}, {"‍", "\u200D"}, {"‌", "\u200C"},
|
||||
{"⇑", "⇑"}, {"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"},
|
||||
{"¨", "¨"}, {"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"℘", "℘"},
|
||||
{"ξ", "ξ"}, {"ý", "ý"}, {"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"},
|
||||
{"‍", "\u200D"}, {"‌", "\u200C"},
|
||||
};
|
||||
|
||||
// Verify the table is sorted at compile time.
|
||||
|
||||
@@ -34,6 +34,7 @@ constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
||||
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
||||
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
||||
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
|
||||
constexpr const char* IMAGE_TAGS[] = {"img"};
|
||||
constexpr const char* SKIP_TAGS[] = {"head"};
|
||||
|
||||
@@ -89,13 +90,50 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
|
||||
}
|
||||
}
|
||||
|
||||
// Update effective bold/italic/underline based on block style and inline style stack
|
||||
EpdFontFamily::Style ChapterHtmlSlimParser::fontStyleForTextDecoration(const CssTextDecoration decoration) {
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR;
|
||||
if ((decoration & CssTextDecoration::Underline) != CssTextDecoration::None) {
|
||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
if ((decoration & CssTextDecoration::LineThrough) != CssTextDecoration::None) {
|
||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::STRIKETHROUGH);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css) {
|
||||
if (css.hasTextDecoration()) {
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = css.textDecoration;
|
||||
}
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::pushDecorationStyleEntry(const CssTextDecoration defaultDecoration,
|
||||
const CssStyle& cssStyle) {
|
||||
StyleStackEntry entry;
|
||||
entry.depth = depth;
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = cssStyle.hasTextDecoration() ? cssStyle.textDecoration : defaultDecoration;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
inlineStyleStack.push_back(entry);
|
||||
updateEffectiveInlineStyle();
|
||||
}
|
||||
|
||||
// Update effective bold/italic/decorations based on block style and inline style stack
|
||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
// Start with block-level styles
|
||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
||||
effectiveUnderline =
|
||||
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
effectiveTextDecoration =
|
||||
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
|
||||
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
||||
effectiveDirection = currentCssStyle.direction;
|
||||
effectiveSup = false;
|
||||
@@ -109,8 +147,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
if (entry.hasItalic) {
|
||||
effectiveItalic = entry.italic;
|
||||
}
|
||||
if (entry.hasUnderline) {
|
||||
effectiveUnderline = entry.underline;
|
||||
// CSS line decorations propagate through descendants; child entries add
|
||||
// their own lines but cannot cancel an ancestor's already active line.
|
||||
if (entry.hasTextDecoration) {
|
||||
effectiveTextDecoration = effectiveTextDecoration | entry.textDecoration;
|
||||
}
|
||||
if (entry.hasDirection) {
|
||||
effectiveDirectionDefined = true;
|
||||
@@ -164,7 +204,6 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
// Determine font style from depth-based tracking and CSS effective style
|
||||
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
||||
|
||||
// Combine style flags using bitwise OR
|
||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||
@@ -174,9 +213,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
if (isItalic) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
||||
}
|
||||
if (isUnderline) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
|
||||
if (effectiveSup) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
||||
} else if (effectiveSub) {
|
||||
@@ -317,6 +354,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
const bool isTocAnchor =
|
||||
std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
|
||||
if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) {
|
||||
// Flush a displaced anchor before overwriting. Consecutive non-block elements
|
||||
// (e.g. <aside id="fn1">text</aside><aside id="fn2">) with no intervening block
|
||||
// never trigger startNewTextBlock, so fn1 gets silently overwritten. That leaves
|
||||
// fn1 missing from the anchor map -> getPageForAnchor returns nullopt -> reader
|
||||
// lands at page 0 (section start) instead of the footnote.
|
||||
if (!self->pendingAnchorId.empty()) {
|
||||
self->flushPendingAnchor();
|
||||
}
|
||||
self->pendingAnchorId = idValue;
|
||||
}
|
||||
} else if (strcmp(atts[i], "dir") == 0) {
|
||||
@@ -412,14 +457,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
headerStyle.bold = false;
|
||||
headerStyle.hasItalic = true;
|
||||
headerStyle.italic = true;
|
||||
headerStyle.hasUnderline = true;
|
||||
headerStyle.underline = false;
|
||||
self->inlineStyleStack.push_back(headerStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
const CssTextDecoration savedTextDecoration = self->effectiveTextDecoration;
|
||||
self->effectiveTextDecoration = CssTextDecoration::None;
|
||||
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
self->effectiveTextDecoration = savedTextDecoration;
|
||||
self->nextWordContinues = false;
|
||||
self->inlineStyleStack.pop_back();
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -752,12 +798,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentFootnote.number[0] = '\0';
|
||||
self->currentFootnoteLinkTextLen = 0;
|
||||
|
||||
// Apply underline style to visually indicate the link
|
||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||
// Apply underline style to visually indicate the link.
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth;
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = true;
|
||||
entry.hasTextDecoration = true;
|
||||
entry.textDecoration = CssTextDecoration::Underline;
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -829,23 +874,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||
// Push inline style entry for underline tag
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = true;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
self->pushDecorationStyleEntry(CssTextDecoration::Underline, cssStyle);
|
||||
} else if (matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS))) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
self->pushDecorationStyleEntry(CssTextDecoration::LineThrough, cssStyle);
|
||||
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
@@ -862,10 +898,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -885,10 +918,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
@@ -927,10 +957,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
applyTextDecorationToEntry(entry, cssStyle);
|
||||
applyDirectionToEntry(entry, cssStyle);
|
||||
if (cssStyle.hasVerticalAlign()) {
|
||||
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
||||
@@ -1142,9 +1169,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
||||
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
||||
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
||||
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
||||
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic;
|
||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||
const bool tableStructuralTag = isTableStructuralTag(name);
|
||||
|
||||
@@ -1163,7 +1189,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
||||
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
|
||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) ||
|
||||
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
|
||||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
@@ -1222,11 +1249,6 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->italicUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving underline tag
|
||||
if (self->underlineUntilDepth == self->depth) {
|
||||
self->underlineUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Pop from inline style stack if we pushed an entry at this depth
|
||||
// This handles all inline elements: b, i, u, span, etc.
|
||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||
@@ -1253,7 +1275,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
}
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
|
||||
|
||||
bool ChapterHtmlSlimParser::beginParse() {
|
||||
// Initialize block style stack with a root entry representing "no ancestor block elements".
|
||||
// The user's paragraph alignment is set as the default so child elements without explicit
|
||||
// text-align inherit it correctly through getCombinedBlockStyle.
|
||||
@@ -1271,67 +1295,78 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
paragraphAlignmentBlockStyle.alignment = align;
|
||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
||||
|
||||
XML_Parser parser = XML_ParserCreate(nullptr);
|
||||
int done;
|
||||
|
||||
if (!parser) {
|
||||
xmlParser_ = XML_ParserCreate(nullptr);
|
||||
if (!xmlParser_) {
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
||||
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
||||
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
|
||||
XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand);
|
||||
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("EHP", filepath, file)) {
|
||||
destroyXmlParser(parser);
|
||||
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file size to decide whether to show indexing popup.
|
||||
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
||||
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
|
||||
popupFn();
|
||||
}
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
XML_SetUserData(xmlParser_, this);
|
||||
XML_SetElementHandler(xmlParser_, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(xmlParser_, characterData);
|
||||
|
||||
// Compute the time taken to parse and build pages
|
||||
const uint32_t chapterStartTime = millis();
|
||||
do {
|
||||
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
|
||||
parseStartTime_ = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() {
|
||||
void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE);
|
||||
if (!buf) {
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
|
||||
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
|
||||
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
|
||||
|
||||
if (len == 0 && file.available() > 0) {
|
||||
if (len == 0 && parseFile_.available() > 0) {
|
||||
LOG_ERR("EHP", "File read error");
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
|
||||
done = file.available() == 0;
|
||||
const int done = parseFile_.available() == 0;
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return false;
|
||||
if (XML_ParseBuffer(xmlParser_, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_),
|
||||
XML_ErrorString(XML_GetErrorCode(xmlParser_)));
|
||||
return ParseStatus::Error;
|
||||
}
|
||||
} while (!done);
|
||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
|
||||
|
||||
destroyXmlParser(parser);
|
||||
file.close();
|
||||
return done ? ParseStatus::Done : ParseStatus::More;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::abortParse() {
|
||||
if (xmlParser_) {
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
}
|
||||
// Only close the file if it was successfully opened in beginParse()
|
||||
if (parseFile_.isOpen()) {
|
||||
parseFile_.close();
|
||||
}
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::finishParse() {
|
||||
if (xmlParser_) {
|
||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_);
|
||||
destroyXmlParser(xmlParser_);
|
||||
xmlParser_ = nullptr;
|
||||
}
|
||||
parseFile_.close();
|
||||
|
||||
// Process last page if there is still text
|
||||
if (currentTextBlock) {
|
||||
@@ -1349,6 +1384,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
if (!beginParse()) {
|
||||
return false;
|
||||
}
|
||||
for (;;) {
|
||||
const ParseStatus status = parseStep();
|
||||
if (status == ParseStatus::Error) {
|
||||
abortParse();
|
||||
return false;
|
||||
}
|
||||
if (status == ParseStatus::Done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return finishParse();
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <climits>
|
||||
@@ -31,7 +32,6 @@ class ChapterHtmlSlimParser {
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
int italicUntilDepth = INT_MAX;
|
||||
int underlineUntilDepth = INT_MAX;
|
||||
// buffer for building up words from characters, will auto break if longer than this
|
||||
// leave one char at end for null pointer
|
||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||
@@ -60,7 +60,8 @@ class ChapterHtmlSlimParser {
|
||||
int depth = 0;
|
||||
bool hasBold = false, bold = false;
|
||||
bool hasItalic = false, italic = false;
|
||||
bool hasUnderline = false, underline = false;
|
||||
bool hasTextDecoration = false;
|
||||
CssTextDecoration textDecoration = CssTextDecoration::None;
|
||||
bool hasDirection = false;
|
||||
CssTextDirection direction = CssTextDirection::Ltr;
|
||||
bool hasSup = false, sup = false;
|
||||
@@ -71,7 +72,7 @@ class ChapterHtmlSlimParser {
|
||||
CssStyle currentCssStyle;
|
||||
bool effectiveBold = false;
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
|
||||
bool effectiveDirectionDefined = false;
|
||||
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
||||
bool effectiveSup = false;
|
||||
@@ -96,12 +97,25 @@ class ChapterHtmlSlimParser {
|
||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||
int wordsExtractedInBlock = 0;
|
||||
|
||||
// Resumable parse state. The one-shot parseAndBuildPages() drives these
|
||||
// internally; the incremental section builder drives them across render ticks
|
||||
// so a large single chapter can yield between pages instead of blocking the UI
|
||||
// until the whole thing is laid out. parseFile_ and the expat parser stay alive
|
||||
// for the lifetime of the parse so it can be paused and resumed at buffer
|
||||
// boundaries.
|
||||
XML_Parser xmlParser_ = nullptr;
|
||||
HalFile parseFile_;
|
||||
uint32_t parseStartTime_ = 0;
|
||||
|
||||
void updateEffectiveInlineStyle();
|
||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||
void flushPendingAnchor();
|
||||
void flushPartWordBuffer();
|
||||
void makePages();
|
||||
static EpdFontFamily::Style fontStyleForTextDecoration(CssTextDecoration decoration);
|
||||
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
|
||||
static void applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css);
|
||||
void pushDecorationStyleEntry(CssTextDecoration defaultDecoration, const CssStyle& cssStyle);
|
||||
void emitHorizontalRule(const BlockStyle& blockStyle);
|
||||
// XML callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
@@ -141,8 +155,28 @@ class ChapterHtmlSlimParser {
|
||||
imageBasePath(imageBasePath),
|
||||
tocAnchors(std::move(tocAnchors)) {}
|
||||
|
||||
~ChapterHtmlSlimParser() = default;
|
||||
~ChapterHtmlSlimParser();
|
||||
|
||||
// One-shot parse: builds every page before returning (begin + step* + finish).
|
||||
bool parseAndBuildPages();
|
||||
|
||||
// Resumable parse, for the incremental section builder. Drive as:
|
||||
// if (!beginParse()) fail;
|
||||
// loop: switch (parseStep()) { More: keep going / yield; Done: finishParse(); Error: abortParse(); }
|
||||
// Pages are emitted via completePageFn as they complete during parseStep(), so
|
||||
// the caller can stop once enough pages are built and resume on a later tick.
|
||||
enum class ParseStatus { More, Done, Error };
|
||||
bool beginParse();
|
||||
ParseStatus parseStep();
|
||||
bool finishParse(); // flush the trailing page and tear down; returns true
|
||||
void abortParse(); // tear down without flushing (error / abandon)
|
||||
|
||||
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
||||
|
||||
// Byte progress of the in-flight parse, used to estimate a still-building section's total page
|
||||
// count (a giant single-spine book never fully lays out, so its real count is unknown). Valid
|
||||
// between beginParse() and finishParse()/abortParse().
|
||||
size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; }
|
||||
size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; }
|
||||
};
|
||||
|
||||
+43
-27
@@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace FsHelpers {
|
||||
@@ -36,12 +37,14 @@ std::string decodeUriEscapes(const std::string& path) {
|
||||
}
|
||||
|
||||
std::string normalisePath(const std::string& path) {
|
||||
std::vector<std::string> components;
|
||||
std::string component;
|
||||
std::vector<std::string_view> components;
|
||||
components.reserve(8); // Eight nested folders is more than we might expect
|
||||
|
||||
for (const auto c : path) {
|
||||
if (c == '/') {
|
||||
if (!component.empty()) {
|
||||
size_t start = 0;
|
||||
for (size_t i = 0; i <= path.length(); ++i) {
|
||||
if (i == path.length() || path[i] == '/') {
|
||||
if (i > start) {
|
||||
std::string_view component(path.data() + start, i - start);
|
||||
if (component == "..") {
|
||||
if (!components.empty()) {
|
||||
components.pop_back();
|
||||
@@ -49,51 +52,54 @@ std::string normalisePath(const std::string& path) {
|
||||
} else {
|
||||
components.push_back(component);
|
||||
}
|
||||
component.clear();
|
||||
}
|
||||
} else {
|
||||
component += c;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!component.empty()) {
|
||||
components.push_back(component);
|
||||
if (components.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size_t total_len = 0;
|
||||
for (const auto& c : components) {
|
||||
total_len += c.length() + 1;
|
||||
}
|
||||
|
||||
std::string result;
|
||||
for (const auto& c : components) {
|
||||
if (!result.empty()) {
|
||||
result += "/";
|
||||
result.reserve(total_len - 1);
|
||||
|
||||
for (size_t i = 0; i < components.size(); ++i) {
|
||||
if (i > 0) {
|
||||
result += '/';
|
||||
}
|
||||
result += c;
|
||||
result.append(components[i].data(), components[i].length());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
// Directories first
|
||||
bool isDir1 = str1.back() == '/';
|
||||
bool isDir2 = str2.back() == '/';
|
||||
if (isDir1 != isDir2) return isDir1;
|
||||
|
||||
// Start naive natural sort
|
||||
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)) {
|
||||
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++;
|
||||
while (isDigit(s1[len1])) len1++;
|
||||
while (isDigit(s2[len2])) len2++;
|
||||
|
||||
// Different length so return smaller integer value
|
||||
if (len1 != len2) return len1 < len2;
|
||||
@@ -108,8 +114,8 @@ void sortFileList(std::vector<std::string>& strs) {
|
||||
s2 += len2;
|
||||
} else {
|
||||
// Regular case-insensitive character comparison
|
||||
char c1 = tolower(*s1);
|
||||
char c2 = tolower(*s2);
|
||||
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++;
|
||||
@@ -118,6 +124,16 @@ void sortFileList(std::vector<std::string>& strs) {
|
||||
|
||||
// One string is prefix of other
|
||||
return *s1 == '\0' && *s2 != '\0';
|
||||
}
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
// Directories first
|
||||
bool isDir1 = str1.back() == '/';
|
||||
bool isDir2 = str2.back() == '/';
|
||||
if (isDir1 != isDir2) return isDir1;
|
||||
|
||||
return naturalLess(str1, str2);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path);
|
||||
|
||||
std::string normalisePath(const std::string& path);
|
||||
|
||||
// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders
|
||||
// before str2. Same ordering sortFileList applies within the file/directory groups.
|
||||
bool naturalLess(const std::string& str1, const std::string& str2);
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs);
|
||||
|
||||
/**
|
||||
|
||||
+247
-18
@@ -282,8 +282,17 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
|
||||
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
|
||||
|
||||
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
// Default: gray pixels paint solid black. Required when grayscale
|
||||
// passes follow (their LUT lightens pixels it assumes were just
|
||||
// driven solid black), and it is the plain non-AA look otherwise.
|
||||
// Fast AA: partial (anti-aliased edge) coverage dithers with the
|
||||
// same period-2 patterns as fillRectDither (dark grey = 50%
|
||||
// checkerboard, light grey = 25%), approximating AA in a single
|
||||
// BW pass. Skipped pixels leave the background untouched.
|
||||
if (!renderer.isFastAntiAliasing() || bmpVal == 0 || (bmpVal == 1 && ((screenX + screenY) & 1) == 0) ||
|
||||
(bmpVal == 2 && (screenX & 1) == 0 && (screenY & 1) == 0)) {
|
||||
renderer.drawPixel(screenX, screenY, pixelState);
|
||||
}
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// Dedicated X3 gray LUTs now provide proper 4-level gray on both devices
|
||||
@@ -661,8 +670,10 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
drawLine(x, fillY, x + width - 1, fillY, state);
|
||||
if (state) {
|
||||
fillRectImpl<Color::Black>(x, y, width, height);
|
||||
} else {
|
||||
fillRectImpl<Color::White>(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,26 +705,194 @@ void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) con
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
|
||||
if (color == Color::Clear) {
|
||||
} else if (color == Color::Black) {
|
||||
fillRect(x, y, width, height, true);
|
||||
} else if (color == Color::White) {
|
||||
fillRect(x, y, width, height, false);
|
||||
} else if (color == Color::LightGray) {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
for (int fillX = x; fillX < x + width; fillX++) {
|
||||
drawPixelDither<Color::LightGray>(fillX, fillY);
|
||||
switch (color) {
|
||||
case Color::Clear:
|
||||
break;
|
||||
case Color::Black:
|
||||
fillRectImpl<Color::Black>(x, y, width, height);
|
||||
break;
|
||||
case Color::White:
|
||||
fillRectImpl<Color::White>(x, y, width, height);
|
||||
break;
|
||||
case Color::LightGray:
|
||||
fillRectImpl<Color::LightGray>(x, y, width, height);
|
||||
break;
|
||||
case Color::DarkGray:
|
||||
fillRectImpl<Color::DarkGray>(x, y, width, height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
template <Color C>
|
||||
void GfxRenderer::fillRectImpl(const int x, const int y, const int width, const int height) const {
|
||||
if constexpr (C == Color::Clear) return;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
|
||||
|
||||
// Clip in logical space.
|
||||
const int screenW = getScreenWidth();
|
||||
const int screenH = getScreenHeight();
|
||||
const int lx0 = std::max(0, x);
|
||||
const int ly0 = std::max(0, y);
|
||||
const int lx1 = std::min(screenW, x + width);
|
||||
const int ly1 = std::min(screenH, y + height);
|
||||
if (lx0 >= lx1 || ly0 >= ly1) return;
|
||||
|
||||
// Rotate the two opposing logical corners into physical-framebuffer space.
|
||||
// The bounding rect in physical space is the rect we need to fill — rotation
|
||||
// is rigid (no shear/stretch) so the bbox of the two corners IS the rect.
|
||||
int paX, paY, pbX, pbY;
|
||||
rotateCoordinates(orientation, lx0, ly0, &paX, &paY, panelWidth, panelHeight);
|
||||
rotateCoordinates(orientation, lx1 - 1, ly1 - 1, &pbX, &pbY, panelWidth, panelHeight);
|
||||
|
||||
const int phyX0 = std::min(paX, pbX);
|
||||
const int phyX1 = std::max(paX, pbX); // inclusive
|
||||
int phyY0 = std::min(paY, pbY);
|
||||
int phyY1 = std::max(paY, pbY);
|
||||
|
||||
// Strip mode: clip Y range to the active band and redirect writes.
|
||||
uint8_t* target = getWriteTarget();
|
||||
const int originY = getWriteOriginY();
|
||||
const int writeRows = getWriteRows();
|
||||
phyY0 = std::max(phyY0, originY);
|
||||
phyY1 = std::min(phyY1, originY + writeRows - 1);
|
||||
if (phyY0 > phyY1) return;
|
||||
|
||||
// Bit/byte layout: MSB-first within a byte, so phyX → bit (7 - (phyX & 7)).
|
||||
// Head and tail masks cover only the in-rect bits of the first/last byte.
|
||||
const int byteStart = phyX0 >> 3;
|
||||
const int byteEnd = phyX1 >> 3; // inclusive
|
||||
const uint8_t headMask = static_cast<uint8_t>(0xFFu >> (phyX0 & 7));
|
||||
const uint8_t tailMask = static_cast<uint8_t>(0xFFu << (7 - (phyX1 & 7)));
|
||||
const int32_t panelStride = static_cast<int32_t>(panelWidthBytes);
|
||||
|
||||
if constexpr (C == Color::Black || C == Color::White) {
|
||||
// Solid fill. Framebuffer: 0 = black, 1 = white.
|
||||
const uint8_t fillByte = (C == Color::Black) ? 0x00u : 0xFFu;
|
||||
for (int py = phyY0; py <= phyY1; ++py) {
|
||||
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
|
||||
if (byteStart == byteEnd) {
|
||||
const uint8_t mask = headMask & tailMask;
|
||||
if constexpr (C == Color::Black) {
|
||||
row[byteStart] &= static_cast<uint8_t>(~mask);
|
||||
} else {
|
||||
row[byteStart] |= mask;
|
||||
}
|
||||
} else {
|
||||
if constexpr (C == Color::Black) {
|
||||
row[byteStart] &= static_cast<uint8_t>(~headMask);
|
||||
if (byteEnd > byteStart + 1) {
|
||||
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
|
||||
}
|
||||
row[byteEnd] &= static_cast<uint8_t>(~tailMask);
|
||||
} else {
|
||||
row[byteStart] |= headMask;
|
||||
if (byteEnd > byteStart + 1) {
|
||||
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
|
||||
}
|
||||
row[byteEnd] |= tailMask;
|
||||
}
|
||||
}
|
||||
} else if (color == Color::DarkGray) {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
for (int fillX = x; fillX < x + width; fillX++) {
|
||||
drawPixelDither<Color::DarkGray>(fillX, fillY);
|
||||
}
|
||||
} else {
|
||||
// Dither (LightGray / DarkGray). Both patterns have period 2 in logical
|
||||
// (x, y), so per physical row we precompute one byte that represents the
|
||||
// pattern across an 8-pixel stretch — every full byte in the row uses
|
||||
// that same value.
|
||||
//
|
||||
// dlxPerPhyX / dlyPerPhyX: how logical (x, y) change as phyX increments
|
||||
// along a physical row. Derived from inverting rotateCoordinates.
|
||||
int dlxPerPhyX = 0, dlyPerPhyX = 0;
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
dlxPerPhyX = 0;
|
||||
dlyPerPhyX = 1;
|
||||
break;
|
||||
case PortraitInverted:
|
||||
dlxPerPhyX = 0;
|
||||
dlyPerPhyX = -1;
|
||||
break;
|
||||
case LandscapeClockwise:
|
||||
dlxPerPhyX = -1;
|
||||
dlyPerPhyX = 0;
|
||||
break;
|
||||
case LandscapeCounterClockwise:
|
||||
dlxPerPhyX = 1;
|
||||
dlyPerPhyX = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// The dither pattern has period 2 in logical space, and each orientation
|
||||
// maps py to logical coords with a fixed parity relationship. The
|
||||
// blackMask byte therefore repeats with period 2 in py. Precompute both
|
||||
// variants outside the row loop to eliminate the per-row switch + 8-bit
|
||||
// construction loop.
|
||||
uint8_t blackMasks[2];
|
||||
for (int parityIdx = 0; parityIdx < 2; ++parityIdx) {
|
||||
const int samplePy = phyY0 + parityIdx;
|
||||
int lxBase = 0, lyBase = 0;
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
lxBase = panelHeight - 1 - samplePy;
|
||||
lyBase = byteStart * 8;
|
||||
break;
|
||||
case PortraitInverted:
|
||||
lxBase = samplePy;
|
||||
lyBase = panelWidth - 1 - byteStart * 8;
|
||||
break;
|
||||
case LandscapeClockwise:
|
||||
lxBase = panelWidth - 1 - byteStart * 8;
|
||||
lyBase = panelHeight - 1 - samplePy;
|
||||
break;
|
||||
case LandscapeCounterClockwise:
|
||||
lxBase = byteStart * 8;
|
||||
lyBase = samplePy;
|
||||
break;
|
||||
}
|
||||
uint8_t mask = 0;
|
||||
for (int b = 0; b < 8; ++b) {
|
||||
const int lx = lxBase + b * dlxPerPhyX;
|
||||
const int ly = lyBase + b * dlyPerPhyX;
|
||||
bool isBlack;
|
||||
if constexpr (C == Color::LightGray) {
|
||||
isBlack = ((lx & 1) == 0) && ((ly & 1) == 0);
|
||||
} else { // DarkGray
|
||||
isBlack = (((lx + ly) & 1) == 0);
|
||||
}
|
||||
if (isBlack) mask |= static_cast<uint8_t>(1u << (7 - b));
|
||||
}
|
||||
blackMasks[samplePy & 1] = mask;
|
||||
}
|
||||
|
||||
for (int py = phyY0; py <= phyY1; ++py) {
|
||||
const uint8_t blackMask = blackMasks[py & 1];
|
||||
const uint8_t whiteMask = static_cast<uint8_t>(~blackMask);
|
||||
|
||||
// Dither writes BOTH inks (the slow path called drawPixel for every
|
||||
// pixel — setting or clearing — so we must do the same). Inside the
|
||||
// rect mask: write whiteMask (1s where white, 0s where black). Outside
|
||||
// the rect mask: leave the framebuffer untouched.
|
||||
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
|
||||
if (byteStart == byteEnd) {
|
||||
const uint8_t rectMask = headMask & tailMask;
|
||||
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~rectMask) | (rectMask & whiteMask));
|
||||
} else {
|
||||
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~headMask) | (headMask & whiteMask));
|
||||
if (byteEnd > byteStart + 1) {
|
||||
// Period 2, so every full byte in this row is exactly whiteMask.
|
||||
memset(row + byteStart + 1, whiteMask, byteEnd - byteStart - 1);
|
||||
}
|
||||
row[byteEnd] = static_cast<uint8_t>((row[byteEnd] & ~tailMask) | (tailMask & whiteMask));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
template void GfxRenderer::fillRectImpl<Color::Black>(int, int, int, int) const;
|
||||
template void GfxRenderer::fillRectImpl<Color::White>(int, int, int, int) const;
|
||||
template void GfxRenderer::fillRectImpl<Color::LightGray>(int, int, int, int) const;
|
||||
template void GfxRenderer::fillRectImpl<Color::DarkGray>(int, int, int, int) const;
|
||||
|
||||
void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height,
|
||||
const int radius, const Color color) const {
|
||||
if (radius <= 0 || color == Color::Clear) {
|
||||
@@ -884,8 +1063,24 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
||||
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
||||
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
|
||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int size) const {
|
||||
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
|
||||
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
|
||||
// icon's position to 8px (one byte) along the rotated axis, which prevents it
|
||||
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
|
||||
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
|
||||
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
|
||||
// only called by the UI themes, which all render in forced Portrait.
|
||||
const int rowBytes = (size + 7) / 8;
|
||||
for (int row = 0; row < size; row++) {
|
||||
for (int col = 0; col < size; col++) {
|
||||
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
|
||||
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
|
||||
if (ink) {
|
||||
drawPixel(x + (size - 1 - row), y + col, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||
@@ -1437,8 +1632,18 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
|
||||
int32_t widthFP = 0;
|
||||
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
|
||||
const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style);
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
}
|
||||
const auto& font = fontIt->second;
|
||||
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
|
||||
int32_t advFP = sdIt->second->getAdvance(cp, styleIdx);
|
||||
if (advFP == 0 && !utf8IsCombiningMark(cp)) {
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
advFP = glyph ? glyph->advanceX : 0;
|
||||
}
|
||||
widthFP += isSupSub ? (advFP + 1) / 2 : advFP;
|
||||
}
|
||||
return fp4::toPixel(widthFP);
|
||||
@@ -1577,6 +1782,30 @@ size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
|
||||
// unused
|
||||
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
|
||||
|
||||
void GfxRenderer::displayGrayscaleBase(HalDisplay::RefreshMode fallback) const {
|
||||
display.displayGrayscaleBase(fallback, fadingFix);
|
||||
}
|
||||
|
||||
void GfxRenderer::preconditionGrayscale() const { display.preconditionGrayscale(); }
|
||||
|
||||
void GfxRenderer::preconditionGrayscale(int x, int y, int w, int h) const {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
// Rotate the logical rect's opposite corners to physical panel coords; the
|
||||
// physical bbox stays axis-aligned for all four orientations.
|
||||
int ax, ay, bx, by;
|
||||
rotateCoordinates(orientation, x, y, &ax, &ay, panelWidth, panelHeight);
|
||||
rotateCoordinates(orientation, x + w - 1, y + h - 1, &bx, &by, panelWidth, panelHeight);
|
||||
int x0 = ax < bx ? ax : bx, x1 = ax > bx ? ax : bx;
|
||||
int y0 = ay < by ? ay : by, y1 = ay > by ? ay : by;
|
||||
if (x0 < 0) x0 = 0;
|
||||
if (y0 < 0) y0 = 0;
|
||||
if (x1 >= panelWidth) x1 = panelWidth - 1;
|
||||
if (y1 >= panelHeight) y1 = panelHeight - 1;
|
||||
if (x1 < x0 || y1 < y0) return;
|
||||
display.preconditionGrayscale(static_cast<uint16_t>(x0), static_cast<uint16_t>(y0),
|
||||
static_cast<uint16_t>(x1 - x0 + 1), static_cast<uint16_t>(y1 - y0 + 1));
|
||||
}
|
||||
|
||||
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); }
|
||||
|
||||
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
|
||||
|
||||
@@ -44,6 +44,13 @@ class GfxRenderer {
|
||||
RenderMode renderMode;
|
||||
Orientation orientation;
|
||||
bool fadingFix;
|
||||
// Fast text anti-aliasing: when set, BW-mode rendering of 2-bit glyphs
|
||||
// dithers partial-coverage (gray) pixels instead of painting them solid
|
||||
// black, approximating AA in a single BW pass with no grayscale refresh.
|
||||
// Must stay false when grayscale LSB/MSB passes follow the BW render: the
|
||||
// gray LUT lightens pixels it assumes were just driven solid black, and
|
||||
// dither-skipped white pixels would take its white-drive frames unopposed.
|
||||
bool fastAntiAliasing = false;
|
||||
uint8_t* frameBuffer = nullptr;
|
||||
uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH;
|
||||
uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT;
|
||||
@@ -81,6 +88,12 @@ class GfxRenderer {
|
||||
void drawPixelDither(int x, int y) const;
|
||||
template <Color color>
|
||||
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
|
||||
// Byte-aligned, orientation-specialized rectangle fill. Rotates the rect's
|
||||
// two opposing corners into physical-framebuffer space once, then walks each
|
||||
// physical row with head-mask / middle memset / tail-mask byte writes — no
|
||||
// per-pixel rotation, no per-pixel RMW.
|
||||
template <Color color>
|
||||
void fillRectImpl(int x, int y, int width, int height) const;
|
||||
|
||||
public:
|
||||
explicit GfxRenderer(HalDisplay& halDisplay)
|
||||
@@ -177,7 +190,7 @@ class GfxRenderer {
|
||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
||||
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawIcon(const uint8_t bitmap[], int x, int y, int size) const;
|
||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||
float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
@@ -218,6 +231,18 @@ class GfxRenderer {
|
||||
// Grayscale functions
|
||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||
RenderMode getRenderMode() const { return renderMode; }
|
||||
void setFastAntiAliasing(const bool enabled) { this->fastAntiAliasing = enabled; }
|
||||
bool isFastAntiAliasing() const { return fastAntiAliasing; }
|
||||
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
|
||||
// takes the gray region in LOGICAL screen coordinates and rotates it to the
|
||||
// panel; the no-arg overload settles the full frame. Call after the BW base
|
||||
// frame is displayed and before the grayscale planes are written.
|
||||
void preconditionGrayscale() const;
|
||||
void preconditionGrayscale(int x, int y, int w, int h) const;
|
||||
// Display the framebuffer as the base frame for a grayscale overlay that
|
||||
// follows (X3: OEM differential base waveform; others: plain display with
|
||||
// `fallback`).
|
||||
void displayGrayscaleBase(HalDisplay::RefreshMode fallback = HalDisplay::HALF_REFRESH) const;
|
||||
void copyGrayscaleLsbBuffers() const;
|
||||
void copyGrayscaleMsbBuffers() const;
|
||||
void displayGrayBuffer() const;
|
||||
|
||||
@@ -65,11 +65,14 @@ STR_SLEEP_COVER_MODE: "Рэжым вокладкі сну"
|
||||
STR_HIDE_BATTERY: "Схаваць % батарэі"
|
||||
STR_EXTRA_SPACING: "Дадат. інтэрвал абзаца"
|
||||
STR_TEXT_AA: "Згладжванне тэксту"
|
||||
STR_TEXT_AA_FULL: "Поўнае"
|
||||
STR_TEXT_AA_FAST: "Хуткае"
|
||||
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
||||
STR_ORIENTATION: "Арыентацыя чытання"
|
||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
||||
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
||||
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||
STR_FONT_FAMILY: "Шрыфт чытання"
|
||||
STR_FONT_SIZE: "Памер шрыфту інтэрфейсу"
|
||||
STR_LINE_SPACING: "Міжрадковы інтэрвал"
|
||||
@@ -125,6 +128,7 @@ STR_PAGE_TURN: "Перагортванне"
|
||||
STR_PORTRAIT: "Партрэт"
|
||||
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
||||
STR_INVERTED: "Інверсія"
|
||||
STR_ORIENTATION_INVERTED: "Партрэт 180°"
|
||||
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
||||
STR_PREV_NEXT: "Назад/Наперад"
|
||||
STR_NEXT_PREV: "Наперад/Назад"
|
||||
@@ -175,6 +179,9 @@ STR_EXIT: "« Выхад"
|
||||
STR_HOME: "« Галоўная"
|
||||
STR_SELECT: "Абраць"
|
||||
STR_TOGGLE: "Выбар"
|
||||
STR_TOGGLE_BOOKMARK: "Пераключыць закладку"
|
||||
STR_BOOKMARK_REMOVED: "Закладка выдалена."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Утрымлівайце Адкрыць, каб выдаліць"
|
||||
STR_CONFIRM: "Пацв."
|
||||
STR_CANCEL: "Адмена"
|
||||
STR_CONNECT: "Падкл."
|
||||
@@ -231,7 +238,6 @@ STR_CHAPTER_PREFIX: "Раздзел:"
|
||||
STR_PAGES_SEPARATOR: "стар. |"
|
||||
STR_BOOK_PREFIX: "Кніга:"
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
||||
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
|
||||
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
||||
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
||||
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
||||
@@ -291,7 +297,8 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
|
||||
STR_LINK: "[спасылка]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||
STR_SLEEP_NEVER: "Ніколі"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
|
||||
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
||||
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
|
||||
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
||||
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
||||
STR_TEXT_AA: "Antialiàsing del text"
|
||||
STR_TEXT_AA_FULL: "Complet"
|
||||
STR_TEXT_AA_FAST: "Ràpid"
|
||||
STR_IMAGES: "Imatges"
|
||||
STR_IMAGES_DISPLAY: "Mostrar"
|
||||
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
|
||||
STR_LONG_PRESS_MENU: "Funció de pulsació llarga"
|
||||
STR_FONT_FAMILY: "Tipus de lletra"
|
||||
STR_FONT_SIZE: "Mida de la lletra (UI)"
|
||||
STR_LINE_SPACING: "Interlineat del lector"
|
||||
@@ -135,9 +138,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Horitzontal horari"
|
||||
STR_INVERTED: "Invertit"
|
||||
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
||||
STR_PREV_NEXT: "Anterior/Següent"
|
||||
STR_NEXT_PREV: "Següent/Anterior"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Punt de llibre"
|
||||
STR_DISABLED: "Desactivats"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -172,8 +178,7 @@ STR_DOWNLOADING: "S'està baixant..."
|
||||
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
||||
STR_ERROR_MSG: "Error:"
|
||||
STR_UNNAMED: "Sense nom"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
|
||||
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
||||
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
||||
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
||||
@@ -189,6 +194,7 @@ STR_HOME: "« Inici"
|
||||
STR_SELECT: "Selecciona"
|
||||
STR_SELECTED: "Seleccionat"
|
||||
STR_TOGGLE: "Canvia"
|
||||
STR_TOGGLE_BOOKMARK: "Commuta punt de llibre"
|
||||
STR_CONFIRM: "Confirma"
|
||||
STR_CANCEL: "Cancel·la"
|
||||
STR_CONNECT: "Connecta"
|
||||
@@ -233,6 +239,7 @@ STR_QUICK_RESUME_TIMEOUT: "Represa ràpida després del temps"
|
||||
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
||||
STR_BOOKMARKS: "Punts de llibre"
|
||||
STR_BOOKMARK_ADDED: "S'ha afegit el punt de llibre."
|
||||
STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
|
||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
||||
STR_QUICK_RESUME: "Represa ràpida"
|
||||
@@ -267,7 +274,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
||||
STR_PAGES_SEPARATOR: " pàgines | "
|
||||
STR_BOOK_PREFIX: "Llibre: "
|
||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||
@@ -294,12 +300,14 @@ STR_BOOK_S_STYLE: "Estil del llibre"
|
||||
STR_EMBEDDED_STYLE: "Estil incrustat"
|
||||
STR_FOCUS_READING: "Lectura enfocada"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu"
|
||||
STR_FOOTNOTES: "Notes al peu"
|
||||
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
||||
STR_LINK: "[enllaç]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
||||
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
||||
@@ -338,6 +346,7 @@ STR_MANAGE_FONTS: "Gestiona els tipus de lletra"
|
||||
STR_FONT_BROWSER: "Navegador de tipus de lletra"
|
||||
STR_LOADING_FONT_LIST: "S'està carregant la llista de tipus de lletra..."
|
||||
STR_NO_FONTS_AVAILABLE: "No hi ha tipus de lletra disponibles"
|
||||
STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!"
|
||||
STR_FONT_INSTALLED: "Tipus de lletra instal·lat!"
|
||||
STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació del tipus de lletra"
|
||||
STR_INSTALLED: "Instal·lat"
|
||||
|
||||
@@ -58,13 +58,15 @@ STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
|
||||
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
||||
STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čtečka"
|
||||
STR_CAT_CONTROLS: "Ovládací prvky"
|
||||
STR_CAT_CONTROLS: "Ovládání"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||
STR_HIDE_BATTERY: "Skrýt baterii %"
|
||||
STR_EXTRA_SPACING: "Extra mezery mezi odstavci"
|
||||
STR_TEXT_AA: "Vyhlazování textu"
|
||||
STR_TEXT_AA_FULL: "Plné"
|
||||
STR_TEXT_AA_FAST: "Rychlé"
|
||||
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
||||
STR_ORIENTATION: "Orientace čtení"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
||||
@@ -73,6 +75,7 @@ STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Změna orientace"
|
||||
STR_FONT_PREVIEW_TEXT: "Příliš žluťoučký kůň úpěl ďábelské ódy"
|
||||
STR_FONT_FAMILY: "Rodina písem čtečky"
|
||||
STR_FONT_SIZE: "Velikost písma rozhraní"
|
||||
STR_LINE_SPACING: "Řádkování čtečky"
|
||||
@@ -130,6 +133,7 @@ STR_PAGE_TURN: "Otáčení stránek"
|
||||
STR_PORTRAIT: "Na výšku"
|
||||
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
|
||||
STR_INVERTED: "Invertovaný"
|
||||
STR_ORIENTATION_INVERTED: "Na výšku 180°"
|
||||
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
|
||||
STR_PREV_NEXT: "Předchozí/Další"
|
||||
STR_NEXT_PREV: "Další/Předchozí"
|
||||
@@ -180,6 +184,9 @@ STR_EXIT: "« Konec"
|
||||
STR_HOME: "« Domů"
|
||||
STR_SELECT: "Vybrat"
|
||||
STR_TOGGLE: "Přepnout"
|
||||
STR_TOGGLE_BOOKMARK: "Přepnout záložku"
|
||||
STR_BOOKMARK_REMOVED: "Záložka odstraněna."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Podržte Otevřít pro smazání"
|
||||
STR_CONFIRM: "Potvrdit"
|
||||
STR_CANCEL: "Zrušit"
|
||||
STR_CONNECT: "Připojit"
|
||||
@@ -238,7 +245,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "stránek |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
|
||||
STR_PERCENT_STEP_HINT: "Vlevo/Vpravo: 1 % Nahoru/Dolů: 10 %"
|
||||
STR_SYNCING_TIME: "Čas synchronizace..."
|
||||
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
||||
@@ -268,5 +274,6 @@ STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Přední tlačítka:"
|
||||
STR_STEP_HINT_SIDE: "Boční tlačítka:"
|
||||
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Hvile-skærm omslag-tilstand"
|
||||
STR_HIDE_BATTERY: "Skjul batteri %"
|
||||
STR_EXTRA_SPACING: "Ekstra afsnitsafstand"
|
||||
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
||||
STR_TEXT_AA_FULL: "Fuld"
|
||||
STR_TEXT_AA_FAST: "Hurtig"
|
||||
STR_IMAGES: "Billeder"
|
||||
STR_IMAGES_DISPLAY: "Vis"
|
||||
STR_IMAGES_PLACEHOLDER: "Pladsholder"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación"
|
||||
STR_FONT_PREVIEW_TEXT: "Høj bly gom vandt fræk sexquiz på wc"
|
||||
STR_FONT_FAMILY: "Læser skrifttype"
|
||||
STR_FONT_SIZE: "Læser skriftstørrelse"
|
||||
STR_LINE_SPACING: "Linjeafstand"
|
||||
@@ -135,6 +138,7 @@ STR_PAGE_TURN: "Sideskift"
|
||||
STR_PORTRAIT: "Portræt"
|
||||
STR_LANDSCAPE_CW: "Liggende med uret"
|
||||
STR_INVERTED: "Inverteret"
|
||||
STR_ORIENTATION_INVERTED: "Portræt 180°"
|
||||
STR_LANDSCAPE_CCW: "Liggende mod uret"
|
||||
STR_PREV_NEXT: "Forrige/Næste"
|
||||
STR_NEXT_PREV: "Næste/Forrige"
|
||||
@@ -186,6 +190,9 @@ STR_HOME: "« Hjem"
|
||||
STR_SELECT: "Vælg"
|
||||
STR_SELECTED: "Valgt"
|
||||
STR_TOGGLE: "Skift"
|
||||
STR_TOGGLE_BOOKMARK: "Skift bogmærke"
|
||||
STR_BOOKMARK_REMOVED: "Bogmærke fjernet."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Hold Åbn nede for at slette"
|
||||
STR_CONFIRM: "Bekræft"
|
||||
STR_CANCEL: "Annuller"
|
||||
STR_CONNECT: "Forbind"
|
||||
@@ -261,7 +268,6 @@ STR_CHAPTER_PREFIX: "Kapitel: "
|
||||
STR_PAGES_SEPARATOR: " sider | "
|
||||
STR_BOOK_PREFIX: "Bog: "
|
||||
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
|
||||
STR_PERCENT_STEP_HINT: "Venstre/Højre: 1% Op/Ned: 10%"
|
||||
STR_SYNCING_TIME: "Synkroniserer tid..."
|
||||
STR_CALC_HASH: "Beregner dokument-hash..."
|
||||
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
||||
@@ -293,7 +299,8 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Aldrig"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Frontknapper:"
|
||||
STR_STEP_HINT_SIDE: "Sideknapper:"
|
||||
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Slaapscherm omslag-modus"
|
||||
STR_HIDE_BATTERY: "Batterij % verbergen"
|
||||
STR_EXTRA_SPACING: "Extra regelafstand alinea"
|
||||
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
||||
STR_TEXT_AA_FULL: "Volledig"
|
||||
STR_TEXT_AA_FAST: "Snel"
|
||||
STR_IMAGES: "Afbeeldingen"
|
||||
STR_IMAGES_DISPLAY: "Weergave"
|
||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
||||
STR_FONT_PREVIEW_TEXT: "Pa's wijze lynx bezag vroom het fikse aquaduct"
|
||||
STR_FONT_FAMILY: "Lettertype lezer"
|
||||
STR_FONT_SIZE: "Lettergrootte lezer"
|
||||
STR_LINE_SPACING: "Regelafstand lezer"
|
||||
@@ -134,7 +137,8 @@ STR_SLEEP: "Slaap"
|
||||
STR_PAGE_TURN: "Pagina omslaan"
|
||||
STR_PORTRAIT: "Staand"
|
||||
STR_LANDSCAPE_CW: "Liggend (rechtsom)"
|
||||
STR_INVERTED: "Omgekeerd"
|
||||
STR_INVERTED: "Geïnverteerd"
|
||||
STR_ORIENTATION_INVERTED: "Staand 180°"
|
||||
STR_LANDSCAPE_CCW: "Liggend (linksom)"
|
||||
STR_PREV_NEXT: "Vorige/Volgende"
|
||||
STR_NEXT_PREV: "Volgende/Vorige"
|
||||
@@ -186,6 +190,9 @@ STR_HOME: "« Home"
|
||||
STR_SELECT: "Kies"
|
||||
STR_SELECTED: "Geselecteerd"
|
||||
STR_TOGGLE: "Wissel"
|
||||
STR_TOGGLE_BOOKMARK: "Bladwijzer wisselen"
|
||||
STR_BOOKMARK_REMOVED: "Bladwijzer verwijderd."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Houd Openen ingedrukt om te verwijderen"
|
||||
STR_CONFIRM: "Bevestig"
|
||||
STR_CANCEL: "Annuleer"
|
||||
STR_CONNECT: "Verbind"
|
||||
@@ -261,7 +268,6 @@ STR_CHAPTER_PREFIX: "Hoofdstuk: "
|
||||
STR_PAGES_SEPARATOR: " pagina's | "
|
||||
STR_BOOK_PREFIX: "Boek: "
|
||||
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
|
||||
STR_PERCENT_STEP_HINT: "Links/Rechts: 1% Omhoog/Omlaag: 10%"
|
||||
STR_SYNCING_TIME: "Tijd synchroniseren..."
|
||||
STR_CALC_HASH: "Document-hash berekenen..."
|
||||
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
||||
@@ -293,7 +299,8 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nooit"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
||||
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||
|
||||
@@ -66,10 +66,14 @@ STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
|
||||
STR_HIDE_BATTERY: "Hide Battery %"
|
||||
STR_EXTRA_SPACING: "Extra Paragraph Spacing"
|
||||
STR_TEXT_AA: "Text Anti-Aliasing"
|
||||
STR_TEXT_AA_FULL: "Full"
|
||||
STR_TEXT_AA_FAST: "Fast"
|
||||
STR_IMAGES: "Images"
|
||||
STR_IMAGES_DISPLAY: "Display"
|
||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||
STR_IMAGES_SUPPRESS: "Suppress"
|
||||
STR_EOB_HOME: "Home"
|
||||
STR_EOB_CONTINUE_WITH: "Continue with"
|
||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||
STR_ORIENTATION: "Reading Orientation"
|
||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||
@@ -78,6 +82,8 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
||||
STR_LONG_PRESS_MENU: "Long-press Menu"
|
||||
STR_FONT_PREVIEW_TEXT: "The quick brown fox jumps over the lazy dog"
|
||||
STR_FONT_FAMILY: "Reader Font Family"
|
||||
STR_FONT_SIZE: "Reader Font Size"
|
||||
STR_LINE_SPACING: "Reader Line Spacing"
|
||||
@@ -137,9 +143,12 @@ STR_FORCE_REFRESH: "Refresh Screen"
|
||||
STR_PORTRAIT: "Portrait"
|
||||
STR_LANDSCAPE_CW: "Landscape CW"
|
||||
STR_INVERTED: "Inverted"
|
||||
STR_ORIENTATION_INVERTED: "Portrait 180°"
|
||||
STR_LANDSCAPE_CCW: "Landscape CCW"
|
||||
STR_PREV_NEXT: "Prev/Next"
|
||||
STR_NEXT_PREV: "Next/Prev"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Bookmark"
|
||||
STR_DISABLED: "Disabled"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -175,8 +184,7 @@ STR_DOWNLOADING: "Downloading..."
|
||||
STR_DOWNLOAD_FAILED: "Download failed"
|
||||
STR_ERROR_MSG: "Error:"
|
||||
STR_UNNAMED: "Unnamed"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Hold Confirm to Delete"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Hold Confirm from the reader to create a bookmark."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"
|
||||
STR_NO_SERVER_URL: "No server URL configured"
|
||||
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
||||
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
||||
@@ -194,6 +202,7 @@ STR_HOME: "« Home"
|
||||
STR_SELECT: "Select"
|
||||
STR_SELECTED: "Selected"
|
||||
STR_TOGGLE: "Toggle"
|
||||
STR_TOGGLE_BOOKMARK: "Toggle Bookmark"
|
||||
STR_CONFIRM: "Confirm"
|
||||
STR_CANCEL: "Cancel"
|
||||
STR_CONNECT: "Connect"
|
||||
@@ -256,6 +265,7 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
||||
STR_BOOKMARKS: "Bookmarks"
|
||||
STR_BOOKMARK_ADDED: "Bookmark added."
|
||||
STR_BOOKMARK_REMOVED: "Bookmark removed."
|
||||
STR_OPDS_BROWSER: "OPDS Browser"
|
||||
STR_SEARCH: "Search"
|
||||
STR_COVER_CUSTOM: "Cover + Custom"
|
||||
@@ -291,7 +301,6 @@ STR_CHAPTER_PREFIX: "Chapter: "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
STR_BOOK_PREFIX: "Book: "
|
||||
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
|
||||
STR_PERCENT_STEP_HINT: "Left/Right: 1% Up/Down: 10%"
|
||||
STR_SYNCING_TIME: "Syncing time..."
|
||||
STR_CALC_HASH: "Calculating document hash..."
|
||||
STR_HASH_FAILED: "Failed to calculate document hash"
|
||||
@@ -326,7 +335,8 @@ STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Never"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Front buttons:"
|
||||
STR_STEP_HINT_SIDE: "Side buttons:"
|
||||
STR_ADD_SERVER: "Add Server"
|
||||
STR_SERVER_NAME: "Server Name"
|
||||
STR_NO_SERVERS: "No OPDS servers configured"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Lepotilanäytön kansitila"
|
||||
STR_HIDE_BATTERY: "Piilota akun %"
|
||||
STR_EXTRA_SPACING: "Kappaleiden lisäväli"
|
||||
STR_TEXT_AA: "Tekstin reunanpehmennys"
|
||||
STR_TEXT_AA_FULL: "Täysi"
|
||||
STR_TEXT_AA_FAST: "Nopea"
|
||||
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
||||
STR_ORIENTATION: "Lukusuunta"
|
||||
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
||||
@@ -73,6 +75,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
||||
STR_FONT_PREVIEW_TEXT: "Törkylempijävongahdus"
|
||||
STR_FONT_FAMILY: "Lukijan fonttiperhe"
|
||||
STR_FONT_SIZE: "Käyttöliittymän fonttikoko"
|
||||
STR_LINE_SPACING: "Lukijan riviväli"
|
||||
@@ -129,7 +132,8 @@ STR_SLEEP: "Lepotila"
|
||||
STR_PAGE_TURN: "Sivunkääntö"
|
||||
STR_PORTRAIT: "Pysty"
|
||||
STR_LANDSCAPE_CW: "Vaaka myötäpäivään"
|
||||
STR_INVERTED: "Käännetty"
|
||||
STR_INVERTED: "Käänteinen"
|
||||
STR_ORIENTATION_INVERTED: "Pysty 180°"
|
||||
STR_LANDSCAPE_CCW: "Vaaka vastapäivään"
|
||||
STR_PREV_NEXT: "Edell/Seur"
|
||||
STR_NEXT_PREV: "Seur/Edell"
|
||||
@@ -180,6 +184,9 @@ STR_EXIT: "« Poistu"
|
||||
STR_HOME: "« Koti"
|
||||
STR_SELECT: "Valitse"
|
||||
STR_TOGGLE: "Vaihda"
|
||||
STR_TOGGLE_BOOKMARK: "Vaihda kirjanmerkki"
|
||||
STR_BOOKMARK_REMOVED: "Kirjanmerkki poistettu."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Pidä Avaa painettuna poistaaksesi"
|
||||
STR_CONFIRM: "Vahvista"
|
||||
STR_CANCEL: "Peruuta"
|
||||
STR_CONNECT: "Yhdistä"
|
||||
@@ -236,7 +243,6 @@ STR_CHAPTER_PREFIX: "Luku: "
|
||||
STR_PAGES_SEPARATOR: " sivua | "
|
||||
STR_BOOK_PREFIX: "Kirja: "
|
||||
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
|
||||
STR_PERCENT_STEP_HINT: "Vasen/Oikea: 1% Ylös/Alas: 10%"
|
||||
STR_SYNCING_TIME: "Synkronoidaan aikaa..."
|
||||
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
||||
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
||||
@@ -266,5 +272,6 @@ STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
||||
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Ei koskaan"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Etupainikkeet:"
|
||||
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
|
||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mode écran de veille"
|
||||
STR_HIDE_BATTERY: "Masquer % batterie"
|
||||
STR_EXTRA_SPACING: "Espacement paragraphes"
|
||||
STR_TEXT_AA: "Lissage du texte"
|
||||
STR_TEXT_AA_FULL: "Complet"
|
||||
STR_TEXT_AA_FAST: "Rapide"
|
||||
STR_IMAGES: "Images"
|
||||
STR_IMAGES_DISPLAY: "Affichage"
|
||||
STR_IMAGES_PLACEHOLDER: "Espace réservé"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Changement d'orientation"
|
||||
STR_FONT_PREVIEW_TEXT: "Portez ce vieux whisky au juge blond qui fume"
|
||||
STR_FONT_FAMILY: "Police de caractères du lecteur"
|
||||
STR_FONT_SIZE: "Taille police lecteur"
|
||||
STR_LINE_SPACING: "Interligne"
|
||||
@@ -135,6 +138,7 @@ STR_PAGE_TURN: "Page suivante"
|
||||
STR_PORTRAIT: "Portrait"
|
||||
STR_LANDSCAPE_CW: "Paysage"
|
||||
STR_INVERTED: "Inversé"
|
||||
STR_ORIENTATION_INVERTED: "Portrait 180°"
|
||||
STR_LANDSCAPE_CCW: "Paysage inversé"
|
||||
STR_PREV_NEXT: "Préc/Suiv"
|
||||
STR_NEXT_PREV: "Suiv/Préc"
|
||||
@@ -186,6 +190,9 @@ STR_HOME: "« Accueil"
|
||||
STR_SELECT: "OK"
|
||||
STR_SELECTED: "Sélectionné"
|
||||
STR_TOGGLE: "Modifier"
|
||||
STR_TOGGLE_BOOKMARK: "Basculer le marque-page"
|
||||
STR_BOOKMARK_REMOVED: "Marque-page supprimé."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Maintenir Ouvrir pour supprimer"
|
||||
STR_CONFIRM: "Confirmer"
|
||||
STR_CANCEL: "Annuler"
|
||||
STR_CONNECT: "Connecter"
|
||||
@@ -262,7 +269,6 @@ STR_CHAPTER_PREFIX: "Chapitre : "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
STR_BOOK_PREFIX: "Livre : "
|
||||
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
||||
STR_PERCENT_STEP_HINT: "Gauche/Droite : 1% Haut/Bas : 10%"
|
||||
STR_SYNCING_TIME: "Synchro de l’heure…"
|
||||
STR_CALC_HASH: "Calcul hash doc…"
|
||||
STR_HASH_FAILED: "Échec calcul hash"
|
||||
@@ -294,7 +300,8 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
|
||||
STR_LINK: "[lien]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Jamais"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
|
||||
STR_STEP_HINT_FRONT: "Boutons avant :"
|
||||
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
||||
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||
|
||||
@@ -65,21 +65,25 @@ STR_SLEEP_COVER_MODE: "Standby-Bildmodus"
|
||||
STR_HIDE_BATTERY: "Batterie % ausblenden"
|
||||
STR_EXTRA_SPACING: "Absatzabstand"
|
||||
STR_TEXT_AA: "Schriftglättung"
|
||||
STR_TEXT_AA_FULL: "Voll"
|
||||
STR_TEXT_AA_FAST: "Schnell"
|
||||
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
||||
STR_ORIENTATION: "Leseausrichtung"
|
||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "AUS"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern"
|
||||
STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"
|
||||
STR_FONT_FAMILY: "Lese-Schriftfamilie"
|
||||
STR_FONT_SIZE: "Schriftgröße"
|
||||
STR_LINE_SPACING: "Lese-Zeilenabstand"
|
||||
STR_SCREEN_MARGIN: "Lese-Seitenränder"
|
||||
STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung"
|
||||
STR_LONG_PRESS_MENU: "Menütaste lang drücken"
|
||||
STR_HYPHENATION: "Silbentrennung"
|
||||
STR_TIME_TO_SLEEP: "Standby nach"
|
||||
STR_TIME_TO_SLEEP: "Standby-Modus nach"
|
||||
STR_REFRESH_FREQ: "Anti-Ghosting nach"
|
||||
STR_KOREADER_SYNC: "KOReader-Synchr."
|
||||
STR_CHECK_UPDATES: "Nach Updates suchen"
|
||||
@@ -128,7 +132,8 @@ STR_PAGE_TURN: "Umblättern"
|
||||
STR_FORCE_REFRESH: "Bildschirm regenerieren"
|
||||
STR_PORTRAIT: "Hochformat"
|
||||
STR_LANDSCAPE_CW: "Querformat rechts"
|
||||
STR_INVERTED: "Hochformat 180°"
|
||||
STR_INVERTED: "Invertiert"
|
||||
STR_ORIENTATION_INVERTED: "Hochformat 180°"
|
||||
STR_LANDSCAPE_CCW: "Querformat links"
|
||||
STR_PREV_NEXT: "Zurück/Weiter"
|
||||
STR_NEXT_PREV: "Weiter/Zurück"
|
||||
@@ -167,8 +172,7 @@ STR_DOWNLOADING: "Herunterladen…"
|
||||
STR_DOWNLOAD_FAILED: "Ladefehler"
|
||||
STR_ERROR_MSG: "Fehler:"
|
||||
STR_UNNAMED: "Unbenannt"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Halte Bestätigen zum Löschen"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Halte Bestätigen im Lesemodus um ein Lesezeichen anzulegen."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen"
|
||||
STR_NO_SERVER_URL: "Keine Server-URL konfiguriert"
|
||||
STR_FETCH_FEED_FAILED: "Feedfehler"
|
||||
STR_PARSE_FEED_FAILED: "Feed-Format ungültig"
|
||||
@@ -186,6 +190,7 @@ STR_HOME: "« Start"
|
||||
STR_SELECT: "Auswahl"
|
||||
STR_SELECTED: "Ausgewählt"
|
||||
STR_TOGGLE: "Ändern"
|
||||
STR_TOGGLE_BOOKMARK: "Lesezeichen umschalten"
|
||||
STR_CONFIRM: "Bestätigen"
|
||||
STR_CANCEL: "Abbrechen"
|
||||
STR_CONNECT: "Verbinden"
|
||||
@@ -249,6 +254,7 @@ STR_QUICK_RESUME_TIMEOUT: "Schnelles Fortsetzen nach Timeout"
|
||||
STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen"
|
||||
STR_BOOKMARKS: "Lesezeichen"
|
||||
STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt."
|
||||
STR_BOOKMARK_REMOVED: "Lesezeichen entfernt."
|
||||
STR_SEARCH: "Suche"
|
||||
STR_OPDS_BROWSER: "OPDS-Browser"
|
||||
STR_COVER_CUSTOM: "Cover + Eigenes"
|
||||
@@ -284,7 +290,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
||||
STR_PAGES_SEPARATOR: " Seiten | "
|
||||
STR_BOOK_PREFIX: "Buch: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
|
||||
STR_PERCENT_STEP_HINT: "links/rechts: 1% hoch/runter: 10%"
|
||||
STR_SYNCING_TIME: "Zeit synchronisieren…"
|
||||
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
||||
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
||||
@@ -318,7 +323,8 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
|
||||
STR_LINK: "[Link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
||||
STR_SLEEP_NEVER: "Nie"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
|
||||
STR_STEP_HINT_FRONT: "Vordere Tasten:"
|
||||
STR_STEP_HINT_SIDE: "Seitentasten:"
|
||||
STR_ADD_SERVER: "Server hinzufügen"
|
||||
STR_SERVER_NAME: "Servername"
|
||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "תצוגת כריכה במצב שינה"
|
||||
STR_HIDE_BATTERY: "הסתר אחוזי סוללה"
|
||||
STR_EXTRA_SPACING: "מרווח פסקאות מוגדל"
|
||||
STR_TEXT_AA: "החלקת קצוות טקסט"
|
||||
STR_TEXT_AA_FULL: "מלאה"
|
||||
STR_TEXT_AA_FAST: "מהירה"
|
||||
STR_IMAGES: "תמונות"
|
||||
STR_IMAGES_DISPLAY: "הצג תמונות"
|
||||
STR_IMAGES_PLACEHOLDER: "סמן"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "שנה כיוון מסך"
|
||||
STR_FONT_PREVIEW_TEXT: "דג סקרן שט בים מאוכזב ולפתע מצא חברה"
|
||||
STR_FONT_FAMILY: "גופן הקריאה"
|
||||
STR_FONT_SIZE: "גודל גופן"
|
||||
STR_LINE_SPACING: "מרווח בין שורות"
|
||||
@@ -133,7 +136,8 @@ STR_PAGE_TURN: "העברת דף"
|
||||
STR_FORCE_REFRESH: "רענון מסך מלא"
|
||||
STR_PORTRAIT: "לאורך"
|
||||
STR_LANDSCAPE_CW: "לרוחב (ימינה)"
|
||||
STR_INVERTED: "הפוך"
|
||||
STR_INVERTED: "היפוך צבעים"
|
||||
STR_ORIENTATION_INVERTED: "לאורך 180°"
|
||||
STR_LANDSCAPE_CCW: "לרוחב (שמאלה)"
|
||||
STR_PREV_NEXT: "הקודם/הבא"
|
||||
STR_NEXT_PREV: "הבא/הקודם"
|
||||
@@ -188,6 +192,7 @@ STR_HOME: "מסך הבית »"
|
||||
STR_SELECT: "בחר"
|
||||
STR_SELECTED: "נבחר"
|
||||
STR_TOGGLE: "בחר"
|
||||
STR_TOGGLE_BOOKMARK: "הוסף/הסר סימנייה"
|
||||
STR_CONFIRM: "אישור"
|
||||
STR_CANCEL: "ביטול"
|
||||
STR_CONNECT: "התחבר"
|
||||
@@ -265,7 +270,6 @@ STR_CHAPTER_PREFIX: "פרק: "
|
||||
STR_PAGES_SEPARATOR: " דפים | "
|
||||
STR_BOOK_PREFIX: "ספר: "
|
||||
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
||||
STR_PERCENT_STEP_HINT: "שמאל/ימין: 1% למעלה/למטה: 10%"
|
||||
STR_SYNCING_TIME: "מסנכרן שעון..."
|
||||
STR_CALC_HASH: "מחשב האש..."
|
||||
STR_HASH_FAILED: "חישוב האש נכשל"
|
||||
@@ -359,8 +363,7 @@ STR_FRONT_BTN_FOLLOW_ORIENTATION: "התאמת לחצנים קדמיים לכיו
|
||||
STR_REMOVE_READ_FROM_RECENTS: "הסר ספרים שנקראו מרשימת האחרונים"
|
||||
STR_MOVE_FINISHED_TO_READ: "העבר ספרים שהסתיימו לתיקיית 'נקראו'"
|
||||
STR_DISABLED: "מבוטל"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "החזק לחוץ על אישור כדי למחוק"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "החזק לחוץ על כפתור אישור בזמן הקריאה כדי להוסיף סימנייה"
|
||||
STR_HOLD_OPEN_TO_DELETE: "לחיצה ארוכה על 'פתח' כדי למחוק"
|
||||
STR_CLOCK: "שעון"
|
||||
STR_CLOCK_UTC_OFFSET: "הפרש זמן UTC"
|
||||
STR_CLOCK_FORMAT: "תבנית השעון"
|
||||
@@ -378,6 +381,15 @@ STR_CLOCK_SYNC_NO_WIFI_HINT: "התחבר תחילה לרשת אלחוטית, ו
|
||||
STR_CLOCK_SYNCED: "השעון סונכרן"
|
||||
STR_BOOKMARKS: "סימניות"
|
||||
STR_BOOKMARK_ADDED: "הסימנייה התווספה"
|
||||
STR_BOOKMARK_REMOVED: "הסימנייה הוסרה"
|
||||
STR_QUICK_RESUME: "חזרה מהירה"
|
||||
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
|
||||
STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "סימנייה"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
||||
STR_SLEEP_NEVER: "אף פעם"
|
||||
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
|
||||
STR_STEP_HINT_SIDE: "לחצני צד:"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Alvásképernyő borítómód"
|
||||
STR_HIDE_BATTERY: "Akkumulátor % elrejtése"
|
||||
STR_EXTRA_SPACING: "Extra bekezdésköz"
|
||||
STR_TEXT_AA: "Szöveg élsimítás"
|
||||
STR_TEXT_AA_FULL: "Teljes"
|
||||
STR_TEXT_AA_FAST: "Gyors"
|
||||
STR_IMAGES: "Képek"
|
||||
STR_IMAGES_DISPLAY: "Megjelenítés"
|
||||
STR_IMAGES_PLACEHOLDER: "Helyőrző"
|
||||
@@ -74,6 +76,7 @@ STR_ORIENTATION: "Olvasási irány"
|
||||
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
||||
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
|
||||
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
|
||||
STR_FONT_FAMILY: "Olvasó betűkészlet"
|
||||
STR_FONT_SIZE: "Olvasó betűméret"
|
||||
STR_LINE_SPACING: "Olvasó sorköz"
|
||||
@@ -131,7 +134,8 @@ STR_SLEEP: "Alvás"
|
||||
STR_PAGE_TURN: "Lapozás"
|
||||
STR_PORTRAIT: "Álló"
|
||||
STR_LANDSCAPE_CW: "Fekvő jobbra"
|
||||
STR_INVERTED: "Fordított"
|
||||
STR_INVERTED: "Invertált"
|
||||
STR_ORIENTATION_INVERTED: "Álló 180°"
|
||||
STR_LANDSCAPE_CCW: "Fekvő balra"
|
||||
STR_PREV_NEXT: "Előző/Következő"
|
||||
STR_NEXT_PREV: "Következő/Előző"
|
||||
@@ -183,6 +187,9 @@ STR_HOME: "« Főoldal"
|
||||
STR_SELECT: "Kiválasztás"
|
||||
STR_SELECTED: "Kiválasztva"
|
||||
STR_TOGGLE: "Váltás"
|
||||
STR_TOGGLE_BOOKMARK: "Könyvjelző váltása"
|
||||
STR_BOOKMARK_REMOVED: "Könyvjelző eltávolítva."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Tartsa lenyomva a Megnyitás gombot a törléshez"
|
||||
STR_CONFIRM: "Megerősítés"
|
||||
STR_CANCEL: "Mégse"
|
||||
STR_CONNECT: "Csatlakozás"
|
||||
@@ -258,7 +265,6 @@ STR_CHAPTER_PREFIX: "Fejezet: "
|
||||
STR_PAGES_SEPARATOR: " oldal | "
|
||||
STR_BOOK_PREFIX: "Könyv: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
|
||||
STR_PERCENT_STEP_HINT: "Bal/Jobb: 1% Fel/Le: 10%"
|
||||
STR_SYNCING_TIME: "Idő szinkronizálása..."
|
||||
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
||||
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
||||
@@ -290,7 +296,8 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
||||
STR_SLEEP_NEVER: "Soha"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
|
||||
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
||||
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
||||
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Modalità copertina"
|
||||
STR_HIDE_BATTERY: "Nascondi % batteria"
|
||||
STR_EXTRA_SPACING: "Spaziatura extra paragrafi"
|
||||
STR_TEXT_AA: "Anti-aliasing testo"
|
||||
STR_TEXT_AA_FULL: "Completo"
|
||||
STR_TEXT_AA_FAST: "Veloce"
|
||||
STR_IMAGES: "Immagini"
|
||||
STR_IMAGES_DISPLAY: "Visualizza"
|
||||
STR_IMAGES_PLACEHOLDER: "Segnaposto"
|
||||
@@ -77,6 +79,8 @@ STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Salta capitolo"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientamento"
|
||||
STR_LONG_PRESS_MENU: "Menu press. lunga"
|
||||
STR_FONT_PREVIEW_TEXT: "Pranzo d'acqua fa volti sghembi"
|
||||
STR_FONT_FAMILY: "Font lettore"
|
||||
STR_FONT_SIZE: "Dimensione font"
|
||||
STR_LINE_SPACING: "Interlinea lettore"
|
||||
@@ -135,7 +139,8 @@ STR_PAGE_TURN: "Cambio pagina"
|
||||
STR_FORCE_REFRESH: "Refresh"
|
||||
STR_PORTRAIT: "Verticale"
|
||||
STR_LANDSCAPE_CW: "Orizzontale Dx"
|
||||
STR_INVERTED: "Capovolto"
|
||||
STR_INVERTED: "Invertito"
|
||||
STR_ORIENTATION_INVERTED: "Verticale 180°"
|
||||
STR_LANDSCAPE_CCW: "Orizzontale Sx"
|
||||
STR_PREV_NEXT: "Prec/Succ"
|
||||
STR_NEXT_PREV: "Succ/Prec"
|
||||
@@ -190,6 +195,7 @@ STR_HOME: "« Home"
|
||||
STR_SELECT: "Seleziona"
|
||||
STR_SELECTED: "Selezionato"
|
||||
STR_TOGGLE: "Cambia"
|
||||
STR_TOGGLE_BOOKMARK: "Attiva/disattiva segnalibro"
|
||||
STR_CONFIRM: "Conferma"
|
||||
STR_CANCEL: "Annulla"
|
||||
STR_CONNECT: "Connetti"
|
||||
@@ -270,7 +276,6 @@ STR_CHAPTER_PREFIX: "Capitolo: "
|
||||
STR_PAGES_SEPARATOR: " pagine | "
|
||||
STR_BOOK_PREFIX: "Libro: "
|
||||
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
|
||||
STR_PERCENT_STEP_HINT: "Sinistra/Destra: 1% Su/Giù: 10%"
|
||||
STR_SYNCING_TIME: "Sincronizzazione orario..."
|
||||
STR_CALC_HASH: "Calcolo hash documento..."
|
||||
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
||||
@@ -303,7 +308,8 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
|
||||
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot"
|
||||
STR_ADD_SERVER: "Aggiungi server"
|
||||
STR_SERVER_NAME: "Nome server"
|
||||
@@ -355,9 +361,9 @@ STR_FIRMWARE_WRITE_FAILED: "Aggiornamento firmware non riuscito"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Non spegnere il dispositivo!"
|
||||
STR_RECOVERY_MODE: "Modalità ripristino"
|
||||
STR_RECOVERY_MODE_HINT: "Metti firmware.bin nella scheda SD e selezionalo"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Tieni premuto Conferma nel lettore per creare un segnalibro"
|
||||
STR_BOOKMARKS: "Segnalibri"
|
||||
STR_BOOKMARK_ADDED: "Segnalibro aggiunto"
|
||||
STR_BOOKMARK_REMOVED: "Segnalibro rimosso"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Eliminare questo segnalibro?"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Connettiti prima al Wi-Fi e poi riprova"
|
||||
STR_CLOCK_SYNC_OK: "Orologio sincronizzato"
|
||||
@@ -372,7 +378,10 @@ STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..."
|
||||
STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita"
|
||||
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare"
|
||||
STR_NEXT_FIELD: "Succ."
|
||||
STR_CURRENT_TIME: "Ora attuale: "
|
||||
STR_DISABLED: "Disattivato"
|
||||
STR_BOOKMARK_OPTION: "Segnalibro"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
|
||||
|
||||
@@ -64,11 +64,14 @@ STR_SLEEP_COVER_MODE: "Ұйқы экраны мұқаба режимі"
|
||||
STR_HIDE_BATTERY: "Батарея % жасыру"
|
||||
STR_EXTRA_SPACING: "Қосымша абзац аралығы"
|
||||
STR_TEXT_AA: "Мәтін сырғытпасы"
|
||||
STR_TEXT_AA_FULL: "Толық"
|
||||
STR_TEXT_AA_FAST: "Жылдам"
|
||||
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
||||
STR_ORIENTATION: "Оқу бағдары"
|
||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
||||
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
||||
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
|
||||
STR_FONT_FAMILY: "Оқырман қаріп тобы"
|
||||
STR_FONT_SIZE: "Интерфейс қаріп өлшемі"
|
||||
STR_LINE_SPACING: "Оқырман жол аралығы"
|
||||
@@ -125,7 +128,8 @@ STR_SLEEP: "Ұйқы"
|
||||
STR_PAGE_TURN: "Бет аудару"
|
||||
STR_PORTRAIT: "Тік бағдар"
|
||||
STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)"
|
||||
STR_INVERTED: "Төңкерілген"
|
||||
STR_INVERTED: "Инверсия"
|
||||
STR_ORIENTATION_INVERTED: "Тік бағдар 180°"
|
||||
STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)"
|
||||
STR_PREV_NEXT: "Алдыңғы/Келесі"
|
||||
STR_NEXT_PREV: "Келесі/Алдыңғы"
|
||||
@@ -176,6 +180,9 @@ STR_EXIT: "« Шығу"
|
||||
STR_HOME: "« Басты"
|
||||
STR_SELECT: "Таңдау"
|
||||
STR_TOGGLE: "Ауыстыру"
|
||||
STR_TOGGLE_BOOKMARK: "Бетбелгіні ауыстыру"
|
||||
STR_BOOKMARK_REMOVED: "Бетбелгі жойылды."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Жою үшін Ашу түймесін ұстап тұрыңыз"
|
||||
STR_CONFIRM: "Растау"
|
||||
STR_CANCEL: "Болдырмау"
|
||||
STR_CONNECT: "Қосылу"
|
||||
@@ -232,7 +239,6 @@ STR_CHAPTER_PREFIX: "Тарау: "
|
||||
STR_PAGES_SEPARATOR: " бет | "
|
||||
STR_BOOK_PREFIX: "Кітап: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
||||
STR_PERCENT_STEP_HINT: "Сол/Оң: 1% Жоғары/Төмен: 10%"
|
||||
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
||||
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
||||
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
||||
@@ -289,7 +295,8 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
|
||||
STR_LINK: "[сілтеме]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||
STR_SLEEP_NEVER: "Ешқашан"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
|
||||
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
||||
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
||||
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Viršelio režimas"
|
||||
STR_HIDE_BATTERY: "Slėpti baterijos %"
|
||||
STR_EXTRA_SPACING: "Tarpai tarp pastraipų"
|
||||
STR_TEXT_AA: "Teksto glotninimas"
|
||||
STR_TEXT_AA_FULL: "Pilnas"
|
||||
STR_TEXT_AA_FAST: "Greitas"
|
||||
STR_IMAGES: "Paveikslėliai"
|
||||
STR_IMAGES_DISPLAY: "Rodyti"
|
||||
STR_IMAGES_PLACEHOLDER: "Vietaženklis"
|
||||
@@ -74,6 +76,7 @@ STR_ORIENTATION: "Orientacija"
|
||||
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
||||
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
|
||||
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
|
||||
STR_FONT_FAMILY: "Šriftas"
|
||||
STR_FONT_SIZE: "Šrifto dydis"
|
||||
STR_LINE_SPACING: "Tarpai tarp eilučių"
|
||||
@@ -131,7 +134,8 @@ STR_SLEEP: "Miegas"
|
||||
STR_PAGE_TURN: "Versti psl."
|
||||
STR_PORTRAIT: "Stačias"
|
||||
STR_LANDSCAPE_CW: "Gulsčias (P)"
|
||||
STR_INVERTED: "Apverstas"
|
||||
STR_INVERTED: "Invertuotas"
|
||||
STR_ORIENTATION_INVERTED: "Stačias 180°"
|
||||
STR_LANDSCAPE_CCW: "Gulsčias (A)"
|
||||
STR_PREV_NEXT: "Atgal/Pirmyn"
|
||||
STR_NEXT_PREV: "Pirmyn/Atgal"
|
||||
@@ -183,6 +187,9 @@ STR_HOME: "« Pradžia"
|
||||
STR_SELECT: "Rinktis"
|
||||
STR_SELECTED: "Pasirinkta"
|
||||
STR_TOGGLE: "Keisti"
|
||||
STR_TOGGLE_BOOKMARK: "Perjungti žymę"
|
||||
STR_BOOKMARK_REMOVED: "Žymė pašalinta."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Laikykite Atidaryti, kad ištrintumėte"
|
||||
STR_CONFIRM: "Gerai"
|
||||
STR_CANCEL: "Atšaukti"
|
||||
STR_CONNECT: "Jungtis"
|
||||
@@ -258,7 +265,6 @@ STR_CHAPTER_PREFIX: "Sk: "
|
||||
STR_PAGES_SEPARATOR: " psl. | "
|
||||
STR_BOOK_PREFIX: "Kn: "
|
||||
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
|
||||
STR_PERCENT_STEP_HINT: "K/D: 1% | V/A: 10%"
|
||||
STR_SYNCING_TIME: "Sinchr. laikas..."
|
||||
STR_CALC_HASH: "Skaičiuojama maiša..."
|
||||
STR_HASH_FAILED: "Maišos klaida"
|
||||
@@ -290,7 +296,8 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
|
||||
STR_LINK: "[nuoroda]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||
STR_SLEEP_NEVER: "Niekada"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
|
||||
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
||||
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
||||
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Okładki wygaszacza"
|
||||
STR_HIDE_BATTERY: "Ukryj % baterii"
|
||||
STR_EXTRA_SPACING: "Dodatkowe odstępy paragrafów"
|
||||
STR_TEXT_AA: "Wygładzanie tekstu"
|
||||
STR_TEXT_AA_FULL: "Pełne"
|
||||
STR_TEXT_AA_FAST: "Szybkie"
|
||||
STR_IMAGES: "Obrazki"
|
||||
STR_IMAGES_DISPLAY: "Pokazuj"
|
||||
STR_IMAGES_PLACEHOLDER: "Ramki"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientacja ekranu"
|
||||
STR_FONT_PREVIEW_TEXT: "Pchnąć w tę łódź jeża lub ośm skrzyń fig"
|
||||
STR_FONT_FAMILY: "Czcionka"
|
||||
STR_FONT_SIZE: "Rozmiar czcionki"
|
||||
STR_LINE_SPACING: "Odstępy między wierszami"
|
||||
@@ -135,7 +138,8 @@ STR_PAGE_TURN: "Nast. str."
|
||||
STR_FORCE_REFRESH: "Odśwież ekran"
|
||||
STR_PORTRAIT: "Pionowo"
|
||||
STR_LANDSCAPE_CW: "Poziomo P"
|
||||
STR_INVERTED: "Odwrócony"
|
||||
STR_INVERTED: "Inwersja"
|
||||
STR_ORIENTATION_INVERTED: "Pionowo 180°"
|
||||
STR_LANDSCAPE_CCW: "Poziomo L"
|
||||
STR_PREV_NEXT: "Poprz./Nast."
|
||||
STR_NEXT_PREV: "Nast./Poprz."
|
||||
@@ -190,6 +194,9 @@ STR_HOME: "« Home"
|
||||
STR_SELECT: "Wybierz"
|
||||
STR_SELECTED: "Wybrano"
|
||||
STR_TOGGLE: "Zmień"
|
||||
STR_TOGGLE_BOOKMARK: "Przełącz zakładkę"
|
||||
STR_BOOKMARK_REMOVED: "Zakładka usunięta."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Przytrzymaj Otwórz, aby usunąć"
|
||||
STR_CONFIRM: "Potwierdź"
|
||||
STR_CANCEL: "Anuluj"
|
||||
STR_CONNECT: "Połącz"
|
||||
@@ -270,7 +277,6 @@ STR_CHAPTER_PREFIX: "Rozdział: "
|
||||
STR_PAGES_SEPARATOR: " stron | "
|
||||
STR_BOOK_PREFIX: "Książka: "
|
||||
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
|
||||
STR_PERCENT_STEP_HINT: "Lewo/Prawo: 1% Góra/Dół: 10%"
|
||||
STR_SYNCING_TIME: "Synchronizacja czasu..."
|
||||
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
||||
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
||||
@@ -303,7 +309,8 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nigdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Przyciski przednie:"
|
||||
STR_STEP_HINT_SIDE: "Przyciski boczne:"
|
||||
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
||||
STR_ADD_SERVER: "Dodaj serwer"
|
||||
STR_SERVER_NAME: "Nazwa serwera"
|
||||
|
||||
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
|
||||
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
||||
STR_INDEXING: "Indexando"
|
||||
STR_MEMORY_ERROR: "Erro de memória"
|
||||
STR_PAGE_LOAD_ERROR: "Erro página"
|
||||
STR_PAGE_LOAD_ERROR: "Erro ao carregar pág."
|
||||
STR_EMPTY_FILE: "Arquivo vazio"
|
||||
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
||||
STR_LOADING: "Carregando..."
|
||||
@@ -32,8 +32,8 @@ STR_CONNECTING: "Conectando..."
|
||||
STR_CONNECTED: "Conectado!"
|
||||
STR_CONNECTION_FAILED: "Falha na conexão"
|
||||
STR_FORGET_NETWORK: "Esquecer rede?"
|
||||
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
|
||||
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
|
||||
STR_SAVE_PASSWORD: "Salvar senha na próxima vez?"
|
||||
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
|
||||
STR_JOIN_NETWORK: "Entrar em uma rede"
|
||||
STR_CREATE_HOTSPOT: "Criar hotspot"
|
||||
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
|
||||
@@ -41,7 +41,7 @@ STR_HOTSPOT_DESC: "Crie uma rede Wi-Fi para outras pessoas entrarem"
|
||||
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
|
||||
STR_HOTSPOT_MODE: "Modo hotspot"
|
||||
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
||||
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
|
||||
STR_OPEN_URL_HINT: "Abra esta URL no seu navegador"
|
||||
STR_OR_HTTP_PREFIX: "ou http://"
|
||||
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
||||
@@ -50,29 +50,38 @@ STR_MAC_ADDRESS: "Endereço MAC:"
|
||||
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
||||
STR_TO_PREFIX: "para"
|
||||
STR_CALIBRE_RECEIVING: "Recebendo:"
|
||||
STR_CALIBRE_RECEIVED: "Recebido:"
|
||||
STR_CALIBRE_RECEIVING: "Recebendo: "
|
||||
STR_CALIBRE_RECEIVED: "Recebido: "
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar ao dispositivo\""
|
||||
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
||||
STR_CAT_DISPLAY: "Tela"
|
||||
STR_CAT_READER: "Leitor"
|
||||
STR_CAT_CONTROLS: "Controles"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
||||
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
||||
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
||||
STR_TEXT_AA: "Suavização de texto"
|
||||
STR_TEXT_AA_FULL: "Completa"
|
||||
STR_TEXT_AA_FAST: "Rápida"
|
||||
STR_IMAGES: "Imagens"
|
||||
STR_IMAGES_DISPLAY: "Exibição"
|
||||
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
|
||||
STR_IMAGES_SUPPRESS: "Ocultar"
|
||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
||||
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
|
||||
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
|
||||
STR_FONT_FAMILY: "Fonte do leitor"
|
||||
STR_FONT_SIZE: "Tam. fonte UI"
|
||||
STR_LINE_SPACING: "Espaçamento entre linhas"
|
||||
@@ -80,6 +89,7 @@ STR_SCREEN_MARGIN: "Margens da tela"
|
||||
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
||||
STR_HYPHENATION: "Hifenização"
|
||||
STR_TIME_TO_SLEEP: "Tempo para repousar"
|
||||
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
|
||||
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
||||
STR_REFRESH_FREQ: "Frequência atualização"
|
||||
@@ -127,12 +137,17 @@ STR_ALWAYS: "Sempre"
|
||||
STR_IGNORE: "Ignorar"
|
||||
STR_SLEEP: "Repouso"
|
||||
STR_PAGE_TURN: "Virar página"
|
||||
STR_FORCE_REFRESH: "Atualizar tela"
|
||||
STR_PORTRAIT: "Retrato"
|
||||
STR_LANDSCAPE_CW: "Paisagem H"
|
||||
STR_INVERTED: "Invertido"
|
||||
STR_ORIENTATION_INVERTED: "Retrato 180°"
|
||||
STR_LANDSCAPE_CCW: "Paisagem AH"
|
||||
STR_PREV_NEXT: "Ant/Próx"
|
||||
STR_NEXT_PREV: "Próx/Ant"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Marcador"
|
||||
STR_DISABLED: "Desabilitado"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Pequeno"
|
||||
@@ -160,17 +175,21 @@ STR_UPDATING: "Atualizando..."
|
||||
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
||||
STR_UPDATE_FAILED: "Falha na atualização"
|
||||
STR_UPDATE_COMPLETE: "Atualização concluída"
|
||||
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
|
||||
STR_NO_ENTRIES: "Nenhum entries encontrado"
|
||||
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
|
||||
STR_RESTARTING_HINT: "Reiniciando... Se não reiniciar, pressione e segure o botão de energia por alguns segundos."
|
||||
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
|
||||
STR_DOWNLOADING: "Baixando..."
|
||||
STR_DOWNLOAD_FAILED: "Falha no download"
|
||||
STR_ERROR_MSG: "Erro:"
|
||||
STR_UNNAMED: "Sem nome"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir"
|
||||
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
||||
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
||||
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
||||
STR_NETWORK_PREFIX: "Rede:"
|
||||
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
|
||||
STR_NEXT_PAGE: "Próxima Página »"
|
||||
STR_PREV_PAGE: "« Página Anterior"
|
||||
STR_NETWORK_PREFIX: "Rede: "
|
||||
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
||||
@@ -179,7 +198,9 @@ STR_BACK: "« Voltar"
|
||||
STR_EXIT: "« Sair"
|
||||
STR_HOME: "« Início"
|
||||
STR_SELECT: "Escolher"
|
||||
STR_SELECTED: "Selecionado"
|
||||
STR_TOGGLE: "Alternar"
|
||||
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||
STR_CONFIRM: "Confirmar"
|
||||
STR_CANCEL: "Cancelar"
|
||||
STR_CONNECT: "Conectar"
|
||||
@@ -188,6 +209,8 @@ STR_DOWNLOAD: "Baixar"
|
||||
STR_RETRY: "Tentar novamente"
|
||||
STR_YES: "Sim"
|
||||
STR_NO: "Não"
|
||||
STR_SHOW: "Mostrar"
|
||||
STR_HIDE: "Ocultar"
|
||||
STR_STATE_ON: "LIG."
|
||||
STR_STATE_OFF: "DESL."
|
||||
STR_NOT_SET: "Não definido"
|
||||
@@ -198,15 +221,51 @@ STR_DIR_DOWN: "Baixo"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
||||
STR_FILTER_CONTRAST: "Contraste"
|
||||
STR_UI_THEME: "Tema da interface"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Customizar Barra de Status"
|
||||
STR_CHAPTER_PAGE_COUNT: "Páginas do Capítulo"
|
||||
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de Progresso"
|
||||
STR_PROGRESS_BAR: "Barra de Progresso"
|
||||
STR_PROGRESS_BAR_THICKNESS: "Espessura da Barra de Progresso"
|
||||
STR_PROGRESS_BAR_THIN: "Fino"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Médio"
|
||||
STR_PROGRESS_BAR_THICK: "Grosso"
|
||||
STR_BOOK: "Livro"
|
||||
STR_CHAPTER: "Capítulo"
|
||||
STR_EXAMPLE_CHAPTER: "Capítulo 21"
|
||||
STR_EXAMPLE_BOOK: "Título do Livro"
|
||||
STR_PREVIEW: "Prévia"
|
||||
STR_TITLE: "Título"
|
||||
STR_BATTERY: "Bateria"
|
||||
STR_XTC_STATUS_BAR: "Barra de Status XTC"
|
||||
STR_BOTTOM: "Baixo"
|
||||
STR_TOP: "Topo"
|
||||
STR_CLOCK: "Relógio"
|
||||
STR_CLOCK_UTC_OFFSET: "Offset UTC do Relógio"
|
||||
STR_CLOCK_FORMAT: "Formato do Relógio"
|
||||
STR_CLOCK_FORMAT_24H: "24 horas"
|
||||
STR_CLOCK_FORMAT_12H: "12 horas"
|
||||
STR_CURRENT_TIME: "Hora atual:"
|
||||
STR_NEXT_FIELD: "Próximo"
|
||||
STR_CLOCK_SYNC: "Sincronizar Relógio"
|
||||
STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora"
|
||||
STR_CLOCK_SYNCING: "Sincronizando via NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Relógio sincronizado"
|
||||
STR_CLOCK_SYNC_FAIL: "Falha na sincronização"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi não conectado"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Conecte ao Wi-Fi primeiro, depois tente novamente."
|
||||
STR_CLOCK_SYNCED: "Relógio sincronizado"
|
||||
STR_UI_THEME: "Tema"
|
||||
STR_THEME_CLASSIC: "Clássico"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
|
||||
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais"
|
||||
STR_BOOKMARKS: "Marcadores"
|
||||
STR_BOOKMARK_ADDED: "Marcador adicionado."
|
||||
STR_BOOKMARK_REMOVED: "Marcador removido."
|
||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||
STR_SEARCH: "Busca"
|
||||
STR_COVER_CUSTOM: "Capa + personalizado"
|
||||
STR_QUICK_RESUME: "Retomada rápida"
|
||||
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||
@@ -220,11 +279,11 @@ STR_CALIBRE_SETUP: "Configuração"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
STR_CLEAR_BUTTON: "Limpar"
|
||||
STR_DEFAULT_VALUE: "Padrão"
|
||||
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
|
||||
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
|
||||
STR_UNASSIGNED: "Não atribuído"
|
||||
STR_ALREADY_ASSIGNED: "Já atribuído"
|
||||
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
|
||||
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
|
||||
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir layout padrão"
|
||||
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar remapeamento"
|
||||
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
||||
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
||||
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
||||
@@ -234,14 +293,15 @@ STR_GO_HOME_BUTTON: "Ir para o início"
|
||||
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
||||
STR_DELETE_CACHE: "Excluir cache do livro"
|
||||
STR_DELETE: "Excluir"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?"
|
||||
STR_DISPLAY_QR: "Mostrar página como QR"
|
||||
STR_CHAPTER_PREFIX: "Capítulo:"
|
||||
STR_PAGES_SEPARATOR: "páginas |"
|
||||
STR_BOOK_PREFIX: "Livro:"
|
||||
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
|
||||
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
|
||||
STR_SYNCING_TIME: "Sincronizando horário..."
|
||||
STR_CALC_HASH: "Calculando hash documento..."
|
||||
STR_HASH_FAILED: "Falha ao calcular o hash documento"
|
||||
STR_CALC_HASH: "Calculando hash do documento..."
|
||||
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
|
||||
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
||||
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
||||
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
||||
@@ -259,14 +319,69 @@ STR_UPLOAD_PROMPT: "Enviar posição atual?"
|
||||
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
||||
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
||||
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
|
||||
STR_SECTION_PREFIX: "Seção"
|
||||
STR_SECTION_PREFIX: "Seção "
|
||||
STR_UPLOAD: "Enviar"
|
||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||
STR_FOCUS_READING: "Leitura focada"
|
||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido de notas de rodapé"
|
||||
STR_SET_SLEEP_COVER: "Definir capa"
|
||||
STR_FOOTNOTES: "Notas de rodapé"
|
||||
STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Capturar tela"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nunca"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botões frontais:"
|
||||
STR_STEP_HINT_SIDE: "Botões laterais:"
|
||||
STR_ADD_SERVER: "Adicionar Servidor"
|
||||
STR_SERVER_NAME: "Nome do Servidor"
|
||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||
STR_DELETE_SERVER: "Excluir Servidor"
|
||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
||||
STR_MANAGE_FONTS: "Gerenciar fontes"
|
||||
STR_FONT_BROWSER: "Navegador de fontes"
|
||||
STR_LOADING_FONT_LIST: "Carregando lista de fontes..."
|
||||
STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível"
|
||||
STR_FONT_INSTALLED: "Fonte instalada!"
|
||||
STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte"
|
||||
STR_INSTALLED: "Instalada"
|
||||
STR_DOWNLOAD_ALL: "Baixar tudo"
|
||||
STR_UPDATE_ALL: "Atualizar tudo"
|
||||
STR_UPDATE_AVAILABLE: "Atualizar"
|
||||
STR_CRASH_TITLE: "Falha do sistema"
|
||||
STR_CRASH_DESCRIPTION: "Um relatório detalhado foi salvo em crash_report.txt. Por favor, inclua este arquivo no seu relatório de erro."
|
||||
STR_CRASH_REASON: "Motivo da falha:"
|
||||
STR_CRASH_NO_REASON: "(Nenhum motivo registrado)"
|
||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Pressione ESQ ou DIR para mover o cursor"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Pressione ESQ para retornar à posição do cursor"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Segure DIR e pressione [***] para ocultar a senha"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Segure DIR e pressione [abc] para exibir a senha"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pressione [***] para ocultar a senha"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pressione [abc] para exibir a senha"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Segure CIMA para editar a entrada"
|
||||
STR_KB_TIPS: "Dicas:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Pressione BAIXO para retornar ao teclado"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Pressione ABC para sair do modo URL"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Segure DEL para limpar todo o texto"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Segure SELECT para caractere secundário"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Segure SELECT para MAIÚSCULO ou caractere secundário"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Segure SELECT para minúsculo ou caractere secundário"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Pressione URL para atalhos"
|
||||
STR_SD_FIRMWARE_UPDATE: "Atualização de firmware via cartão SD"
|
||||
STR_SELECT_FIRMWARE_FILE: "Selecione o arquivo de firmware (.bin)"
|
||||
STR_NO_BIN_FILES: "Nenhum arquivo .bin encontrado"
|
||||
STR_VALIDATING_FIRMWARE: "Validando firmware..."
|
||||
STR_INVALID_FIRMWARE: "Arquivo de firmware inválido"
|
||||
STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição"
|
||||
STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais"
|
||||
STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?"
|
||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo"
|
||||
STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
|
||||
STR_RECOVERY_MODE: "Modo de Recuperação"
|
||||
STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
|
||||
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
|
||||
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
|
||||
STR_TEXT_AA: "Anti-Aliasing text"
|
||||
STR_TEXT_AA_FULL: "Complet"
|
||||
STR_TEXT_AA_FAST: "Rapid"
|
||||
STR_IMAGES: "Imagini"
|
||||
STR_IMAGES_DISPLAY: "Afişare"
|
||||
STR_IMAGES_PLACEHOLDER: "Substituent"
|
||||
@@ -77,6 +79,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Schimbă orientarea"
|
||||
STR_FONT_PREVIEW_TEXT: "Încă vând gem, whisky bej și tequila roz, preț fix"
|
||||
STR_FONT_FAMILY: "Familie font lectură"
|
||||
STR_FONT_SIZE: "Dimensiune font"
|
||||
STR_LINE_SPACING: "Spaţiere între rânduri"
|
||||
@@ -135,6 +138,7 @@ STR_PAGE_TURN: "Răsfoire pagină"
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Orizontal dreapta"
|
||||
STR_INVERTED: "Invers"
|
||||
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||
STR_LANDSCAPE_CCW: "Orizontal stânga"
|
||||
STR_PREV_NEXT: "Înainte/Înapoi"
|
||||
STR_NEXT_PREV: "Înapoi/Înainte"
|
||||
@@ -186,6 +190,9 @@ STR_HOME: "« Acasă"
|
||||
STR_SELECT: "Selectează"
|
||||
STR_SELECTED: "Selectat"
|
||||
STR_TOGGLE: "Schimbă"
|
||||
STR_TOGGLE_BOOKMARK: "Comută marcajul"
|
||||
STR_BOOKMARK_REMOVED: "Marcaj eliminat."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Țineți apăsat Deschideți pentru a șterge"
|
||||
STR_CONFIRM: "Confirmă"
|
||||
STR_CANCEL: "Anulare"
|
||||
STR_CONNECT: "Conectare"
|
||||
@@ -261,7 +268,6 @@ STR_CHAPTER_PREFIX: "Capitol: "
|
||||
STR_PAGES_SEPARATOR: " pagini | "
|
||||
STR_BOOK_PREFIX: "Carte: "
|
||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
||||
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
|
||||
STR_SYNCING_TIME: "Timp de sincronizare..."
|
||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||
@@ -293,7 +299,8 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
|
||||
STR_LINK: "[link]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Niciodată"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
||||
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
||||
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Режим обложки сна"
|
||||
STR_HIDE_BATTERY: "Скрыть % батареи"
|
||||
STR_EXTRA_SPACING: "Доп. интервал абзаца"
|
||||
STR_TEXT_AA: "Сглаживание текста"
|
||||
STR_TEXT_AA_FULL: "Полное"
|
||||
STR_TEXT_AA_FAST: "Быстрое"
|
||||
STR_IMAGES: "Изображения"
|
||||
STR_IMAGES_DISPLAY: "Показать"
|
||||
STR_IMAGES_PLACEHOLDER: "Заглушки"
|
||||
@@ -78,6 +80,8 @@ STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию"
|
||||
STR_LONG_PRESS_MENU: "Долгое нажатие меню"
|
||||
STR_FONT_PREVIEW_TEXT: "Съешь ещё этих мягких французских булок, да выпей же чаю"
|
||||
STR_FONT_FAMILY: "Шрифт чтения"
|
||||
STR_FONT_SIZE: "Размер шрифта интерфейса"
|
||||
STR_LINE_SPACING: "Межстрочный интервал"
|
||||
@@ -138,10 +142,13 @@ STR_FORCE_REFRESH: "Обновление экрана"
|
||||
STR_PORTRAIT: "Портрет"
|
||||
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
||||
STR_INVERTED: "Инверсия"
|
||||
STR_ORIENTATION_INVERTED: "Портрет 180°"
|
||||
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
||||
STR_PREV_NEXT: "Назад/Вперёд"
|
||||
STR_NEXT_PREV: "Вперёд/Назад"
|
||||
STR_DISABLED: "Выключены"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Закладка"
|
||||
STR_DISABLED: "Выключено"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Маленький"
|
||||
@@ -176,8 +183,7 @@ STR_DOWNLOADING: "Загрузка..."
|
||||
STR_DOWNLOAD_FAILED: "Ошибка загрузки"
|
||||
STR_ERROR_MSG: "Ошибка:"
|
||||
STR_UNNAMED: "Без имени"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Удерживайте ОТКРЫТЬ для удаления закладки"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Удерживайте ВЫБРАТЬ для добавления закладки"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Удерживайте Открыть для удаления"
|
||||
STR_NO_SERVER_URL: "URL сервера не настроен"
|
||||
STR_FETCH_FEED_FAILED: "Не удалось получить ленту"
|
||||
STR_PARSE_FEED_FAILED: "Не удалось обработать ленту"
|
||||
@@ -195,6 +201,7 @@ STR_HOME: "« Главная"
|
||||
STR_SELECT: "Выбрать"
|
||||
STR_SELECTED: "Выбран"
|
||||
STR_TOGGLE: "Выбор"
|
||||
STR_TOGGLE_BOOKMARK: "Переключить закладку"
|
||||
STR_CONFIRM: "Подтв."
|
||||
STR_CANCEL: "Отмена"
|
||||
STR_CONNECT: "Подкл."
|
||||
@@ -257,6 +264,7 @@ STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания"
|
||||
STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки"
|
||||
STR_BOOKMARKS: "Закладки"
|
||||
STR_BOOKMARK_ADDED: "Закладка добавлена"
|
||||
STR_BOOKMARK_REMOVED: "Закладка удалена"
|
||||
STR_OPDS_BROWSER: "OPDS браузер"
|
||||
STR_SEARCH: "Поиск"
|
||||
STR_COVER_CUSTOM: "Обложка + Свой"
|
||||
@@ -292,7 +300,6 @@ STR_CHAPTER_PREFIX: "Глава: "
|
||||
STR_PAGES_SEPARATOR: " стр. | "
|
||||
STR_BOOK_PREFIX: "Книга: "
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
||||
STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%"
|
||||
STR_SYNCING_TIME: "Синхронизация времени..."
|
||||
STR_CALC_HASH: "Расчёт хэша документа..."
|
||||
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
||||
@@ -326,7 +333,8 @@ STR_LINK: "[ссылка]"
|
||||
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||
STR_SLEEP_NEVER: "Никогда"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
|
||||
STR_STEP_HINT_FRONT: "Передние кнопки:"
|
||||
STR_STEP_HINT_SIDE: "Боковые кнопки:"
|
||||
STR_ADD_SERVER: "Добавить сервер"
|
||||
STR_SERVER_NAME: "Имя сервера"
|
||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Obrazovka spánku – režim krytu"
|
||||
STR_HIDE_BATTERY: "Skryť % batérie"
|
||||
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
|
||||
STR_TEXT_AA: "Vyhladzovanie textu"
|
||||
STR_TEXT_AA_FULL: "Plné"
|
||||
STR_TEXT_AA_FAST: "Rýchle"
|
||||
STR_IMAGES: "Obrázky"
|
||||
STR_IMAGES_DISPLAY: "Zobraz"
|
||||
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||
@@ -78,6 +80,7 @@ STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
|
||||
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
|
||||
STR_FONT_FAMILY: "Rodina písiem čítačky"
|
||||
STR_FONT_SIZE: "Veľkosť písma rozhrania"
|
||||
STR_LINE_SPACING: "Riadkovanie čítačky"
|
||||
@@ -136,7 +139,8 @@ STR_PAGE_TURN: "Otáčanie stránok"
|
||||
STR_FORCE_REFRESH: "Obnoviť obrazovku"
|
||||
STR_PORTRAIT: "Na výšku"
|
||||
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
|
||||
STR_INVERTED: "Obrátený"
|
||||
STR_INVERTED: "Invertovaný"
|
||||
STR_ORIENTATION_INVERTED: "Na výšku 180°"
|
||||
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
|
||||
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
|
||||
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
|
||||
@@ -175,8 +179,7 @@ STR_DOWNLOADING: "Sťahovanie..."
|
||||
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
|
||||
STR_ERROR_MSG: "Chyba:"
|
||||
STR_UNNAMED: "Nepomenované"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Podrž potvrdiť pre vymazanie"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Podrž tlačidlo Potvrdiť pre vytvorenie záložky."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Podržte Otvoriť pre vymazanie"
|
||||
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
|
||||
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
|
||||
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
|
||||
@@ -194,6 +197,7 @@ STR_HOME: "« Domov"
|
||||
STR_SELECT: "Vybrať"
|
||||
STR_SELECTED: "Vybrané"
|
||||
STR_TOGGLE: "Prepnúť"
|
||||
STR_TOGGLE_BOOKMARK: "Prepnúť záložku"
|
||||
STR_CONFIRM: "Potvrdiť"
|
||||
STR_CANCEL: "Zrušiť"
|
||||
STR_CONNECT: "Pripojiť"
|
||||
@@ -256,6 +260,7 @@ STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
|
||||
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
|
||||
STR_BOOKMARKS: "Záložky"
|
||||
STR_BOOKMARK_ADDED: "Záložka pridaná."
|
||||
STR_BOOKMARK_REMOVED: "Záložka odstránená."
|
||||
STR_OPDS_BROWSER: "Prehliadač OPDS"
|
||||
STR_SEARCH: "Hľadať"
|
||||
STR_COVER_CUSTOM: "Obálka + Vlastné"
|
||||
@@ -291,7 +296,6 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "strán |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
|
||||
STR_PERCENT_STEP_HINT: "Vľavo/Vpravo: 1 % Hore/Dole: 10 %"
|
||||
STR_SYNCING_TIME: "Čas synchronizácie..."
|
||||
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||
@@ -325,7 +329,8 @@ STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
|
||||
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
|
||||
STR_ADD_SERVER: "Pridať server"
|
||||
STR_SERVER_NAME: "Názov servera"
|
||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Način naslovnice v spanju"
|
||||
STR_HIDE_BATTERY: "Skrij % baterije"
|
||||
STR_EXTRA_SPACING: "Dodaten razmik med odstavki"
|
||||
STR_TEXT_AA: "Glajenje besedila (AA)"
|
||||
STR_TEXT_AA_FULL: "Polno"
|
||||
STR_TEXT_AA_FAST: "Hitro"
|
||||
STR_IMAGES: "Slike"
|
||||
STR_IMAGES_DISPLAY: "Prikaži"
|
||||
STR_IMAGES_PLACEHOLDER: "Oznaka mesta"
|
||||
@@ -74,6 +76,7 @@ STR_ORIENTATION: "Orientacija branja"
|
||||
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
||||
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
|
||||
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
|
||||
STR_FONT_FAMILY: "Pisava bralnika"
|
||||
STR_FONT_SIZE: "Velikost pisave"
|
||||
STR_LINE_SPACING: "Razmik med vrsticami"
|
||||
@@ -131,7 +134,8 @@ STR_SLEEP: "Spanje"
|
||||
STR_PAGE_TURN: "Obračanje strani"
|
||||
STR_PORTRAIT: "Pokončno"
|
||||
STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)"
|
||||
STR_INVERTED: "Obrnjeno"
|
||||
STR_INVERTED: "Invertirano"
|
||||
STR_ORIENTATION_INVERTED: "Pokončno 180°"
|
||||
STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)"
|
||||
STR_PREV_NEXT: "Nazaj/Naprej"
|
||||
STR_NEXT_PREV: "Naprej/Nazaj"
|
||||
@@ -183,6 +187,9 @@ STR_HOME: "« Domov"
|
||||
STR_SELECT: "Izberi"
|
||||
STR_SELECTED: "Izbrano"
|
||||
STR_TOGGLE: "Preklopi"
|
||||
STR_TOGGLE_BOOKMARK: "Preklopi zaznamek"
|
||||
STR_BOOKMARK_REMOVED: "Zaznamek odstranjen."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Držite Odpri za brisanje"
|
||||
STR_CONFIRM: "Potrdi"
|
||||
STR_CANCEL: "Prekliči"
|
||||
STR_CONNECT: "Poveži"
|
||||
@@ -258,7 +265,6 @@ STR_CHAPTER_PREFIX: "Poglavje: "
|
||||
STR_PAGES_SEPARATOR: " strani | "
|
||||
STR_BOOK_PREFIX: "Knjiga: "
|
||||
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
|
||||
STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%"
|
||||
STR_SYNCING_TIME: "Sinhronizacija časa..."
|
||||
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
||||
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
||||
@@ -290,7 +296,8 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
|
||||
STR_LINK: "[povezava]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikoli"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
||||
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
||||
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||
|
||||
@@ -65,6 +65,8 @@ STR_SLEEP_COVER_MODE: "Modo de pantalla de suspensión"
|
||||
STR_HIDE_BATTERY: "Ocultar % de batería"
|
||||
STR_EXTRA_SPACING: "Espaciado entre párrafos"
|
||||
STR_TEXT_AA: "Suavizado de texto"
|
||||
STR_TEXT_AA_FULL: "Completo"
|
||||
STR_TEXT_AA_FAST: "Rápido"
|
||||
STR_IMAGES: "Imágenes"
|
||||
STR_IMAGES_DISPLAY: "Mostrar"
|
||||
STR_IMAGES_PLACEHOLDER: "Reemplazar"
|
||||
@@ -77,6 +79,8 @@ STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambiar orient."
|
||||
STR_LONG_PRESS_MENU: "Función de pulsación larga"
|
||||
STR_FONT_PREVIEW_TEXT: "Benjamín pidió una bebida de kiwi y fresa. Noé, sin vergüenza, la más exquisita champaña del menú"
|
||||
STR_FONT_FAMILY: "Tipografía"
|
||||
STR_FONT_SIZE: "Tamaño"
|
||||
STR_LINE_SPACING: "Interlineado"
|
||||
@@ -136,9 +140,12 @@ STR_FORCE_REFRESH: "Refrescar pant."
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Horizontal (horario)"
|
||||
STR_INVERTED: "Invertido"
|
||||
STR_ORIENTATION_INVERTED: "Al revés"
|
||||
STR_LANDSCAPE_CCW: "Horizontal (antihorario)"
|
||||
STR_PREV_NEXT: "Ant./Sig."
|
||||
STR_NEXT_PREV: "Sig./Ant."
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Marcador"
|
||||
STR_DISABLED: "Desactivados"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -174,8 +181,7 @@ STR_DOWNLOADING: "Descargando..."
|
||||
STR_DOWNLOAD_FAILED: "Fallo de descarga"
|
||||
STR_ERROR_MSG: "Error:"
|
||||
STR_UNNAMED: "Sin nombre"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Mantenga pulsado Confirmar para borrar"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Mantenga pulsado Confirmar en el lector para crear un marcador."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Mantenga pulsado Abrir para borrar"
|
||||
STR_NO_SERVER_URL: "No se configuró URL de servidor"
|
||||
STR_FETCH_FEED_FAILED: "Fallo al obtener el feed"
|
||||
STR_PARSE_FEED_FAILED: "Fallo al procesar el feed"
|
||||
@@ -193,6 +199,7 @@ STR_HOME: "« Inicio"
|
||||
STR_SELECT: "Selecc."
|
||||
STR_SELECTED: "Seleccionado"
|
||||
STR_TOGGLE: "Cambiar"
|
||||
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||
STR_CONFIRM: "Confirmar"
|
||||
STR_CANCEL: "Cancelar"
|
||||
STR_CONNECT: "Conectar"
|
||||
@@ -256,6 +263,7 @@ STR_QUICK_RESUME_TIMEOUT: "Reanudación rápida tras tiempo"
|
||||
STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales"
|
||||
STR_BOOKMARKS: "Marcadores"
|
||||
STR_BOOKMARK_ADDED: "Marcador añadido."
|
||||
STR_BOOKMARK_REMOVED: "Marcador eliminado."
|
||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||
STR_SEARCH: "Buscar"
|
||||
STR_COVER_CUSTOM: "Portada + Pers."
|
||||
@@ -291,7 +299,6 @@ STR_CHAPTER_PREFIX: "Cap.: "
|
||||
STR_PAGES_SEPARATOR: " pág. | "
|
||||
STR_BOOK_PREFIX: "Libro: "
|
||||
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
|
||||
STR_PERCENT_STEP_HINT: "Izq./Dcha.: 1% | Subir/Bajar: 10%"
|
||||
STR_SYNCING_TIME: "Tiempo de sincronización..."
|
||||
STR_CALC_HASH: "Calculando hash del documento..."
|
||||
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
||||
@@ -318,13 +325,15 @@ STR_BOOK_S_STYLE: "Estilo del libro"
|
||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||
STR_FOCUS_READING: "Lectura enfocada"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido desde las notas al pie"
|
||||
STR_SET_SLEEP_COVER: "Pant. sus."
|
||||
STR_FOOTNOTES: "Pie de página"
|
||||
STR_FOOTNOTES: "Notas al pie"
|
||||
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
||||
STR_LINK: "[enlace]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||
STR_SLEEP_NEVER: "Nunca"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
|
||||
STR_STEP_HINT_FRONT: "Botones frontales:"
|
||||
STR_STEP_HINT_SIDE: "Botones laterales:"
|
||||
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||
STR_ADD_SERVER: "Agregar servidor"
|
||||
STR_SERVER_NAME: "Nombre de servidor"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Viloskärmens omslagsläge"
|
||||
STR_HIDE_BATTERY: "Dölj batteriprocent"
|
||||
STR_EXTRA_SPACING: "Extra paragrafmellanrum"
|
||||
STR_TEXT_AA: "Textkantutjämning"
|
||||
STR_TEXT_AA_FULL: "Full"
|
||||
STR_TEXT_AA_FAST: "Snabb"
|
||||
STR_IMAGES: "Bilder"
|
||||
STR_IMAGES_DISPLAY: "Visa"
|
||||
STR_IMAGES_PLACEHOLDER: "Platshållare"
|
||||
@@ -78,6 +80,7 @@ STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ändra orientering"
|
||||
STR_FONT_PREVIEW_TEXT: "Flygande bäckasiner söka hwila på mjuka tuvor"
|
||||
STR_FONT_FAMILY: "Eboksläsarens typsnittsfamilj"
|
||||
STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek"
|
||||
STR_LINE_SPACING: "Eboksläsarens linjemellanrum"
|
||||
@@ -137,6 +140,7 @@ STR_FORCE_REFRESH: "Uppdatera skärmen"
|
||||
STR_PORTRAIT: "Porträtt"
|
||||
STR_LANDSCAPE_CW: "Landskap medurs"
|
||||
STR_INVERTED: "Inverterad"
|
||||
STR_ORIENTATION_INVERTED: "Porträtt 180°"
|
||||
STR_LANDSCAPE_CCW: "Landskap moturs"
|
||||
STR_PREV_NEXT: "Förra/Nästa"
|
||||
STR_NEXT_PREV: "Nästa/Förra"
|
||||
@@ -175,8 +179,7 @@ STR_DOWNLOADING: "Laddar ner…"
|
||||
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
|
||||
STR_ERROR_MSG: "Fel:"
|
||||
STR_UNNAMED: "Ej namngiven"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Håll ned Bekräfta för att radera"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Håll Bekräfta i läsaren för att skapa ett bokmärke."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Håll ned Öppna för att radera"
|
||||
STR_NO_SERVER_URL: "Ingen serveradress konfigurerad"
|
||||
STR_FETCH_FEED_FAILED: "Misslyckades att hämta flöde"
|
||||
STR_PARSE_FEED_FAILED: "Misslyckades att analysera flöde"
|
||||
@@ -194,6 +197,7 @@ STR_HOME: "« Hem"
|
||||
STR_SELECT: "Välj "
|
||||
STR_SELECTED: "Vald"
|
||||
STR_TOGGLE: "Växla"
|
||||
STR_TOGGLE_BOOKMARK: "Växla bokmärke"
|
||||
STR_CONFIRM: "Bekräfta"
|
||||
STR_CANCEL: "Avbryt"
|
||||
STR_CONNECT: "Anslut"
|
||||
@@ -257,6 +261,7 @@ STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout"
|
||||
STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar"
|
||||
STR_BOOKMARKS: "Bokmärken"
|
||||
STR_BOOKMARK_ADDED: "Bokmärke tillagt."
|
||||
STR_BOOKMARK_REMOVED: "Bokmärke borttaget."
|
||||
STR_OPDS_BROWSER: "OPDS-webbläsare"
|
||||
STR_SEARCH: "Sök"
|
||||
STR_COVER_CUSTOM: "Omslag + Valfri"
|
||||
@@ -292,7 +297,6 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
||||
STR_PAGES_SEPARATOR: " sidor | "
|
||||
STR_BOOK_PREFIX: "Bok:"
|
||||
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
|
||||
STR_PERCENT_STEP_HINT: "Vänster/Höger: 1% Upp/Ner 10%"
|
||||
STR_SYNCING_TIME: "Synkroniserar tid…"
|
||||
STR_CALC_HASH: "Beräknar dokumenthash"
|
||||
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
||||
@@ -325,7 +329,8 @@ STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
|
||||
STR_LINK: "[länk]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Aldrig"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Framknappar:"
|
||||
STR_STEP_HINT_SIDE: "Sidoknappar:"
|
||||
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||
STR_ADD_SERVER: "Lägg till server"
|
||||
STR_SERVER_NAME: "Servernamn"
|
||||
|
||||
@@ -64,6 +64,8 @@ STR_SLEEP_COVER_MODE: "Uyku Ekranı Kapak Modu"
|
||||
STR_HIDE_BATTERY: "Pil Yüzdesini Gizle"
|
||||
STR_EXTRA_SPACING: "Ekstra Paragraf Boşluğu"
|
||||
STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
||||
STR_TEXT_AA_FULL: "Tam"
|
||||
STR_TEXT_AA_FAST: "Hızlı"
|
||||
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
||||
STR_ORIENTATION: "Okuma Yönü"
|
||||
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
||||
@@ -72,6 +74,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
||||
STR_FONT_PREVIEW_TEXT: "Pijamalı hasta yağız şoföre çabucak güvendi"
|
||||
STR_FONT_FAMILY: "Okuyucu Yazı Tipi Ailesi"
|
||||
STR_FONT_SIZE: "Arayüz Yazı Boyutu"
|
||||
STR_LINE_SPACING: "Okuyucu Satır Aralığı"
|
||||
@@ -129,7 +132,8 @@ STR_SLEEP: "Uyku"
|
||||
STR_PAGE_TURN: "Sayfa Çevirme"
|
||||
STR_PORTRAIT: "Dikey"
|
||||
STR_LANDSCAPE_CW: "Yatay (Saat Yönü)"
|
||||
STR_INVERTED: "Ters"
|
||||
STR_INVERTED: "Negatif"
|
||||
STR_ORIENTATION_INVERTED: "Dikey 180°"
|
||||
STR_LANDSCAPE_CCW: "Yatay (Saat Yönü Tersi)"
|
||||
STR_PREV_NEXT: "Önceki/Sonraki"
|
||||
STR_NEXT_PREV: "Sonraki/Önceki"
|
||||
@@ -180,6 +184,9 @@ STR_EXIT: "« Çıkış"
|
||||
STR_HOME: "« Ana Sayfa"
|
||||
STR_SELECT: "Seç"
|
||||
STR_TOGGLE: "Değiştir"
|
||||
STR_TOGGLE_BOOKMARK: "Yer imini değiştir"
|
||||
STR_BOOKMARK_REMOVED: "Yer imi kaldırıldı."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Silmek için Aç düğmesini basılı tutun"
|
||||
STR_CONFIRM: "Onayla"
|
||||
STR_CANCEL: "İptal"
|
||||
STR_CONNECT: "Bağlan"
|
||||
@@ -236,7 +243,6 @@ STR_CHAPTER_PREFIX: "Bölüm: "
|
||||
STR_PAGES_SEPARATOR: " sayfa | "
|
||||
STR_BOOK_PREFIX: "Kitap: "
|
||||
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
|
||||
STR_PERCENT_STEP_HINT: "Sol/Sağ: %1 Yukarı/Aşağı: %10"
|
||||
STR_SYNCING_TIME: "Zaman senkronize ediliyor..."
|
||||
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
||||
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
||||
@@ -284,7 +290,8 @@ STR_IMAGES_SUPPRESS: "Bastır"
|
||||
STR_LINK: "[bağlantı]"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
||||
STR_SLEEP_NEVER: "Asla"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
|
||||
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
||||
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
||||
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
||||
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||
STR_PREVIEW: "Önizleme"
|
||||
|
||||
@@ -61,10 +61,13 @@ STR_CAT_READER: "Читач"
|
||||
STR_CAT_CONTROLS: "Кнопки"
|
||||
STR_CAT_SYSTEM: "Система"
|
||||
STR_SLEEP_SCREEN: "Екран у режимі сну"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Швидке поверн. після таймауту"
|
||||
STR_SLEEP_COVER_MODE: "Режим заповнення"
|
||||
STR_HIDE_BATTERY: "Приховати % батареї"
|
||||
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
|
||||
STR_TEXT_AA: "Згладжування тексту"
|
||||
STR_TEXT_AA_FULL: "Повне"
|
||||
STR_TEXT_AA_FAST: "Швидке"
|
||||
STR_IMAGES: "Зображення"
|
||||
STR_IMAGES_DISPLAY: "Показати"
|
||||
STR_IMAGES_PLACEHOLDER: "Заглушка"
|
||||
@@ -77,6 +80,7 @@ STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настик
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану"
|
||||
STR_FONT_PREVIEW_TEXT: "Єхидна, ґава, їжак ще й шиплячі плазуни бігцем форсують Янцзи"
|
||||
STR_FONT_FAMILY: "Шрифт"
|
||||
STR_FONT_SIZE: "Розмір шрифту"
|
||||
STR_LINE_SPACING: "Міжрядковий інтервал"
|
||||
@@ -85,8 +89,8 @@ STR_PARA_ALIGNMENT: "Вирівнювання тексту"
|
||||
STR_HYPHENATION: "Перенесення слів"
|
||||
STR_TIME_TO_SLEEP: "Перехід в режим сну"
|
||||
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Очищати прочитані книги зі списку останніх"
|
||||
STR_MOVE_FINISHED_TO_READ: "Переміщати прочитані книги до теки Read"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Приховувати прочитані книги"
|
||||
STR_MOVE_FINISHED_TO_READ: "Переміщати прочит. в теку Read"
|
||||
STR_REFRESH_FREQ: "Частота оновлення екрану"
|
||||
STR_KOREADER_SYNC: "Синхронізація KOReader"
|
||||
STR_CHECK_UPDATES: "Перевірити оновлення системи"
|
||||
@@ -135,10 +139,12 @@ STR_PAGE_TURN: "Наст. сторінка"
|
||||
STR_FORCE_REFRESH: "Оновити екран"
|
||||
STR_PORTRAIT: "Книжкова"
|
||||
STR_LANDSCAPE_CW: "Альбом. за год."
|
||||
STR_INVERTED: "Перевернутий"
|
||||
STR_INVERTED: "Інверсія"
|
||||
STR_ORIENTATION_INVERTED: "Книжкова 180°"
|
||||
STR_LANDSCAPE_CCW: "Альбом. проти год."
|
||||
STR_PREV_NEXT: "Попер/Наст"
|
||||
STR_NEXT_PREV: "Наст/Попер"
|
||||
STR_DISABLED: "Вимкнуто"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Малий"
|
||||
@@ -173,6 +179,7 @@ STR_DOWNLOADING: "Завантаження..."
|
||||
STR_DOWNLOAD_FAILED: "Завантаження не вдалося"
|
||||
STR_ERROR_MSG: "Помилка:"
|
||||
STR_UNNAMED: "Без назви"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Утримуйте Відкрити, щоб видалити"
|
||||
STR_NO_SERVER_URL: "URL сервера не налаштовано"
|
||||
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
|
||||
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
|
||||
@@ -190,6 +197,7 @@ STR_HOME: "« Додому"
|
||||
STR_SELECT: "Вибрати"
|
||||
STR_SELECTED: "Вибрано"
|
||||
STR_TOGGLE: "Обрати"
|
||||
STR_TOGGLE_BOOKMARK: "Перемкнути закладку"
|
||||
STR_CONFIRM: "Підтвердити"
|
||||
STR_CANCEL: "Скасувати"
|
||||
STR_CONNECT: "Приєдн."
|
||||
@@ -228,18 +236,35 @@ STR_BATTERY: "Акумулятор"
|
||||
STR_XTC_STATUS_BAR: "XTC Рядок прогресу"
|
||||
STR_BOTTOM: "Низ"
|
||||
STR_TOP: "Верх"
|
||||
STR_CLOCK: "Годинник"
|
||||
STR_CLOCK_UTC_OFFSET: "Часовий пояс"
|
||||
STR_CLOCK_FORMAT: "Формат Годинника"
|
||||
STR_CLOCK_FORMAT_24H: "24-години"
|
||||
STR_CLOCK_FORMAT_12H: "12-годин"
|
||||
STR_CURRENT_TIME: "Поточний час:"
|
||||
STR_NEXT_FIELD: "наступний"
|
||||
STR_CLOCK_SYNC: "Синхр. Годинник"
|
||||
STR_CLOCK_SYNC_NOW: "Синхр. годин. зараз"
|
||||
STR_CLOCK_SYNCING: "Синхр. через NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Успішна синхр."
|
||||
STR_CLOCK_SYNC_FAIL: "Невдала синхр. "
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi не під'єднано"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Під'єднайтесь спершу до Wi-Fi, тоді спробуйте знову."
|
||||
STR_CLOCK_SYNCED: "Годинник синхр."
|
||||
STR_UI_THEME: "Тема інтерфейсу"
|
||||
STR_THEME_CLASSIC: "Класична"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Швидке продовження після таймауту"
|
||||
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
|
||||
STR_BOOKMARKS: "Закладки"
|
||||
STR_BOOKMARK_ADDED: "Закладку додано"
|
||||
STR_BOOKMARK_REMOVED: "Закладку видалено"
|
||||
STR_OPDS_BROWSER: "Браузер OPDS"
|
||||
STR_SEARCH: "Пошук"
|
||||
STR_COVER_CUSTOM: "Обкл. + власне"
|
||||
STR_QUICK_RESUME: "Швидке продовження"
|
||||
STR_QUICK_RESUME: "Швидке поверн."
|
||||
STR_MENU_RECENT_BOOKS: "Останні книги"
|
||||
STR_REMOVE_FROM_RECENTS: "Видалити з останніх книг?"
|
||||
STR_NO_RECENT_BOOKS: "Немає останніх книг"
|
||||
@@ -265,12 +290,12 @@ STR_GO_HOME_BUTTON: "На головну"
|
||||
STR_SYNC_PROGRESS: "Прогрес синхронізації"
|
||||
STR_DELETE_CACHE: "Видалити кеш книги"
|
||||
STR_DELETE: "Видалити"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Видалити цю закладку?"
|
||||
STR_DISPLAY_QR: "Показати сторінку як QR-код"
|
||||
STR_CHAPTER_PREFIX: "Розділ: "
|
||||
STR_PAGES_SEPARATOR: " сторінок | "
|
||||
STR_BOOK_PREFIX: "Книга: "
|
||||
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
||||
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
|
||||
STR_SYNCING_TIME: "Синхронізація часу..."
|
||||
STR_CALC_HASH: "Обчислення хешу документа..."
|
||||
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
||||
@@ -297,14 +322,16 @@ STR_BOOK_S_STYLE: "Стиль книги"
|
||||
STR_EMBEDDED_STYLE: "Вбудований стиль"
|
||||
STR_FOCUS_READING: "Фокусне читання"
|
||||
STR_OPDS_SERVER_URL: "URL сервера OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Швидке повернення з приміток"
|
||||
STR_SET_SLEEP_COVER: "Як обкл."
|
||||
STR_FOOTNOTES: "Примітки"
|
||||
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
|
||||
STR_LINK: "[посилання]"
|
||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||
STR_SLEEP_NEVER: "Ніколи"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||
STR_STEP_HINT_FRONT: "Передні кнопки:"
|
||||
STR_STEP_HINT_SIDE: "Бічні кнопки:"
|
||||
STR_ADD_SERVER: "Додати сервер"
|
||||
STR_SERVER_NAME: "Назва сервера"
|
||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
|
||||
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
||||
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
||||
STR_TEXT_AA: "Antialiàsing del text"
|
||||
STR_TEXT_AA_FULL: "Complet"
|
||||
STR_TEXT_AA_FAST: "Ràpid"
|
||||
STR_IMAGES: "Imatges"
|
||||
STR_IMAGES_DISPLAY: "Mostrar"
|
||||
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
||||
@@ -78,6 +80,8 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
|
||||
STR_LONG_PRESS_MENU: "Funció de pulsació llarga"
|
||||
STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!"
|
||||
STR_FONT_FAMILY: "Família de fonts"
|
||||
STR_FONT_SIZE: "Grandària de la lletra (UI)"
|
||||
STR_LINE_SPACING: "Interlineat del lector"
|
||||
@@ -87,7 +91,8 @@ STR_HYPHENATION: "Partició de mots"
|
||||
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Mai"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
||||
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
|
||||
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
|
||||
@@ -139,9 +144,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Horitzontal horari"
|
||||
STR_INVERTED: "Invertit"
|
||||
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
||||
STR_PREV_NEXT: "Anterior/Següent"
|
||||
STR_NEXT_PREV: "Següent/Anterior"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Punt de llibre"
|
||||
STR_DISABLED: "Desactivats"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -176,8 +184,7 @@ STR_DOWNLOADING: "S'està baixant..."
|
||||
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
||||
STR_ERROR_MSG: "Error:"
|
||||
STR_UNNAMED: "Sense nom"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
|
||||
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
||||
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
||||
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
||||
@@ -193,6 +200,7 @@ STR_HOME: "« Inici"
|
||||
STR_SELECT: "Selecciona"
|
||||
STR_SELECTED: "Seleccionat"
|
||||
STR_TOGGLE: "Canvia"
|
||||
STR_TOGGLE_BOOKMARK: "Canvia punt de llibre"
|
||||
STR_CONFIRM: "Confirma"
|
||||
STR_CANCEL: "Cancel·la"
|
||||
STR_CONNECT: "Connecta"
|
||||
@@ -236,6 +244,7 @@ STR_SUNLIGHT_FADING_FIX: "Correcció de l'esvaïment pel sol"
|
||||
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
||||
STR_BOOKMARKS: "Punts de llibre"
|
||||
STR_BOOKMARK_ADDED: "S'ha afegit el punt de llibre."
|
||||
STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
|
||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
||||
STR_QUICK_RESUME: "Represa ràpida"
|
||||
@@ -270,7 +279,6 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
||||
STR_PAGES_SEPARATOR: " pàgines | "
|
||||
STR_BOOK_PREFIX: "Llibre: "
|
||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||
@@ -297,6 +305,7 @@ STR_BOOK_S_STYLE: "Estil del llibre"
|
||||
STR_EMBEDDED_STYLE: "Estil incrustat"
|
||||
STR_FOCUS_READING: "Lectura enfocada"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu"
|
||||
STR_FOOTNOTES: "Notes al peu"
|
||||
STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina"
|
||||
STR_LINK: "[enllaç]"
|
||||
|
||||
@@ -66,6 +66,8 @@ STR_SLEEP_COVER_MODE: "Kiểu ảnh bìa màn hình ngủ"
|
||||
STR_HIDE_BATTERY: "Ẩn % pin"
|
||||
STR_EXTRA_SPACING: "Giãn cách đoạn thêm"
|
||||
STR_TEXT_AA: "Khử răng cưa chữ"
|
||||
STR_TEXT_AA_FULL: "Đầy đủ"
|
||||
STR_TEXT_AA_FAST: "Nhanh"
|
||||
STR_IMAGES: "Hình ảnh"
|
||||
STR_IMAGES_DISPLAY: "Hiển thị"
|
||||
STR_IMAGES_PLACEHOLDER: "Khung thay thế"
|
||||
@@ -78,6 +80,7 @@ STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Nhảy chương"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Đổi hướng"
|
||||
STR_FONT_PREVIEW_TEXT: "Trường quê em do bố của em xây kĩ nên sạch và đẹp lắm"
|
||||
STR_FONT_FAMILY: "Phông chữ trình đọc"
|
||||
STR_FONT_SIZE: "Cỡ chữ trình đọc"
|
||||
STR_LINE_SPACING: "Giãn dòng trình đọc"
|
||||
@@ -136,7 +139,8 @@ STR_PAGE_TURN: "Lật trang"
|
||||
STR_FORCE_REFRESH: "Làm tươi màn hình"
|
||||
STR_PORTRAIT: "Dọc"
|
||||
STR_LANDSCAPE_CW: "Ngang (thuận)"
|
||||
STR_INVERTED: "Lật ngược"
|
||||
STR_INVERTED: "Đảo màu"
|
||||
STR_ORIENTATION_INVERTED: "Dọc 180°"
|
||||
STR_LANDSCAPE_CCW: "Ngang (ngược)"
|
||||
STR_PREV_NEXT: "Trước/Sau"
|
||||
STR_NEXT_PREV: "Sau/Trước"
|
||||
@@ -175,8 +179,7 @@ STR_DOWNLOADING: "Đang tải về..."
|
||||
STR_DOWNLOAD_FAILED: "Tải về thất bại"
|
||||
STR_ERROR_MSG: "Lỗi:"
|
||||
STR_UNNAMED: "Không tên"
|
||||
STR_HOLD_CONFIRM_TO_DELETE: "Giữ Xác nhận để xóa"
|
||||
STR_BOOKMARK_INSTRUCTIONS: "Giữ Xác nhận trong trình đọc để tạo dấu trang."
|
||||
STR_HOLD_OPEN_TO_DELETE: "Giữ Mở để xóa"
|
||||
STR_NO_SERVER_URL: "Chưa cấu hình URL máy chủ"
|
||||
STR_FETCH_FEED_FAILED: "Không tải được nguồn cấp"
|
||||
STR_PARSE_FEED_FAILED: "Không phân tích được nguồn cấp"
|
||||
@@ -194,6 +197,7 @@ STR_HOME: "« Thư viện"
|
||||
STR_SELECT: "Chọn"
|
||||
STR_SELECTED: "Đã chọn"
|
||||
STR_TOGGLE: "Bật/Tắt"
|
||||
STR_TOGGLE_BOOKMARK: "Bật/tắt dấu trang"
|
||||
STR_CONFIRM: "Xác nhận"
|
||||
STR_CANCEL: "Hủy"
|
||||
STR_CONNECT: "Kết nối"
|
||||
@@ -256,6 +260,7 @@ STR_SUNLIGHT_FADING_FIX: "Khắc phục mờ dưới nắng"
|
||||
STR_REMAP_FRONT_BUTTONS: "Gán lại nút mặt trước"
|
||||
STR_BOOKMARKS: "Dấu trang"
|
||||
STR_BOOKMARK_ADDED: "Đã thêm dấu trang."
|
||||
STR_BOOKMARK_REMOVED: "Đã xóa dấu trang."
|
||||
STR_OPDS_BROWSER: "Trình duyệt OPDS"
|
||||
STR_SEARCH: "Tìm kiếm"
|
||||
STR_COVER_CUSTOM: "Ảnh bìa + Tùy chỉnh"
|
||||
@@ -291,7 +296,6 @@ STR_CHAPTER_PREFIX: "Chương: "
|
||||
STR_PAGES_SEPARATOR: " trang | "
|
||||
STR_BOOK_PREFIX: "Sách: "
|
||||
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
|
||||
STR_PERCENT_STEP_HINT: "Trái/Phải: 1% Lên/Xuống: 10%"
|
||||
STR_SYNCING_TIME: "Đang đồng bộ giờ..."
|
||||
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
|
||||
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
|
||||
@@ -325,7 +329,8 @@ STR_LINK: "[liên kết]"
|
||||
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
||||
STR_SLEEP_NEVER: "Không bao giờ"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
|
||||
STR_STEP_HINT_FRONT: "Nút trước:"
|
||||
STR_STEP_HINT_SIDE: "Nút bên:"
|
||||
STR_ADD_SERVER: "Thêm máy chủ"
|
||||
STR_SERVER_NAME: "Tên máy chủ"
|
||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||
|
||||
@@ -164,6 +164,7 @@ namespace {
|
||||
constexpr int MAX_MCU_HEIGHT = 16;
|
||||
constexpr size_t JPEG_DECODER_SIZE = 20 * 1024;
|
||||
constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024;
|
||||
constexpr uint32_t FP_ONE = 1UL << 16;
|
||||
|
||||
// Static file pointer for JPEGDEC open callback.
|
||||
// Safe in single-threaded embedded context; never accessed concurrently.
|
||||
@@ -208,6 +209,9 @@ struct BmpConvertCtx {
|
||||
bool needsScaling;
|
||||
uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point
|
||||
uint32_t scaleY_fp;
|
||||
bool smoothUpscale;
|
||||
uint32_t smoothScaleX_fp;
|
||||
uint32_t smoothScaleY_fp;
|
||||
|
||||
// Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels)
|
||||
// Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row
|
||||
@@ -219,6 +223,13 @@ struct BmpConvertCtx {
|
||||
std::unique_ptr<uint32_t[]> rowAccum;
|
||||
std::unique_ptr<uint32_t[]> rowCount;
|
||||
|
||||
int smoothNextOutY;
|
||||
int smoothPrevY;
|
||||
std::unique_ptr<uint8_t[]> smoothRows;
|
||||
uint8_t* smoothPrevRow;
|
||||
uint8_t* smoothCurrRow;
|
||||
uint8_t* smoothOutRow;
|
||||
|
||||
std::unique_ptr<uint8_t[]> bmpRow;
|
||||
|
||||
std::unique_ptr<AtkinsonDitherer> atkinsonDitherer;
|
||||
@@ -265,6 +276,88 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY)
|
||||
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
|
||||
}
|
||||
|
||||
// Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays
|
||||
// local because cover generation streams dithered BMP rows instead of framebuffer pixels.
|
||||
static uint32_t interpolationStep(const int srcSize, const int outSize) {
|
||||
if (srcSize <= 1 || outSize <= 1) return 0;
|
||||
return (static_cast<uint32_t>(srcSize - 1) << 16) / static_cast<uint32_t>(outSize - 1);
|
||||
}
|
||||
|
||||
static uint32_t interpolatedSourceFp(const int outIndex, const int outSize, const int srcSize, const uint32_t step) {
|
||||
if (srcSize <= 1 || outSize <= 1) return 0;
|
||||
if (outIndex >= outSize - 1) return static_cast<uint32_t>(srcSize - 1) << 16;
|
||||
return static_cast<uint32_t>(outIndex) * step;
|
||||
}
|
||||
|
||||
static void scaleRowLinear(BmpConvertCtx* ctx, const uint8_t* srcRow, uint8_t* dstRow) {
|
||||
for (int outX = 0; outX < ctx->outWidth; outX++) {
|
||||
const uint32_t srcX_fp = interpolatedSourceFp(outX, ctx->outWidth, ctx->srcWidth, ctx->smoothScaleX_fp);
|
||||
const int x0 = srcX_fp >> 16;
|
||||
const int x1 = (x0 + 1 < ctx->srcWidth) ? (x0 + 1) : x0;
|
||||
const uint32_t fx = srcX_fp & (FP_ONE - 1);
|
||||
dstRow[outX] = static_cast<uint8_t>((srcRow[x0] * (FP_ONE - fx) + srcRow[x1] * fx) >> 16);
|
||||
}
|
||||
}
|
||||
|
||||
static void writeBlendedRow(BmpConvertCtx* ctx, const uint8_t* row0, const uint8_t* row1, const uint32_t fy,
|
||||
const int outY) {
|
||||
const uint32_t invFy = FP_ONE - fy;
|
||||
for (int outX = 0; outX < ctx->outWidth; outX++) {
|
||||
ctx->smoothOutRow[outX] = static_cast<uint8_t>((row0[outX] * invFy + row1[outX] * fy) >> 16);
|
||||
}
|
||||
writeOutputRow(ctx, ctx->smoothOutRow, outY);
|
||||
}
|
||||
|
||||
static void processSmoothSourceRow(BmpConvertCtx* ctx, const uint8_t* srcRow, const int srcY) {
|
||||
scaleRowLinear(ctx, srcRow, ctx->smoothCurrRow);
|
||||
|
||||
if (ctx->smoothPrevY < 0) {
|
||||
uint8_t* tmp = ctx->smoothPrevRow;
|
||||
ctx->smoothPrevRow = ctx->smoothCurrRow;
|
||||
ctx->smoothCurrRow = tmp;
|
||||
ctx->smoothPrevY = srcY;
|
||||
if (ctx->srcHeight <= 1) {
|
||||
while (ctx->smoothNextOutY < ctx->outHeight) {
|
||||
writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY);
|
||||
ctx->smoothNextOutY++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
while (ctx->smoothNextOutY < ctx->outHeight) {
|
||||
const uint32_t srcY_fp =
|
||||
interpolatedSourceFp(ctx->smoothNextOutY, ctx->outHeight, ctx->srcHeight, ctx->smoothScaleY_fp);
|
||||
const int y0 = srcY_fp >> 16;
|
||||
const int y1 = (y0 + 1 < ctx->srcHeight) ? (y0 + 1) : y0;
|
||||
if (y1 > srcY) break;
|
||||
|
||||
const uint8_t* row0 = (y0 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow;
|
||||
const uint8_t* row1 = (y1 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow;
|
||||
writeBlendedRow(ctx, row0, row1, srcY_fp & (FP_ONE - 1), ctx->smoothNextOutY);
|
||||
ctx->smoothNextOutY++;
|
||||
}
|
||||
|
||||
uint8_t* tmp = ctx->smoothPrevRow;
|
||||
ctx->smoothPrevRow = ctx->smoothCurrRow;
|
||||
ctx->smoothCurrRow = tmp;
|
||||
ctx->smoothPrevY = srcY;
|
||||
}
|
||||
|
||||
static void finishSmoothUpscale(BmpConvertCtx* ctx) {
|
||||
if (ctx->smoothPrevY < 0) {
|
||||
LOG_ERR("JPG", "No progressive rows decoded for smoothing");
|
||||
ctx->error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
while (ctx->smoothNextOutY < ctx->outHeight) {
|
||||
writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY);
|
||||
ctx->smoothNextOutY++;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush one scaled output row from Y-axis accumulators and advance currentOutY
|
||||
static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
|
||||
@@ -344,7 +437,9 @@ int bmpDrawCallback(JPEGDRAW* pDraw) {
|
||||
for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) {
|
||||
const uint8_t* srcRow = ctx->mcuBuf.get() + (y - blockY) * ctx->srcWidth;
|
||||
|
||||
if (!ctx->needsScaling) {
|
||||
if (ctx->smoothUpscale) {
|
||||
processSmoothSourceRow(ctx, srcRow, y);
|
||||
} else if (!ctx->needsScaling) {
|
||||
// 1:1 — outWidth == srcWidth, write directly
|
||||
writeOutputRow(ctx, srcRow, y);
|
||||
} else {
|
||||
@@ -472,6 +567,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
needsScaling = true;
|
||||
}
|
||||
|
||||
const bool smoothUpscale =
|
||||
progressiveDecode && needsScaling && scaleSrcWidth <= outWidth && scaleSrcHeight <= outHeight;
|
||||
|
||||
// Write BMP header with output dimensions
|
||||
int bytesPerRow;
|
||||
if (USE_8BIT_OUTPUT && !oneBit) {
|
||||
@@ -496,6 +594,11 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
ctx.needsScaling = needsScaling;
|
||||
ctx.scaleX_fp = scaleX_fp;
|
||||
ctx.scaleY_fp = scaleY_fp;
|
||||
ctx.smoothUpscale = smoothUpscale;
|
||||
ctx.smoothScaleX_fp = interpolationStep(ctx.srcWidth, outWidth);
|
||||
ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight);
|
||||
ctx.smoothNextOutY = 0;
|
||||
ctx.smoothPrevY = -1;
|
||||
ctx.error = false;
|
||||
|
||||
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
|
||||
@@ -512,7 +615,20 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
return false;
|
||||
}
|
||||
|
||||
if (needsScaling) {
|
||||
if (smoothUpscale) {
|
||||
// One contiguous allocation avoids three heap blocks while keeping smoothing line-buffered.
|
||||
const size_t smoothRowsBytes = static_cast<size_t>(outWidth) * 3;
|
||||
ctx.smoothRows = makeUniqueNoThrow<uint8_t[]>(smoothRowsBytes);
|
||||
if (!ctx.smoothRows) {
|
||||
LOG_ERR("JPG", "OOM: progressive smoothing buffers");
|
||||
return false;
|
||||
}
|
||||
ctx.smoothPrevRow = ctx.smoothRows.get();
|
||||
ctx.smoothCurrRow = ctx.smoothPrevRow + outWidth;
|
||||
ctx.smoothOutRow = ctx.smoothCurrRow + outWidth;
|
||||
LOG_DBG("JPG", "Progressive smoothing: %dx%d -> %dx%d, buffers=%u bytes", ctx.srcWidth, ctx.srcHeight, outWidth,
|
||||
outHeight, static_cast<unsigned>(smoothRowsBytes));
|
||||
} else if (needsScaling) {
|
||||
ctx.rowAccum = makeUniqueNoThrow<uint32_t[]>(outWidth);
|
||||
ctx.rowCount = makeUniqueNoThrow<uint32_t[]>(outWidth);
|
||||
if (!ctx.rowAccum || !ctx.rowCount) {
|
||||
@@ -549,6 +665,10 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
|
||||
rc = jpeg->decode(0, 0, 0);
|
||||
|
||||
if (rc == 1 && ctx.smoothUpscale && !ctx.error) {
|
||||
finishSmoothUpscale(&ctx);
|
||||
}
|
||||
|
||||
if (rc != 1 || ctx.error) {
|
||||
LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError());
|
||||
return false;
|
||||
|
||||
@@ -278,14 +278,19 @@ class XPathParagraphResolver final : public Print {
|
||||
path.push_back({name, siblingIndex});
|
||||
parentStates.emplace_back();
|
||||
|
||||
// Count both <p> and <li> as paragraph-like positions, matching how the section
|
||||
// layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures
|
||||
// KOReader progress in list items maps to the correct XPath.
|
||||
if (name == "p") {
|
||||
paragraphCount++;
|
||||
} else if (name == "li") {
|
||||
paragraphCount++;
|
||||
}
|
||||
if (paragraphCount == targetParagraph) {
|
||||
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
||||
stopped = true;
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ int parseIndex(const std::string& xpath, const char* prefix, bool last = false)
|
||||
|
||||
int parseCharOffset(const std::string& xpath) {
|
||||
const size_t textPos = xpath.rfind("text()");
|
||||
if (textPos == std::string::npos) return 0;
|
||||
const size_t dotPos = xpath.find('.', textPos);
|
||||
const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.');
|
||||
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0;
|
||||
int val = 0;
|
||||
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
|
||||
@@ -104,7 +103,13 @@ bool isChapterStartXPath(const std::string& xpath) {
|
||||
if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) {
|
||||
return false;
|
||||
}
|
||||
if (xpath.find('/', bodyContentStart) != std::string::npos) {
|
||||
size_t terminalEnd = dotPos;
|
||||
static constexpr char kTextNode[] = "/text()";
|
||||
const size_t textNodePos = xpath.rfind(kTextNode, dotPos);
|
||||
if (textNodePos != std::string::npos && textNodePos >= bodyContentStart) {
|
||||
terminalEnd = textNodePos;
|
||||
}
|
||||
if (xpath.find('/', bodyContentStart) < terminalEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -122,7 +127,7 @@ struct XPathStep {
|
||||
|
||||
static constexpr int MAX_XPATH_DEPTH = 16;
|
||||
|
||||
// Parse the XPath segment between /body/DocFragment[N]/body/ and text()[N].offset
|
||||
// Parse the XPath segment between /body/DocFragment[N]/body/ and the terminal position
|
||||
// into an ordered sequence of steps. Returns step count, 0 on failure.
|
||||
// Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51"
|
||||
// Fills steps with: {div,1}, {ul,1}, {li,4}
|
||||
@@ -136,13 +141,20 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH])
|
||||
if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0;
|
||||
size_t pos = afterBracket + 1 + strlen(kBody);
|
||||
|
||||
const size_t textPos = xpath.rfind("/text()");
|
||||
if (textPos == std::string::npos || textPos <= pos) return 0;
|
||||
size_t stepsEnd = xpath.rfind("/text()");
|
||||
if (stepsEnd == std::string::npos) {
|
||||
stepsEnd = xpath.rfind('.');
|
||||
if (stepsEnd == std::string::npos || stepsEnd <= pos || stepsEnd + 1 >= xpath.size()) return 0;
|
||||
for (size_t i = stepsEnd + 1; i < xpath.size(); i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 0;
|
||||
}
|
||||
}
|
||||
if (stepsEnd <= pos) return 0;
|
||||
|
||||
int count = 0;
|
||||
while (pos < textPos && count < MAX_XPATH_DEPTH) {
|
||||
while (pos < stepsEnd && count < MAX_XPATH_DEPTH) {
|
||||
const size_t slash = xpath.find('/', pos);
|
||||
const size_t segEnd = (slash < textPos) ? slash : textPos;
|
||||
const size_t segEnd = (slash < stepsEnd) ? slash : stepsEnd;
|
||||
|
||||
XPathStep& step = steps[count];
|
||||
const size_t bracket = xpath.find('[', pos);
|
||||
@@ -166,7 +178,7 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH])
|
||||
}
|
||||
|
||||
count++;
|
||||
pos = (slash < textPos) ? slash + 1 : textPos;
|
||||
pos = (slash < stepsEnd) ? slash + 1 : stepsEnd;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -223,10 +235,148 @@ class ParagraphStreamer final : public Print {
|
||||
char capturedAnchorId[MAX_ANCHOR_ID] = {};
|
||||
int capturedAnchorIdLen = 0;
|
||||
bool capturingAnchorTag = false;
|
||||
enum IdScanState { ID_SCAN, ID_I, ID_D, ID_EQ, ID_IN_VALUE_D, ID_IN_VALUE_S } idState = ID_SCAN;
|
||||
enum AnchorAttrState {
|
||||
ATTR_FIND_NAME,
|
||||
ATTR_READ_NAME,
|
||||
ATTR_AFTER_NAME,
|
||||
ATTR_BEFORE_VALUE,
|
||||
ATTR_CAPTURE_D,
|
||||
ATTR_CAPTURE_S
|
||||
} attrState = ATTR_FIND_NAME;
|
||||
uint8_t attrNameLen = 0;
|
||||
bool currentAttrIsId = false;
|
||||
bool inAttrQuote =
|
||||
false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close)
|
||||
char attrQuoteChar = 0;
|
||||
uint8_t nonVisibleDepth = 0;
|
||||
|
||||
bool isNonVisibleTag() const {
|
||||
return strcasecmp(tagName, "head") == 0 || strcasecmp(tagName, "style") == 0 ||
|
||||
strcasecmp(tagName, "script") == 0 || strcasecmp(tagName, "title") == 0;
|
||||
}
|
||||
|
||||
static bool isAttrWhitespace(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
|
||||
|
||||
static bool isAttrNameChar(uint8_t c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ||
|
||||
c == ':' || c == '.';
|
||||
}
|
||||
|
||||
void resetAnchorAttrScan() {
|
||||
attrState = ATTR_FIND_NAME;
|
||||
attrNameLen = 0;
|
||||
currentAttrIsId = false;
|
||||
}
|
||||
|
||||
void finishCapturedAnchorId() {
|
||||
capturedAnchorId[capturedAnchorIdLen] = '\0';
|
||||
capturingAnchorTag = false;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void beginAnchorIdScan() {
|
||||
capturingAnchorTag = true;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void endAnchorIdScan() {
|
||||
if (capturingAnchorTag) {
|
||||
capturedAnchorIdLen = 0;
|
||||
}
|
||||
capturingAnchorTag = false;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void appendCapturedAnchorId(uint8_t c) {
|
||||
if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) {
|
||||
capturedAnchorId[capturedAnchorIdLen++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
void scanAnchorAttribute(uint8_t c) {
|
||||
switch (attrState) {
|
||||
case ATTR_FIND_NAME:
|
||||
if (isAttrNameChar(c)) {
|
||||
attrState = ATTR_READ_NAME;
|
||||
attrNameLen = 1;
|
||||
currentAttrIsId = c == 'i';
|
||||
}
|
||||
break;
|
||||
case ATTR_READ_NAME:
|
||||
if (isAttrNameChar(c)) {
|
||||
if (attrNameLen == 1) {
|
||||
currentAttrIsId = currentAttrIsId && c == 'd';
|
||||
} else {
|
||||
currentAttrIsId = false;
|
||||
}
|
||||
attrNameLen++;
|
||||
} else {
|
||||
currentAttrIsId = currentAttrIsId && attrNameLen == 2;
|
||||
if (isAttrWhitespace(c)) {
|
||||
attrState = ATTR_AFTER_NAME;
|
||||
} else if (c == '=') {
|
||||
attrState = ATTR_BEFORE_VALUE;
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ATTR_AFTER_NAME:
|
||||
if (isAttrWhitespace(c)) {
|
||||
break;
|
||||
}
|
||||
if (c == '=') {
|
||||
attrState = ATTR_BEFORE_VALUE;
|
||||
} else if (isAttrNameChar(c)) {
|
||||
attrState = ATTR_READ_NAME;
|
||||
attrNameLen = 1;
|
||||
currentAttrIsId = c == 'i';
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
break;
|
||||
case ATTR_BEFORE_VALUE:
|
||||
if (isAttrWhitespace(c)) {
|
||||
break;
|
||||
}
|
||||
if (currentAttrIsId && c == '"') {
|
||||
capturedAnchorIdLen = 0;
|
||||
attrState = ATTR_CAPTURE_D;
|
||||
} else if (currentAttrIsId && c == '\'') {
|
||||
capturedAnchorIdLen = 0;
|
||||
attrState = ATTR_CAPTURE_S;
|
||||
} else if (c == '"') {
|
||||
attrState = ATTR_CAPTURE_D;
|
||||
} else if (c == '\'') {
|
||||
attrState = ATTR_CAPTURE_S;
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
break;
|
||||
case ATTR_CAPTURE_D:
|
||||
if (c == '"') {
|
||||
if (currentAttrIsId) {
|
||||
finishCapturedAnchorId();
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
} else if (currentAttrIsId) {
|
||||
appendCapturedAnchorId(c);
|
||||
}
|
||||
break;
|
||||
case ATTR_CAPTURE_S:
|
||||
if (c == '\'') {
|
||||
if (currentAttrIsId) {
|
||||
finishCapturedAnchorId();
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
} else if (currentAttrIsId) {
|
||||
appendCapturedAnchorId(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void onVisibleCodepoint() {
|
||||
totalVisChars++;
|
||||
@@ -286,15 +436,19 @@ class ParagraphStreamer final : public Print {
|
||||
void onOpenTag() {
|
||||
htmlDepth++;
|
||||
|
||||
if (nonVisibleDepth > 0 || isNonVisibleTag()) {
|
||||
nonVisibleDepth++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepCount == 0) {
|
||||
if (strcasecmp(tagName, "p") == 0) onLegacyP();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture <a id> inside the fully-matched element even after target char is found
|
||||
// Capture a child <a id> inside the fully-matched element even after target char is found.
|
||||
if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) {
|
||||
capturingAnchorTag = true;
|
||||
idState = ID_SCAN;
|
||||
beginAnchorIdScan();
|
||||
}
|
||||
|
||||
if (revDone) return;
|
||||
@@ -315,6 +469,7 @@ class ParagraphStreamer final : public Print {
|
||||
stepEnteredAtDepth[matchedDepth] = htmlDepth;
|
||||
matchedDepth++;
|
||||
if (matchedDepth == stepCount) {
|
||||
beginAnchorIdScan();
|
||||
paragraphAtMatch = pCount;
|
||||
liCountAtMatch = liCount;
|
||||
revPFound = true;
|
||||
@@ -332,6 +487,12 @@ class ParagraphStreamer final : public Print {
|
||||
}
|
||||
|
||||
void onCloseTag() {
|
||||
if (nonVisibleDepth > 0) {
|
||||
nonVisibleDepth--;
|
||||
if (htmlDepth > 0) htmlDepth--;
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: each direct child element closing advances the text node index.
|
||||
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) {
|
||||
currentTextNode++;
|
||||
@@ -419,42 +580,12 @@ class ParagraphStreamer final : public Print {
|
||||
attrQuoteChar = 0;
|
||||
}
|
||||
if (capturingAnchorTag) {
|
||||
switch (idState) {
|
||||
case ID_SCAN:
|
||||
idState = (c == 'i' || c == 'I') ? ID_I : ID_SCAN;
|
||||
break;
|
||||
case ID_I:
|
||||
idState = (c == 'd' || c == 'D') ? ID_D : ID_SCAN;
|
||||
break;
|
||||
case ID_D:
|
||||
idState = (c == '=') ? ID_EQ : ID_SCAN;
|
||||
break;
|
||||
case ID_EQ:
|
||||
if (c == '"')
|
||||
idState = ID_IN_VALUE_D;
|
||||
else if (c == '\'')
|
||||
idState = ID_IN_VALUE_S;
|
||||
break;
|
||||
case ID_IN_VALUE_D:
|
||||
if (c == '"') {
|
||||
capturedAnchorId[capturedAnchorIdLen] = '\0';
|
||||
capturingAnchorTag = false;
|
||||
} else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID)
|
||||
capturedAnchorId[capturedAnchorIdLen++] = c;
|
||||
break;
|
||||
case ID_IN_VALUE_S:
|
||||
if (c == '\'') {
|
||||
capturedAnchorId[capturedAnchorIdLen] = '\0';
|
||||
capturingAnchorTag = false;
|
||||
} else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID)
|
||||
capturedAnchorId[capturedAnchorIdLen++] = c;
|
||||
break;
|
||||
}
|
||||
scanAnchorAttribute(c);
|
||||
}
|
||||
// Only treat '/' as self-closing when outside a quoted attribute value.
|
||||
if (c == '/' && !inAttrQuote) {
|
||||
endAnchorIdScan();
|
||||
onCloseTag();
|
||||
capturingAnchorTag = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -512,10 +643,13 @@ class ParagraphStreamer final : public Print {
|
||||
tagNameLen = 0;
|
||||
tagIsClose = false;
|
||||
capturingAnchorTag = false;
|
||||
idState = ID_SCAN;
|
||||
resetAnchorAttrScan();
|
||||
inAttrQuote = false;
|
||||
attrQuoteChar = 0;
|
||||
} else if (c == '>') {
|
||||
if (tagState == TAG_ATTRS) {
|
||||
endAnchorIdScan();
|
||||
}
|
||||
globalInTag = false;
|
||||
inAttrQuote = false;
|
||||
if (tagState == TAG_IN_NAME && tagNameLen > 0) {
|
||||
@@ -529,6 +663,9 @@ class ParagraphStreamer final : public Print {
|
||||
tagState = TAG_IDLE;
|
||||
} else if (globalInTag) {
|
||||
processByteInTag(c);
|
||||
} else if (nonVisibleDepth > 0) {
|
||||
// Ignore head/style/script/title text. KOReader XPaths are body-relative, and CSS text
|
||||
// should not contribute to intra-spine progress.
|
||||
} else {
|
||||
if (c == '&') {
|
||||
globalInEntity = true;
|
||||
@@ -572,12 +709,13 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
||||
float intra =
|
||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||
// Progress-based XPath correctly handles both <p> and <li> positions.
|
||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||
// Fall back to paragraph-index lookup when progress-based resolution fails.
|
||||
if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
||||
}
|
||||
// Fall back to progress-based XPath, then synthetic progress mapping.
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||
}
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ OpdsParser::OpdsParser() {
|
||||
if (!parser) {
|
||||
errorOccured = true;
|
||||
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
||||
return;
|
||||
}
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
}
|
||||
|
||||
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
|
||||
@@ -20,10 +24,6 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
||||
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
if (errorOccured) return length;
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
|
||||
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
||||
size_t remaining = length;
|
||||
constexpr size_t chunkSize = 1024;
|
||||
@@ -54,6 +54,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
}
|
||||
|
||||
void OpdsParser::flush() {
|
||||
if (errorOccured || !parser) return;
|
||||
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
||||
errorOccured = true;
|
||||
destroyXmlParser(parser);
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ std::string Txt::getTitle() const {
|
||||
|
||||
// Remove .txt extension
|
||||
if (FsHelpers::hasTxtExtension(filename)) {
|
||||
filename = filename.substr(0, filename.length() - 4);
|
||||
filename.resize(filename.length() - 4);
|
||||
}
|
||||
|
||||
return filename;
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
#include "Utf8.h"
|
||||
|
||||
#include "Utf8ComposeTable.h"
|
||||
|
||||
namespace {
|
||||
// Look up the canonical composition of (base + combining mark), or 0 if none.
|
||||
uint32_t utf8ComposePair(const uint32_t base, const uint32_t mark) {
|
||||
if (base > 0xFFFF || mark > 0xFFFF) return 0;
|
||||
int lo = 0;
|
||||
int hi = kUtf8ComposeTableSize - 1;
|
||||
while (lo <= hi) {
|
||||
const int mid = (lo + hi) / 2;
|
||||
const Utf8ComposeEntry& e = kUtf8ComposeTable[mid];
|
||||
if (e.base < base || (e.base == base && e.mark < mark)) {
|
||||
lo = mid + 1;
|
||||
} else if (e.base > base || (e.base == base && e.mark > mark)) {
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
return e.composed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string utf8ComposeNfc(const std::string& in) {
|
||||
// Fast path: NFC composition can only change text that contains a combining
|
||||
// diacritical mark U+0300-036F (UTF-8 lead byte 0xCC or 0xCD). Plain ASCII and
|
||||
// already-precomposed (NFC) text -- the vast majority of words -- have none, so
|
||||
// return them untouched without walking codepoints or allocating. A 0xCD that is
|
||||
// actually a non-combining codepoint just falls through to the full pass below.
|
||||
bool maybeHasMarks = false;
|
||||
for (const unsigned char c : in) {
|
||||
if (c == 0xCC || c == 0xCD) {
|
||||
maybeHasMarks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!maybeHasMarks) return in;
|
||||
|
||||
std::string out;
|
||||
out.reserve(in.size());
|
||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(in.c_str());
|
||||
uint32_t base = 0;
|
||||
bool haveBase = false;
|
||||
while (*p) {
|
||||
const uint32_t cp = utf8NextCodepoint(&p);
|
||||
if (cp == 0) break;
|
||||
if (utf8IsCombiningMark(cp)) {
|
||||
const uint32_t composed = haveBase ? utf8ComposePair(base, cp) : 0;
|
||||
if (composed) {
|
||||
base = composed; // keep accumulating further marks onto the composed char
|
||||
continue;
|
||||
}
|
||||
// No composition: flush the pending base, then emit the mark unchanged.
|
||||
if (haveBase) {
|
||||
utf8AppendCodepoint(base, out);
|
||||
haveBase = false;
|
||||
}
|
||||
utf8AppendCodepoint(cp, out);
|
||||
} else {
|
||||
if (haveBase) utf8AppendCodepoint(base, out);
|
||||
base = cp;
|
||||
haveBase = true;
|
||||
}
|
||||
}
|
||||
if (haveBase) utf8AppendCodepoint(base, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
int utf8CodepointLen(const unsigned char c) {
|
||||
if (c < 0x80) return 1; // 0xxxxxxx
|
||||
if ((c >> 5) == 0x6) return 2; // 110xxxxx
|
||||
|
||||
+10
-1
@@ -12,6 +12,12 @@ size_t utf8RemoveLastChar(std::string& str);
|
||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
|
||||
// Canonical composition (NFC) for the Latin / Vietnamese range: precomposes a
|
||||
// base letter followed by combining diacritical mark(s) into a single codepoint.
|
||||
// Needed because the device fonts have no combining-mark positioning, so text
|
||||
// stored in NFD (e.g. some EPUB chapter titles) otherwise renders broken.
|
||||
std::string utf8ComposeNfc(const std::string& in);
|
||||
|
||||
// Truncate a raw char buffer to the last complete UTF-8 codepoint boundary.
|
||||
// Returns the new length (<= len). If the buffer ends mid-sequence, the
|
||||
// incomplete trailing bytes are excluded.
|
||||
@@ -21,12 +27,15 @@ int utf8SafeTruncateBuffer(const char* buf, int len);
|
||||
// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation,
|
||||
// and fullwidth forms — the ranges where word boundaries are implicit per character.
|
||||
inline bool utf8IsCjkBreakable(const uint32_t cp) {
|
||||
return (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
|
||||
return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo
|
||||
|| (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
|
||||
|| (cp >= 0x3040 && cp <= 0x309F) // Hiragana
|
||||
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|
||||
|| (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo
|
||||
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|
||||
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|
||||
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|
||||
|| (cp >= 0xD7B0 && cp <= 0xD7FF) // Hangul Jamo Extended-B
|
||||
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|
||||
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|
||||
|| (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Auto-generated canonical composition (NFC) table for the Latin / Vietnamese
|
||||
// range (combining marks U+0300-U+036F). Generated from Python unicodedata.
|
||||
// Used by utf8ComposeNfc() to precompose decomposed (NFD) text so the device
|
||||
// fonts (which lack combining-mark positioning) render it correctly.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
struct Utf8ComposeEntry {
|
||||
uint16_t base;
|
||||
uint16_t mark;
|
||||
uint16_t composed;
|
||||
};
|
||||
|
||||
// Sorted by (base, mark) for binary search.
|
||||
static constexpr Utf8ComposeEntry kUtf8ComposeTable[] = {
|
||||
{0x0041, 0x0300, 0x00C0}, {0x0041, 0x0301, 0x00C1}, {0x0041, 0x0302, 0x00C2}, {0x0041, 0x0303, 0x00C3},
|
||||
{0x0041, 0x0304, 0x0100}, {0x0041, 0x0306, 0x0102}, {0x0041, 0x0307, 0x0226}, {0x0041, 0x0308, 0x00C4},
|
||||
{0x0041, 0x0309, 0x1EA2}, {0x0041, 0x030A, 0x00C5}, {0x0041, 0x030C, 0x01CD}, {0x0041, 0x030F, 0x0200},
|
||||
{0x0041, 0x0311, 0x0202}, {0x0041, 0x0323, 0x1EA0}, {0x0041, 0x0325, 0x1E00}, {0x0041, 0x0328, 0x0104},
|
||||
{0x0041, 0x0340, 0x00C0}, {0x0041, 0x0341, 0x00C1}, {0x0042, 0x0307, 0x1E02}, {0x0042, 0x0323, 0x1E04},
|
||||
{0x0042, 0x0331, 0x1E06}, {0x0043, 0x0301, 0x0106}, {0x0043, 0x0302, 0x0108}, {0x0043, 0x0307, 0x010A},
|
||||
{0x0043, 0x030C, 0x010C}, {0x0043, 0x0327, 0x00C7}, {0x0043, 0x0341, 0x0106}, {0x0044, 0x0307, 0x1E0A},
|
||||
{0x0044, 0x030C, 0x010E}, {0x0044, 0x0323, 0x1E0C}, {0x0044, 0x0327, 0x1E10}, {0x0044, 0x032D, 0x1E12},
|
||||
{0x0044, 0x0331, 0x1E0E}, {0x0045, 0x0300, 0x00C8}, {0x0045, 0x0301, 0x00C9}, {0x0045, 0x0302, 0x00CA},
|
||||
{0x0045, 0x0303, 0x1EBC}, {0x0045, 0x0304, 0x0112}, {0x0045, 0x0306, 0x0114}, {0x0045, 0x0307, 0x0116},
|
||||
{0x0045, 0x0308, 0x00CB}, {0x0045, 0x0309, 0x1EBA}, {0x0045, 0x030C, 0x011A}, {0x0045, 0x030F, 0x0204},
|
||||
{0x0045, 0x0311, 0x0206}, {0x0045, 0x0323, 0x1EB8}, {0x0045, 0x0327, 0x0228}, {0x0045, 0x0328, 0x0118},
|
||||
{0x0045, 0x032D, 0x1E18}, {0x0045, 0x0330, 0x1E1A}, {0x0045, 0x0340, 0x00C8}, {0x0045, 0x0341, 0x00C9},
|
||||
{0x0046, 0x0307, 0x1E1E}, {0x0047, 0x0301, 0x01F4}, {0x0047, 0x0302, 0x011C}, {0x0047, 0x0304, 0x1E20},
|
||||
{0x0047, 0x0306, 0x011E}, {0x0047, 0x0307, 0x0120}, {0x0047, 0x030C, 0x01E6}, {0x0047, 0x0327, 0x0122},
|
||||
{0x0047, 0x0341, 0x01F4}, {0x0048, 0x0302, 0x0124}, {0x0048, 0x0307, 0x1E22}, {0x0048, 0x0308, 0x1E26},
|
||||
{0x0048, 0x030C, 0x021E}, {0x0048, 0x0323, 0x1E24}, {0x0048, 0x0327, 0x1E28}, {0x0048, 0x032E, 0x1E2A},
|
||||
{0x0049, 0x0300, 0x00CC}, {0x0049, 0x0301, 0x00CD}, {0x0049, 0x0302, 0x00CE}, {0x0049, 0x0303, 0x0128},
|
||||
{0x0049, 0x0304, 0x012A}, {0x0049, 0x0306, 0x012C}, {0x0049, 0x0307, 0x0130}, {0x0049, 0x0308, 0x00CF},
|
||||
{0x0049, 0x0309, 0x1EC8}, {0x0049, 0x030C, 0x01CF}, {0x0049, 0x030F, 0x0208}, {0x0049, 0x0311, 0x020A},
|
||||
{0x0049, 0x0323, 0x1ECA}, {0x0049, 0x0328, 0x012E}, {0x0049, 0x0330, 0x1E2C}, {0x0049, 0x0340, 0x00CC},
|
||||
{0x0049, 0x0341, 0x00CD}, {0x0049, 0x0344, 0x1E2E}, {0x004A, 0x0302, 0x0134}, {0x004B, 0x0301, 0x1E30},
|
||||
{0x004B, 0x030C, 0x01E8}, {0x004B, 0x0323, 0x1E32}, {0x004B, 0x0327, 0x0136}, {0x004B, 0x0331, 0x1E34},
|
||||
{0x004B, 0x0341, 0x1E30}, {0x004C, 0x0301, 0x0139}, {0x004C, 0x030C, 0x013D}, {0x004C, 0x0323, 0x1E36},
|
||||
{0x004C, 0x0327, 0x013B}, {0x004C, 0x032D, 0x1E3C}, {0x004C, 0x0331, 0x1E3A}, {0x004C, 0x0341, 0x0139},
|
||||
{0x004D, 0x0301, 0x1E3E}, {0x004D, 0x0307, 0x1E40}, {0x004D, 0x0323, 0x1E42}, {0x004D, 0x0341, 0x1E3E},
|
||||
{0x004E, 0x0300, 0x01F8}, {0x004E, 0x0301, 0x0143}, {0x004E, 0x0303, 0x00D1}, {0x004E, 0x0307, 0x1E44},
|
||||
{0x004E, 0x030C, 0x0147}, {0x004E, 0x0323, 0x1E46}, {0x004E, 0x0327, 0x0145}, {0x004E, 0x032D, 0x1E4A},
|
||||
{0x004E, 0x0331, 0x1E48}, {0x004E, 0x0340, 0x01F8}, {0x004E, 0x0341, 0x0143}, {0x004F, 0x0300, 0x00D2},
|
||||
{0x004F, 0x0301, 0x00D3}, {0x004F, 0x0302, 0x00D4}, {0x004F, 0x0303, 0x00D5}, {0x004F, 0x0304, 0x014C},
|
||||
{0x004F, 0x0306, 0x014E}, {0x004F, 0x0307, 0x022E}, {0x004F, 0x0308, 0x00D6}, {0x004F, 0x0309, 0x1ECE},
|
||||
{0x004F, 0x030B, 0x0150}, {0x004F, 0x030C, 0x01D1}, {0x004F, 0x030F, 0x020C}, {0x004F, 0x0311, 0x020E},
|
||||
{0x004F, 0x031B, 0x01A0}, {0x004F, 0x0323, 0x1ECC}, {0x004F, 0x0328, 0x01EA}, {0x004F, 0x0340, 0x00D2},
|
||||
{0x004F, 0x0341, 0x00D3}, {0x0050, 0x0301, 0x1E54}, {0x0050, 0x0307, 0x1E56}, {0x0050, 0x0341, 0x1E54},
|
||||
{0x0052, 0x0301, 0x0154}, {0x0052, 0x0307, 0x1E58}, {0x0052, 0x030C, 0x0158}, {0x0052, 0x030F, 0x0210},
|
||||
{0x0052, 0x0311, 0x0212}, {0x0052, 0x0323, 0x1E5A}, {0x0052, 0x0327, 0x0156}, {0x0052, 0x0331, 0x1E5E},
|
||||
{0x0052, 0x0341, 0x0154}, {0x0053, 0x0301, 0x015A}, {0x0053, 0x0302, 0x015C}, {0x0053, 0x0307, 0x1E60},
|
||||
{0x0053, 0x030C, 0x0160}, {0x0053, 0x0323, 0x1E62}, {0x0053, 0x0326, 0x0218}, {0x0053, 0x0327, 0x015E},
|
||||
{0x0053, 0x0341, 0x015A}, {0x0054, 0x0307, 0x1E6A}, {0x0054, 0x030C, 0x0164}, {0x0054, 0x0323, 0x1E6C},
|
||||
{0x0054, 0x0326, 0x021A}, {0x0054, 0x0327, 0x0162}, {0x0054, 0x032D, 0x1E70}, {0x0054, 0x0331, 0x1E6E},
|
||||
{0x0055, 0x0300, 0x00D9}, {0x0055, 0x0301, 0x00DA}, {0x0055, 0x0302, 0x00DB}, {0x0055, 0x0303, 0x0168},
|
||||
{0x0055, 0x0304, 0x016A}, {0x0055, 0x0306, 0x016C}, {0x0055, 0x0308, 0x00DC}, {0x0055, 0x0309, 0x1EE6},
|
||||
{0x0055, 0x030A, 0x016E}, {0x0055, 0x030B, 0x0170}, {0x0055, 0x030C, 0x01D3}, {0x0055, 0x030F, 0x0214},
|
||||
{0x0055, 0x0311, 0x0216}, {0x0055, 0x031B, 0x01AF}, {0x0055, 0x0323, 0x1EE4}, {0x0055, 0x0324, 0x1E72},
|
||||
{0x0055, 0x0328, 0x0172}, {0x0055, 0x032D, 0x1E76}, {0x0055, 0x0330, 0x1E74}, {0x0055, 0x0340, 0x00D9},
|
||||
{0x0055, 0x0341, 0x00DA}, {0x0055, 0x0344, 0x01D7}, {0x0056, 0x0303, 0x1E7C}, {0x0056, 0x0323, 0x1E7E},
|
||||
{0x0057, 0x0300, 0x1E80}, {0x0057, 0x0301, 0x1E82}, {0x0057, 0x0302, 0x0174}, {0x0057, 0x0307, 0x1E86},
|
||||
{0x0057, 0x0308, 0x1E84}, {0x0057, 0x0323, 0x1E88}, {0x0057, 0x0340, 0x1E80}, {0x0057, 0x0341, 0x1E82},
|
||||
{0x0058, 0x0307, 0x1E8A}, {0x0058, 0x0308, 0x1E8C}, {0x0059, 0x0300, 0x1EF2}, {0x0059, 0x0301, 0x00DD},
|
||||
{0x0059, 0x0302, 0x0176}, {0x0059, 0x0303, 0x1EF8}, {0x0059, 0x0304, 0x0232}, {0x0059, 0x0307, 0x1E8E},
|
||||
{0x0059, 0x0308, 0x0178}, {0x0059, 0x0309, 0x1EF6}, {0x0059, 0x0323, 0x1EF4}, {0x0059, 0x0340, 0x1EF2},
|
||||
{0x0059, 0x0341, 0x00DD}, {0x005A, 0x0301, 0x0179}, {0x005A, 0x0302, 0x1E90}, {0x005A, 0x0307, 0x017B},
|
||||
{0x005A, 0x030C, 0x017D}, {0x005A, 0x0323, 0x1E92}, {0x005A, 0x0331, 0x1E94}, {0x005A, 0x0341, 0x0179},
|
||||
{0x0061, 0x0300, 0x00E0}, {0x0061, 0x0301, 0x00E1}, {0x0061, 0x0302, 0x00E2}, {0x0061, 0x0303, 0x00E3},
|
||||
{0x0061, 0x0304, 0x0101}, {0x0061, 0x0306, 0x0103}, {0x0061, 0x0307, 0x0227}, {0x0061, 0x0308, 0x00E4},
|
||||
{0x0061, 0x0309, 0x1EA3}, {0x0061, 0x030A, 0x00E5}, {0x0061, 0x030C, 0x01CE}, {0x0061, 0x030F, 0x0201},
|
||||
{0x0061, 0x0311, 0x0203}, {0x0061, 0x0323, 0x1EA1}, {0x0061, 0x0325, 0x1E01}, {0x0061, 0x0328, 0x0105},
|
||||
{0x0061, 0x0340, 0x00E0}, {0x0061, 0x0341, 0x00E1}, {0x0062, 0x0307, 0x1E03}, {0x0062, 0x0323, 0x1E05},
|
||||
{0x0062, 0x0331, 0x1E07}, {0x0063, 0x0301, 0x0107}, {0x0063, 0x0302, 0x0109}, {0x0063, 0x0307, 0x010B},
|
||||
{0x0063, 0x030C, 0x010D}, {0x0063, 0x0327, 0x00E7}, {0x0063, 0x0341, 0x0107}, {0x0064, 0x0307, 0x1E0B},
|
||||
{0x0064, 0x030C, 0x010F}, {0x0064, 0x0323, 0x1E0D}, {0x0064, 0x0327, 0x1E11}, {0x0064, 0x032D, 0x1E13},
|
||||
{0x0064, 0x0331, 0x1E0F}, {0x0065, 0x0300, 0x00E8}, {0x0065, 0x0301, 0x00E9}, {0x0065, 0x0302, 0x00EA},
|
||||
{0x0065, 0x0303, 0x1EBD}, {0x0065, 0x0304, 0x0113}, {0x0065, 0x0306, 0x0115}, {0x0065, 0x0307, 0x0117},
|
||||
{0x0065, 0x0308, 0x00EB}, {0x0065, 0x0309, 0x1EBB}, {0x0065, 0x030C, 0x011B}, {0x0065, 0x030F, 0x0205},
|
||||
{0x0065, 0x0311, 0x0207}, {0x0065, 0x0323, 0x1EB9}, {0x0065, 0x0327, 0x0229}, {0x0065, 0x0328, 0x0119},
|
||||
{0x0065, 0x032D, 0x1E19}, {0x0065, 0x0330, 0x1E1B}, {0x0065, 0x0340, 0x00E8}, {0x0065, 0x0341, 0x00E9},
|
||||
{0x0066, 0x0307, 0x1E1F}, {0x0067, 0x0301, 0x01F5}, {0x0067, 0x0302, 0x011D}, {0x0067, 0x0304, 0x1E21},
|
||||
{0x0067, 0x0306, 0x011F}, {0x0067, 0x0307, 0x0121}, {0x0067, 0x030C, 0x01E7}, {0x0067, 0x0327, 0x0123},
|
||||
{0x0067, 0x0341, 0x01F5}, {0x0068, 0x0302, 0x0125}, {0x0068, 0x0307, 0x1E23}, {0x0068, 0x0308, 0x1E27},
|
||||
{0x0068, 0x030C, 0x021F}, {0x0068, 0x0323, 0x1E25}, {0x0068, 0x0327, 0x1E29}, {0x0068, 0x032E, 0x1E2B},
|
||||
{0x0068, 0x0331, 0x1E96}, {0x0069, 0x0300, 0x00EC}, {0x0069, 0x0301, 0x00ED}, {0x0069, 0x0302, 0x00EE},
|
||||
{0x0069, 0x0303, 0x0129}, {0x0069, 0x0304, 0x012B}, {0x0069, 0x0306, 0x012D}, {0x0069, 0x0308, 0x00EF},
|
||||
{0x0069, 0x0309, 0x1EC9}, {0x0069, 0x030C, 0x01D0}, {0x0069, 0x030F, 0x0209}, {0x0069, 0x0311, 0x020B},
|
||||
{0x0069, 0x0323, 0x1ECB}, {0x0069, 0x0328, 0x012F}, {0x0069, 0x0330, 0x1E2D}, {0x0069, 0x0340, 0x00EC},
|
||||
{0x0069, 0x0341, 0x00ED}, {0x0069, 0x0344, 0x1E2F}, {0x006A, 0x0302, 0x0135}, {0x006A, 0x030C, 0x01F0},
|
||||
{0x006B, 0x0301, 0x1E31}, {0x006B, 0x030C, 0x01E9}, {0x006B, 0x0323, 0x1E33}, {0x006B, 0x0327, 0x0137},
|
||||
{0x006B, 0x0331, 0x1E35}, {0x006B, 0x0341, 0x1E31}, {0x006C, 0x0301, 0x013A}, {0x006C, 0x030C, 0x013E},
|
||||
{0x006C, 0x0323, 0x1E37}, {0x006C, 0x0327, 0x013C}, {0x006C, 0x032D, 0x1E3D}, {0x006C, 0x0331, 0x1E3B},
|
||||
{0x006C, 0x0341, 0x013A}, {0x006D, 0x0301, 0x1E3F}, {0x006D, 0x0307, 0x1E41}, {0x006D, 0x0323, 0x1E43},
|
||||
{0x006D, 0x0341, 0x1E3F}, {0x006E, 0x0300, 0x01F9}, {0x006E, 0x0301, 0x0144}, {0x006E, 0x0303, 0x00F1},
|
||||
{0x006E, 0x0307, 0x1E45}, {0x006E, 0x030C, 0x0148}, {0x006E, 0x0323, 0x1E47}, {0x006E, 0x0327, 0x0146},
|
||||
{0x006E, 0x032D, 0x1E4B}, {0x006E, 0x0331, 0x1E49}, {0x006E, 0x0340, 0x01F9}, {0x006E, 0x0341, 0x0144},
|
||||
{0x006F, 0x0300, 0x00F2}, {0x006F, 0x0301, 0x00F3}, {0x006F, 0x0302, 0x00F4}, {0x006F, 0x0303, 0x00F5},
|
||||
{0x006F, 0x0304, 0x014D}, {0x006F, 0x0306, 0x014F}, {0x006F, 0x0307, 0x022F}, {0x006F, 0x0308, 0x00F6},
|
||||
{0x006F, 0x0309, 0x1ECF}, {0x006F, 0x030B, 0x0151}, {0x006F, 0x030C, 0x01D2}, {0x006F, 0x030F, 0x020D},
|
||||
{0x006F, 0x0311, 0x020F}, {0x006F, 0x031B, 0x01A1}, {0x006F, 0x0323, 0x1ECD}, {0x006F, 0x0328, 0x01EB},
|
||||
{0x006F, 0x0340, 0x00F2}, {0x006F, 0x0341, 0x00F3}, {0x0070, 0x0301, 0x1E55}, {0x0070, 0x0307, 0x1E57},
|
||||
{0x0070, 0x0341, 0x1E55}, {0x0072, 0x0301, 0x0155}, {0x0072, 0x0307, 0x1E59}, {0x0072, 0x030C, 0x0159},
|
||||
{0x0072, 0x030F, 0x0211}, {0x0072, 0x0311, 0x0213}, {0x0072, 0x0323, 0x1E5B}, {0x0072, 0x0327, 0x0157},
|
||||
{0x0072, 0x0331, 0x1E5F}, {0x0072, 0x0341, 0x0155}, {0x0073, 0x0301, 0x015B}, {0x0073, 0x0302, 0x015D},
|
||||
{0x0073, 0x0307, 0x1E61}, {0x0073, 0x030C, 0x0161}, {0x0073, 0x0323, 0x1E63}, {0x0073, 0x0326, 0x0219},
|
||||
{0x0073, 0x0327, 0x015F}, {0x0073, 0x0341, 0x015B}, {0x0074, 0x0307, 0x1E6B}, {0x0074, 0x0308, 0x1E97},
|
||||
{0x0074, 0x030C, 0x0165}, {0x0074, 0x0323, 0x1E6D}, {0x0074, 0x0326, 0x021B}, {0x0074, 0x0327, 0x0163},
|
||||
{0x0074, 0x032D, 0x1E71}, {0x0074, 0x0331, 0x1E6F}, {0x0075, 0x0300, 0x00F9}, {0x0075, 0x0301, 0x00FA},
|
||||
{0x0075, 0x0302, 0x00FB}, {0x0075, 0x0303, 0x0169}, {0x0075, 0x0304, 0x016B}, {0x0075, 0x0306, 0x016D},
|
||||
{0x0075, 0x0308, 0x00FC}, {0x0075, 0x0309, 0x1EE7}, {0x0075, 0x030A, 0x016F}, {0x0075, 0x030B, 0x0171},
|
||||
{0x0075, 0x030C, 0x01D4}, {0x0075, 0x030F, 0x0215}, {0x0075, 0x0311, 0x0217}, {0x0075, 0x031B, 0x01B0},
|
||||
{0x0075, 0x0323, 0x1EE5}, {0x0075, 0x0324, 0x1E73}, {0x0075, 0x0328, 0x0173}, {0x0075, 0x032D, 0x1E77},
|
||||
{0x0075, 0x0330, 0x1E75}, {0x0075, 0x0340, 0x00F9}, {0x0075, 0x0341, 0x00FA}, {0x0075, 0x0344, 0x01D8},
|
||||
{0x0076, 0x0303, 0x1E7D}, {0x0076, 0x0323, 0x1E7F}, {0x0077, 0x0300, 0x1E81}, {0x0077, 0x0301, 0x1E83},
|
||||
{0x0077, 0x0302, 0x0175}, {0x0077, 0x0307, 0x1E87}, {0x0077, 0x0308, 0x1E85}, {0x0077, 0x030A, 0x1E98},
|
||||
{0x0077, 0x0323, 0x1E89}, {0x0077, 0x0340, 0x1E81}, {0x0077, 0x0341, 0x1E83}, {0x0078, 0x0307, 0x1E8B},
|
||||
{0x0078, 0x0308, 0x1E8D}, {0x0079, 0x0300, 0x1EF3}, {0x0079, 0x0301, 0x00FD}, {0x0079, 0x0302, 0x0177},
|
||||
{0x0079, 0x0303, 0x1EF9}, {0x0079, 0x0304, 0x0233}, {0x0079, 0x0307, 0x1E8F}, {0x0079, 0x0308, 0x00FF},
|
||||
{0x0079, 0x0309, 0x1EF7}, {0x0079, 0x030A, 0x1E99}, {0x0079, 0x0323, 0x1EF5}, {0x0079, 0x0340, 0x1EF3},
|
||||
{0x0079, 0x0341, 0x00FD}, {0x007A, 0x0301, 0x017A}, {0x007A, 0x0302, 0x1E91}, {0x007A, 0x0307, 0x017C},
|
||||
{0x007A, 0x030C, 0x017E}, {0x007A, 0x0323, 0x1E93}, {0x007A, 0x0331, 0x1E95}, {0x007A, 0x0341, 0x017A},
|
||||
{0x00A8, 0x0300, 0x1FED}, {0x00A8, 0x0301, 0x0385}, {0x00A8, 0x0340, 0x1FED}, {0x00A8, 0x0341, 0x0385},
|
||||
{0x00A8, 0x0342, 0x1FC1}, {0x00C2, 0x0300, 0x1EA6}, {0x00C2, 0x0301, 0x1EA4}, {0x00C2, 0x0303, 0x1EAA},
|
||||
{0x00C2, 0x0309, 0x1EA8}, {0x00C2, 0x0323, 0x1EAC}, {0x00C2, 0x0340, 0x1EA6}, {0x00C2, 0x0341, 0x1EA4},
|
||||
{0x00C4, 0x0304, 0x01DE}, {0x00C5, 0x0301, 0x01FA}, {0x00C5, 0x0341, 0x01FA}, {0x00C6, 0x0301, 0x01FC},
|
||||
{0x00C6, 0x0304, 0x01E2}, {0x00C6, 0x0341, 0x01FC}, {0x00C7, 0x0301, 0x1E08}, {0x00C7, 0x0341, 0x1E08},
|
||||
{0x00CA, 0x0300, 0x1EC0}, {0x00CA, 0x0301, 0x1EBE}, {0x00CA, 0x0303, 0x1EC4}, {0x00CA, 0x0309, 0x1EC2},
|
||||
{0x00CA, 0x0323, 0x1EC6}, {0x00CA, 0x0340, 0x1EC0}, {0x00CA, 0x0341, 0x1EBE}, {0x00CF, 0x0301, 0x1E2E},
|
||||
{0x00CF, 0x0341, 0x1E2E}, {0x00D2, 0x031B, 0x1EDC}, {0x00D3, 0x031B, 0x1EDA}, {0x00D4, 0x0300, 0x1ED2},
|
||||
{0x00D4, 0x0301, 0x1ED0}, {0x00D4, 0x0303, 0x1ED6}, {0x00D4, 0x0309, 0x1ED4}, {0x00D4, 0x0323, 0x1ED8},
|
||||
{0x00D4, 0x0340, 0x1ED2}, {0x00D4, 0x0341, 0x1ED0}, {0x00D5, 0x0301, 0x1E4C}, {0x00D5, 0x0304, 0x022C},
|
||||
{0x00D5, 0x0308, 0x1E4E}, {0x00D5, 0x031B, 0x1EE0}, {0x00D5, 0x0341, 0x1E4C}, {0x00D6, 0x0304, 0x022A},
|
||||
{0x00D8, 0x0301, 0x01FE}, {0x00D8, 0x0341, 0x01FE}, {0x00D9, 0x031B, 0x1EEA}, {0x00DA, 0x031B, 0x1EE8},
|
||||
{0x00DC, 0x0300, 0x01DB}, {0x00DC, 0x0301, 0x01D7}, {0x00DC, 0x0304, 0x01D5}, {0x00DC, 0x030C, 0x01D9},
|
||||
{0x00DC, 0x0340, 0x01DB}, {0x00DC, 0x0341, 0x01D7}, {0x00E2, 0x0300, 0x1EA7}, {0x00E2, 0x0301, 0x1EA5},
|
||||
{0x00E2, 0x0303, 0x1EAB}, {0x00E2, 0x0309, 0x1EA9}, {0x00E2, 0x0323, 0x1EAD}, {0x00E2, 0x0340, 0x1EA7},
|
||||
{0x00E2, 0x0341, 0x1EA5}, {0x00E4, 0x0304, 0x01DF}, {0x00E5, 0x0301, 0x01FB}, {0x00E5, 0x0341, 0x01FB},
|
||||
{0x00E6, 0x0301, 0x01FD}, {0x00E6, 0x0304, 0x01E3}, {0x00E6, 0x0341, 0x01FD}, {0x00E7, 0x0301, 0x1E09},
|
||||
{0x00E7, 0x0341, 0x1E09}, {0x00EA, 0x0300, 0x1EC1}, {0x00EA, 0x0301, 0x1EBF}, {0x00EA, 0x0303, 0x1EC5},
|
||||
{0x00EA, 0x0309, 0x1EC3}, {0x00EA, 0x0323, 0x1EC7}, {0x00EA, 0x0340, 0x1EC1}, {0x00EA, 0x0341, 0x1EBF},
|
||||
{0x00EF, 0x0301, 0x1E2F}, {0x00EF, 0x0341, 0x1E2F}, {0x00F2, 0x031B, 0x1EDD}, {0x00F3, 0x031B, 0x1EDB},
|
||||
{0x00F4, 0x0300, 0x1ED3}, {0x00F4, 0x0301, 0x1ED1}, {0x00F4, 0x0303, 0x1ED7}, {0x00F4, 0x0309, 0x1ED5},
|
||||
{0x00F4, 0x0323, 0x1ED9}, {0x00F4, 0x0340, 0x1ED3}, {0x00F4, 0x0341, 0x1ED1}, {0x00F5, 0x0301, 0x1E4D},
|
||||
{0x00F5, 0x0304, 0x022D}, {0x00F5, 0x0308, 0x1E4F}, {0x00F5, 0x031B, 0x1EE1}, {0x00F5, 0x0341, 0x1E4D},
|
||||
{0x00F6, 0x0304, 0x022B}, {0x00F8, 0x0301, 0x01FF}, {0x00F8, 0x0341, 0x01FF}, {0x00F9, 0x031B, 0x1EEB},
|
||||
{0x00FA, 0x031B, 0x1EE9}, {0x00FC, 0x0300, 0x01DC}, {0x00FC, 0x0301, 0x01D8}, {0x00FC, 0x0304, 0x01D6},
|
||||
{0x00FC, 0x030C, 0x01DA}, {0x00FC, 0x0340, 0x01DC}, {0x00FC, 0x0341, 0x01D8}, {0x0102, 0x0300, 0x1EB0},
|
||||
{0x0102, 0x0301, 0x1EAE}, {0x0102, 0x0303, 0x1EB4}, {0x0102, 0x0309, 0x1EB2}, {0x0102, 0x0323, 0x1EB6},
|
||||
{0x0102, 0x0340, 0x1EB0}, {0x0102, 0x0341, 0x1EAE}, {0x0103, 0x0300, 0x1EB1}, {0x0103, 0x0301, 0x1EAF},
|
||||
{0x0103, 0x0303, 0x1EB5}, {0x0103, 0x0309, 0x1EB3}, {0x0103, 0x0323, 0x1EB7}, {0x0103, 0x0340, 0x1EB1},
|
||||
{0x0103, 0x0341, 0x1EAF}, {0x0106, 0x0327, 0x1E08}, {0x0107, 0x0327, 0x1E09}, {0x0112, 0x0300, 0x1E14},
|
||||
{0x0112, 0x0301, 0x1E16}, {0x0112, 0x0340, 0x1E14}, {0x0112, 0x0341, 0x1E16}, {0x0113, 0x0300, 0x1E15},
|
||||
{0x0113, 0x0301, 0x1E17}, {0x0113, 0x0340, 0x1E15}, {0x0113, 0x0341, 0x1E17}, {0x0114, 0x0327, 0x1E1C},
|
||||
{0x0115, 0x0327, 0x1E1D}, {0x014C, 0x0300, 0x1E50}, {0x014C, 0x0301, 0x1E52}, {0x014C, 0x0328, 0x01EC},
|
||||
{0x014C, 0x0340, 0x1E50}, {0x014C, 0x0341, 0x1E52}, {0x014D, 0x0300, 0x1E51}, {0x014D, 0x0301, 0x1E53},
|
||||
{0x014D, 0x0328, 0x01ED}, {0x014D, 0x0340, 0x1E51}, {0x014D, 0x0341, 0x1E53}, {0x015A, 0x0307, 0x1E64},
|
||||
{0x015B, 0x0307, 0x1E65}, {0x0160, 0x0307, 0x1E66}, {0x0161, 0x0307, 0x1E67}, {0x0168, 0x0301, 0x1E78},
|
||||
{0x0168, 0x031B, 0x1EEE}, {0x0168, 0x0341, 0x1E78}, {0x0169, 0x0301, 0x1E79}, {0x0169, 0x031B, 0x1EEF},
|
||||
{0x0169, 0x0341, 0x1E79}, {0x016A, 0x0308, 0x1E7A}, {0x016B, 0x0308, 0x1E7B}, {0x017F, 0x0307, 0x1E9B},
|
||||
{0x01A0, 0x0300, 0x1EDC}, {0x01A0, 0x0301, 0x1EDA}, {0x01A0, 0x0303, 0x1EE0}, {0x01A0, 0x0309, 0x1EDE},
|
||||
{0x01A0, 0x0323, 0x1EE2}, {0x01A0, 0x0340, 0x1EDC}, {0x01A0, 0x0341, 0x1EDA}, {0x01A1, 0x0300, 0x1EDD},
|
||||
{0x01A1, 0x0301, 0x1EDB}, {0x01A1, 0x0303, 0x1EE1}, {0x01A1, 0x0309, 0x1EDF}, {0x01A1, 0x0323, 0x1EE3},
|
||||
{0x01A1, 0x0340, 0x1EDD}, {0x01A1, 0x0341, 0x1EDB}, {0x01AF, 0x0300, 0x1EEA}, {0x01AF, 0x0301, 0x1EE8},
|
||||
{0x01AF, 0x0303, 0x1EEE}, {0x01AF, 0x0309, 0x1EEC}, {0x01AF, 0x0323, 0x1EF0}, {0x01AF, 0x0340, 0x1EEA},
|
||||
{0x01AF, 0x0341, 0x1EE8}, {0x01B0, 0x0300, 0x1EEB}, {0x01B0, 0x0301, 0x1EE9}, {0x01B0, 0x0303, 0x1EEF},
|
||||
{0x01B0, 0x0309, 0x1EED}, {0x01B0, 0x0323, 0x1EF1}, {0x01B0, 0x0340, 0x1EEB}, {0x01B0, 0x0341, 0x1EE9},
|
||||
{0x01B7, 0x030C, 0x01EE}, {0x01EA, 0x0304, 0x01EC}, {0x01EB, 0x0304, 0x01ED}, {0x0226, 0x0304, 0x01E0},
|
||||
{0x0227, 0x0304, 0x01E1}, {0x0228, 0x0306, 0x1E1C}, {0x0229, 0x0306, 0x1E1D}, {0x022E, 0x0304, 0x0230},
|
||||
{0x022F, 0x0304, 0x0231}, {0x0292, 0x030C, 0x01EF}, {0x0391, 0x0300, 0x1FBA}, {0x0391, 0x0301, 0x0386},
|
||||
{0x0391, 0x0304, 0x1FB9}, {0x0391, 0x0306, 0x1FB8}, {0x0391, 0x0313, 0x1F08}, {0x0391, 0x0314, 0x1F09},
|
||||
{0x0391, 0x0340, 0x1FBA}, {0x0391, 0x0341, 0x0386}, {0x0391, 0x0343, 0x1F08}, {0x0391, 0x0345, 0x1FBC},
|
||||
{0x0395, 0x0300, 0x1FC8}, {0x0395, 0x0301, 0x0388}, {0x0395, 0x0313, 0x1F18}, {0x0395, 0x0314, 0x1F19},
|
||||
{0x0395, 0x0340, 0x1FC8}, {0x0395, 0x0341, 0x0388}, {0x0395, 0x0343, 0x1F18}, {0x0397, 0x0300, 0x1FCA},
|
||||
{0x0397, 0x0301, 0x0389}, {0x0397, 0x0313, 0x1F28}, {0x0397, 0x0314, 0x1F29}, {0x0397, 0x0340, 0x1FCA},
|
||||
{0x0397, 0x0341, 0x0389}, {0x0397, 0x0343, 0x1F28}, {0x0397, 0x0345, 0x1FCC}, {0x0399, 0x0300, 0x1FDA},
|
||||
{0x0399, 0x0301, 0x038A}, {0x0399, 0x0304, 0x1FD9}, {0x0399, 0x0306, 0x1FD8}, {0x0399, 0x0308, 0x03AA},
|
||||
{0x0399, 0x0313, 0x1F38}, {0x0399, 0x0314, 0x1F39}, {0x0399, 0x0340, 0x1FDA}, {0x0399, 0x0341, 0x038A},
|
||||
{0x0399, 0x0343, 0x1F38}, {0x039F, 0x0300, 0x1FF8}, {0x039F, 0x0301, 0x038C}, {0x039F, 0x0313, 0x1F48},
|
||||
{0x039F, 0x0314, 0x1F49}, {0x039F, 0x0340, 0x1FF8}, {0x039F, 0x0341, 0x038C}, {0x039F, 0x0343, 0x1F48},
|
||||
{0x03A1, 0x0314, 0x1FEC}, {0x03A5, 0x0300, 0x1FEA}, {0x03A5, 0x0301, 0x038E}, {0x03A5, 0x0304, 0x1FE9},
|
||||
{0x03A5, 0x0306, 0x1FE8}, {0x03A5, 0x0308, 0x03AB}, {0x03A5, 0x0314, 0x1F59}, {0x03A5, 0x0340, 0x1FEA},
|
||||
{0x03A5, 0x0341, 0x038E}, {0x03A9, 0x0300, 0x1FFA}, {0x03A9, 0x0301, 0x038F}, {0x03A9, 0x0313, 0x1F68},
|
||||
{0x03A9, 0x0314, 0x1F69}, {0x03A9, 0x0340, 0x1FFA}, {0x03A9, 0x0341, 0x038F}, {0x03A9, 0x0343, 0x1F68},
|
||||
{0x03A9, 0x0345, 0x1FFC}, {0x03AC, 0x0345, 0x1FB4}, {0x03AE, 0x0345, 0x1FC4}, {0x03B1, 0x0300, 0x1F70},
|
||||
{0x03B1, 0x0301, 0x03AC}, {0x03B1, 0x0304, 0x1FB1}, {0x03B1, 0x0306, 0x1FB0}, {0x03B1, 0x0313, 0x1F00},
|
||||
{0x03B1, 0x0314, 0x1F01}, {0x03B1, 0x0340, 0x1F70}, {0x03B1, 0x0341, 0x03AC}, {0x03B1, 0x0342, 0x1FB6},
|
||||
{0x03B1, 0x0343, 0x1F00}, {0x03B1, 0x0345, 0x1FB3}, {0x03B5, 0x0300, 0x1F72}, {0x03B5, 0x0301, 0x03AD},
|
||||
{0x03B5, 0x0313, 0x1F10}, {0x03B5, 0x0314, 0x1F11}, {0x03B5, 0x0340, 0x1F72}, {0x03B5, 0x0341, 0x03AD},
|
||||
{0x03B5, 0x0343, 0x1F10}, {0x03B7, 0x0300, 0x1F74}, {0x03B7, 0x0301, 0x03AE}, {0x03B7, 0x0313, 0x1F20},
|
||||
{0x03B7, 0x0314, 0x1F21}, {0x03B7, 0x0340, 0x1F74}, {0x03B7, 0x0341, 0x03AE}, {0x03B7, 0x0342, 0x1FC6},
|
||||
{0x03B7, 0x0343, 0x1F20}, {0x03B7, 0x0345, 0x1FC3}, {0x03B9, 0x0300, 0x1F76}, {0x03B9, 0x0301, 0x03AF},
|
||||
{0x03B9, 0x0304, 0x1FD1}, {0x03B9, 0x0306, 0x1FD0}, {0x03B9, 0x0308, 0x03CA}, {0x03B9, 0x0313, 0x1F30},
|
||||
{0x03B9, 0x0314, 0x1F31}, {0x03B9, 0x0340, 0x1F76}, {0x03B9, 0x0341, 0x03AF}, {0x03B9, 0x0342, 0x1FD6},
|
||||
{0x03B9, 0x0343, 0x1F30}, {0x03B9, 0x0344, 0x0390}, {0x03BF, 0x0300, 0x1F78}, {0x03BF, 0x0301, 0x03CC},
|
||||
{0x03BF, 0x0313, 0x1F40}, {0x03BF, 0x0314, 0x1F41}, {0x03BF, 0x0340, 0x1F78}, {0x03BF, 0x0341, 0x03CC},
|
||||
{0x03BF, 0x0343, 0x1F40}, {0x03C1, 0x0313, 0x1FE4}, {0x03C1, 0x0314, 0x1FE5}, {0x03C1, 0x0343, 0x1FE4},
|
||||
{0x03C5, 0x0300, 0x1F7A}, {0x03C5, 0x0301, 0x03CD}, {0x03C5, 0x0304, 0x1FE1}, {0x03C5, 0x0306, 0x1FE0},
|
||||
{0x03C5, 0x0308, 0x03CB}, {0x03C5, 0x0313, 0x1F50}, {0x03C5, 0x0314, 0x1F51}, {0x03C5, 0x0340, 0x1F7A},
|
||||
{0x03C5, 0x0341, 0x03CD}, {0x03C5, 0x0342, 0x1FE6}, {0x03C5, 0x0343, 0x1F50}, {0x03C5, 0x0344, 0x03B0},
|
||||
{0x03C9, 0x0300, 0x1F7C}, {0x03C9, 0x0301, 0x03CE}, {0x03C9, 0x0313, 0x1F60}, {0x03C9, 0x0314, 0x1F61},
|
||||
{0x03C9, 0x0340, 0x1F7C}, {0x03C9, 0x0341, 0x03CE}, {0x03C9, 0x0342, 0x1FF6}, {0x03C9, 0x0343, 0x1F60},
|
||||
{0x03C9, 0x0345, 0x1FF3}, {0x03CA, 0x0300, 0x1FD2}, {0x03CA, 0x0301, 0x0390}, {0x03CA, 0x0340, 0x1FD2},
|
||||
{0x03CA, 0x0341, 0x0390}, {0x03CA, 0x0342, 0x1FD7}, {0x03CB, 0x0300, 0x1FE2}, {0x03CB, 0x0301, 0x03B0},
|
||||
{0x03CB, 0x0340, 0x1FE2}, {0x03CB, 0x0341, 0x03B0}, {0x03CB, 0x0342, 0x1FE7}, {0x03CE, 0x0345, 0x1FF4},
|
||||
{0x03D2, 0x0301, 0x03D3}, {0x03D2, 0x0308, 0x03D4}, {0x03D2, 0x0341, 0x03D3}, {0x0406, 0x0308, 0x0407},
|
||||
{0x0410, 0x0306, 0x04D0}, {0x0410, 0x0308, 0x04D2}, {0x0413, 0x0301, 0x0403}, {0x0413, 0x0341, 0x0403},
|
||||
{0x0415, 0x0300, 0x0400}, {0x0415, 0x0306, 0x04D6}, {0x0415, 0x0308, 0x0401}, {0x0415, 0x0340, 0x0400},
|
||||
{0x0416, 0x0306, 0x04C1}, {0x0416, 0x0308, 0x04DC}, {0x0417, 0x0308, 0x04DE}, {0x0418, 0x0300, 0x040D},
|
||||
{0x0418, 0x0304, 0x04E2}, {0x0418, 0x0306, 0x0419}, {0x0418, 0x0308, 0x04E4}, {0x0418, 0x0340, 0x040D},
|
||||
{0x041A, 0x0301, 0x040C}, {0x041A, 0x0341, 0x040C}, {0x041E, 0x0308, 0x04E6}, {0x0423, 0x0304, 0x04EE},
|
||||
{0x0423, 0x0306, 0x040E}, {0x0423, 0x0308, 0x04F0}, {0x0423, 0x030B, 0x04F2}, {0x0427, 0x0308, 0x04F4},
|
||||
{0x042B, 0x0308, 0x04F8}, {0x042D, 0x0308, 0x04EC}, {0x0430, 0x0306, 0x04D1}, {0x0430, 0x0308, 0x04D3},
|
||||
{0x0433, 0x0301, 0x0453}, {0x0433, 0x0341, 0x0453}, {0x0435, 0x0300, 0x0450}, {0x0435, 0x0306, 0x04D7},
|
||||
{0x0435, 0x0308, 0x0451}, {0x0435, 0x0340, 0x0450}, {0x0436, 0x0306, 0x04C2}, {0x0436, 0x0308, 0x04DD},
|
||||
{0x0437, 0x0308, 0x04DF}, {0x0438, 0x0300, 0x045D}, {0x0438, 0x0304, 0x04E3}, {0x0438, 0x0306, 0x0439},
|
||||
{0x0438, 0x0308, 0x04E5}, {0x0438, 0x0340, 0x045D}, {0x043A, 0x0301, 0x045C}, {0x043A, 0x0341, 0x045C},
|
||||
{0x043E, 0x0308, 0x04E7}, {0x0443, 0x0304, 0x04EF}, {0x0443, 0x0306, 0x045E}, {0x0443, 0x0308, 0x04F1},
|
||||
{0x0443, 0x030B, 0x04F3}, {0x0447, 0x0308, 0x04F5}, {0x044B, 0x0308, 0x04F9}, {0x044D, 0x0308, 0x04ED},
|
||||
{0x0456, 0x0308, 0x0457}, {0x0474, 0x030F, 0x0476}, {0x0475, 0x030F, 0x0477}, {0x04D8, 0x0308, 0x04DA},
|
||||
{0x04D9, 0x0308, 0x04DB}, {0x04E8, 0x0308, 0x04EA}, {0x04E9, 0x0308, 0x04EB}, {0x1E36, 0x0304, 0x1E38},
|
||||
{0x1E37, 0x0304, 0x1E39}, {0x1E5A, 0x0304, 0x1E5C}, {0x1E5B, 0x0304, 0x1E5D}, {0x1E60, 0x0323, 0x1E68},
|
||||
{0x1E61, 0x0323, 0x1E69}, {0x1E62, 0x0307, 0x1E68}, {0x1E63, 0x0307, 0x1E69}, {0x1EA0, 0x0302, 0x1EAC},
|
||||
{0x1EA0, 0x0306, 0x1EB6}, {0x1EA1, 0x0302, 0x1EAD}, {0x1EA1, 0x0306, 0x1EB7}, {0x1EB8, 0x0302, 0x1EC6},
|
||||
{0x1EB9, 0x0302, 0x1EC7}, {0x1ECC, 0x0302, 0x1ED8}, {0x1ECC, 0x031B, 0x1EE2}, {0x1ECD, 0x0302, 0x1ED9},
|
||||
{0x1ECD, 0x031B, 0x1EE3}, {0x1ECE, 0x031B, 0x1EDE}, {0x1ECF, 0x031B, 0x1EDF}, {0x1EE4, 0x031B, 0x1EF0},
|
||||
{0x1EE5, 0x031B, 0x1EF1}, {0x1EE6, 0x031B, 0x1EEC}, {0x1EE7, 0x031B, 0x1EED},
|
||||
};
|
||||
static constexpr int kUtf8ComposeTableSize = sizeof(kUtf8ComposeTable) / sizeof(kUtf8ComposeTable[0]);
|
||||
+15
-18
@@ -397,37 +397,34 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
|
||||
// Continue out of block with data set
|
||||
} else if (fileStat.method == ZIP_METHOD_DEFLATED) {
|
||||
// Read out deflated content from file
|
||||
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
|
||||
if (deflatedData == nullptr) {
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
|
||||
auto* fileReadBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||
if (!fileReadBuffer) {
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
|
||||
free(data);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const size_t dataRead = file.read(deflatedData, deflatedDataSize);
|
||||
ZipInflateCtx ctx;
|
||||
ctx.file = &file;
|
||||
ctx.fileRemaining = deflatedDataSize;
|
||||
ctx.readBuf = fileReadBuffer;
|
||||
ctx.readBufSize = 1024;
|
||||
|
||||
if (dataRead != deflatedDataSize) {
|
||||
LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
|
||||
free(deflatedData);
|
||||
if (!ctx.reader.init(true)) {
|
||||
LOG_ERR("ZIP", "Failed to init inflate reader");
|
||||
free(fileReadBuffer);
|
||||
free(data);
|
||||
return nullptr;
|
||||
}
|
||||
ctx.reader.setReadCallback(zipReadCallback);
|
||||
|
||||
bool success = false;
|
||||
{
|
||||
InflateReader r;
|
||||
r.init(false);
|
||||
r.setSource(deflatedData, deflatedDataSize);
|
||||
success = r.read(data, inflatedDataSize);
|
||||
}
|
||||
free(deflatedData);
|
||||
|
||||
if (!success) {
|
||||
if (!ctx.reader.read(data, inflatedDataSize)) {
|
||||
LOG_ERR("ZIP", "Failed to inflate file");
|
||||
free(fileReadBuffer);
|
||||
free(data);
|
||||
return nullptr;
|
||||
}
|
||||
free(fileReadBuffer);
|
||||
|
||||
// Continue out of block with data set
|
||||
} else {
|
||||
|
||||
@@ -81,6 +81,27 @@ void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* m
|
||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||
}
|
||||
|
||||
void HalDisplay::displayGrayscaleBase(RefreshMode fallback, bool turnOffScreen) {
|
||||
// X3: a HALF fallback means the caller wants a clean base (e.g. the sleep
|
||||
// cover, a full-screen swap from arbitrary prior content). Without this, the
|
||||
// X3 grayscale base takes its gentle differential happy path and the prior
|
||||
// home/reader frame ghosts through the soft aa_pre_bw_mid waveform. Forcing a
|
||||
// resync makes displayGrayscaleBase clear first, matching displayBuffer(HALF).
|
||||
// The reader's FAST path is deliberately left on the differential path so
|
||||
// per-page grayscale stays cheap.
|
||||
if (gpio.deviceIsX3() && fallback == RefreshMode::HALF_REFRESH) {
|
||||
einkDisplay.requestResync(1);
|
||||
}
|
||||
|
||||
einkDisplay.displayGrayscaleBase(convertRefreshMode(fallback), turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::preconditionGrayscale() { einkDisplay.preconditionGrayscale(); }
|
||||
|
||||
void HalDisplay::preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
|
||||
einkDisplay.preconditionGrayscale(x, y, w, h);
|
||||
}
|
||||
|
||||
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
|
||||
|
||||
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
||||
|
||||
@@ -47,6 +47,19 @@ class HalDisplay {
|
||||
// Access to frame buffer
|
||||
uint8_t* getFrameBuffer() const;
|
||||
|
||||
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
||||
// to the gray region in physical panel coordinates (no-arg = full frame).
|
||||
// Call after the BW base frame is displayed and before the grayscale planes
|
||||
// are written; no-op on X4. See EInkDisplay::preconditionGrayscale.
|
||||
void preconditionGrayscale();
|
||||
void preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
|
||||
|
||||
// Display the framebuffer as the base frame for a grayscale overlay that
|
||||
// follows. On X3, HALF fallback first requests a resync to match
|
||||
// displayBuffer(HALF); FAST fallback keeps the OEM differential base waveform
|
||||
// ("AA-pre-BW(mid)"). Other panels display normally with `fallback` mode.
|
||||
void displayGrayscaleBase(RefreshMode fallback = HALF_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
|
||||
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
||||
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
|
||||
|
||||
-1
Submodule open-x4-sdk deleted from 26648d643a
+11
-5
@@ -4,7 +4,7 @@ build_cache_dir = .cache
|
||||
extra_configs = platformio.local.ini
|
||||
|
||||
[crosspoint]
|
||||
version = 1.3.0
|
||||
version = 1.4.1
|
||||
|
||||
[base]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
||||
@@ -35,6 +35,8 @@ build_flags =
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-Wno-bidi-chars
|
||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||
-fno-exceptions
|
||||
@@ -57,10 +59,14 @@ extra_scripts =
|
||||
|
||||
; Libraries
|
||||
lib_deps =
|
||||
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
|
||||
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
|
||||
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
|
||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
||||
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
|
||||
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||
bblanchon/ArduinoJson @ 7.4.2
|
||||
ricmoo/QRCode @ 0.0.1
|
||||
bitbank2/PNGdec @ 1.1.6
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "I18nKeys.h"
|
||||
@@ -96,6 +97,7 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
|
||||
}
|
||||
|
||||
bool CrossPointSettings::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
||||
}
|
||||
@@ -106,7 +108,11 @@ bool CrossPointSettings::loadFromFile() {
|
||||
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
bool result;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
}
|
||||
if (result && resave) {
|
||||
if (saveToFile()) {
|
||||
LOG_DBG("CPS", "Resaved settings to update format");
|
||||
@@ -166,6 +172,7 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <iosfwd>
|
||||
#include <mutex>
|
||||
|
||||
class CrossPointSettings {
|
||||
private:
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Private constructor for singleton
|
||||
CrossPointSettings() = default;
|
||||
|
||||
@@ -17,6 +20,10 @@ class CrossPointSettings {
|
||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
||||
|
||||
// Access the settings mutex for protecting multi-field reads/writes from other cores.
|
||||
// Callers must not re-enter SETTINGS methods that lock _mutex while holding it.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
enum SLEEP_SCREEN_MODE {
|
||||
DARK = 0,
|
||||
LIGHT = 1,
|
||||
@@ -65,6 +72,8 @@ class CrossPointSettings {
|
||||
XTC_STATUS_BAR_MODE_COUNT
|
||||
};
|
||||
|
||||
enum STATUS_BAR_CLOCK_MODE { STATUS_BAR_CLOCK_HIDE = 0, STATUS_BAR_CLOCK_RIGHT = 1, STATUS_BAR_CLOCK_LEFT = 2 };
|
||||
|
||||
enum ORIENTATION {
|
||||
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
||||
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
||||
@@ -136,6 +145,17 @@ class CrossPointSettings {
|
||||
// Short power button press actions
|
||||
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, FOOTNOTES = 4, SHORT_PWRBTN_COUNT };
|
||||
|
||||
// Long-press Confirm action while reading an EPUB. The setting cycles through these values.
|
||||
// Persisted in settings.json by index: any new function (e.g. dictionary, bookmark) MUST use a
|
||||
// value >= 2 and be appended at the END of the enumValues array in SettingsList.h, otherwise the
|
||||
// stored indices shift and existing saves are silently misinterpreted.
|
||||
enum LONG_PRESS_MENU_FUNCTION {
|
||||
LP_MENU_KOSYNC = 0,
|
||||
LP_MENU_DISABLED = 1,
|
||||
LP_MENU_BOOKMARK = 2,
|
||||
LONG_PRESS_MENU_FUNCTION_COUNT
|
||||
};
|
||||
|
||||
// Hide battery percentage
|
||||
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
|
||||
|
||||
@@ -153,6 +173,13 @@ class CrossPointSettings {
|
||||
// Image rendering in EPUB reader
|
||||
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
||||
|
||||
// Text anti-aliasing mode for 2-bit fonts. FULL runs the grayscale LUT
|
||||
// passes after the BW page render (extra refresh per page turn). FAST
|
||||
// approximates AA inside the single BW pass by dithering partial-coverage
|
||||
// glyph pixels (GfxRenderer fast anti-aliasing flag). Value 1 must stay
|
||||
// FULL: it was the "on" value of the old on/off toggle in saved settings.
|
||||
enum TEXT_ANTIALIASING { TEXT_AA_OFF = 0, TEXT_AA_FULL = 1, TEXT_AA_FAST = 2, TEXT_ANTIALIASING_COUNT };
|
||||
|
||||
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
|
||||
|
||||
enum QUICK_RESUME_SLEEP_SCREEN {
|
||||
@@ -177,7 +204,7 @@ class CrossPointSettings {
|
||||
uint8_t statusBarBattery = 1;
|
||||
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
||||
// Clock display in status bar (X3 only, requires DS3231 RTC)
|
||||
uint8_t statusBarClock = 0;
|
||||
uint8_t statusBarClock = STATUS_BAR_CLOCK_HIDE;
|
||||
// Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t.
|
||||
// Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00.
|
||||
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
|
||||
@@ -189,7 +216,7 @@ class CrossPointSettings {
|
||||
uint8_t clockHasBeenSynced = 0;
|
||||
// Text rendering settings
|
||||
uint8_t extraParagraphSpacing = 1;
|
||||
uint8_t textAntiAliasing = 1;
|
||||
uint8_t textAntiAliasing = TEXT_AA_FULL;
|
||||
// Short power button click behaviour
|
||||
uint8_t shortPwrBtn = IGNORE;
|
||||
// EPUB reading orientation settings
|
||||
@@ -226,6 +253,9 @@ class CrossPointSettings {
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press page turn button behavior
|
||||
uint8_t longPressButtonBehavior = OFF;
|
||||
// Long-press Confirm function in EPUB reader (cycles through LONG_PRESS_MENU_FUNCTION values).
|
||||
// Defaults to Disabled so shortcut-based bookmark toggling remains opt-in.
|
||||
uint8_t longPressMenuFunction = LP_MENU_DISABLED;
|
||||
// UI Theme
|
||||
uint8_t uiTheme = LYRA;
|
||||
// Sunlight fading compensation
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||
@@ -32,6 +33,7 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
|
||||
}
|
||||
|
||||
bool CrossPointState::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
||||
}
|
||||
@@ -41,6 +43,7 @@ bool CrossPointState::loadFromFile() {
|
||||
if (Storage.exists(STATE_FILE_JSON)) {
|
||||
String json = Storage.readFile(STATE_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
return JsonSettingsIO::loadState(*this, json.c_str());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,7 @@ bool CrossPointState::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
class CrossPointState {
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Static instance
|
||||
static CrossPointState instance;
|
||||
|
||||
public:
|
||||
// Access the state mutex for protecting multi-field reads/writes from other cores.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
||||
|
||||
std::string openEpubPath;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <Logging.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
@@ -153,10 +154,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
@@ -345,6 +342,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
|
||||
|
||||
store.recentBooks.clear();
|
||||
JsonArray arr = doc["books"].as<JsonArray>();
|
||||
store.recentBooks.reserve(std::min(arr.size(), (size_t)10));
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.getCount() >= 10) break;
|
||||
RecentBook book;
|
||||
@@ -423,6 +421,9 @@ bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks,
|
||||
obj["xpath"] = bookmark.xpath;
|
||||
obj["percentage"] = bookmark.percentage;
|
||||
obj["summary"] = bookmark.summary;
|
||||
obj["si"] = bookmark.computedSpineIndex;
|
||||
obj["pc"] = bookmark.computedChapterPageCount;
|
||||
obj["pp"] = bookmark.computedChapterProgress;
|
||||
}
|
||||
|
||||
String json;
|
||||
@@ -447,6 +448,9 @@ bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const
|
||||
bookmark.xpath = obj["xpath"] | std::string("");
|
||||
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
|
||||
bookmark.summary = obj["summary"] | std::string("");
|
||||
bookmark.computedSpineIndex = obj["si"] | static_cast<uint16_t>(0);
|
||||
bookmark.computedChapterPageCount = obj["pc"] | static_cast<uint16_t>(0);
|
||||
bookmark.computedChapterProgress = obj["pp"] | static_cast<uint16_t>(0);
|
||||
}
|
||||
|
||||
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
#include "MappedInputManager.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
|
||||
// setting. The reader (and its modal menus) render rotated, so navigation/labels flip there; the
|
||||
// home and settings UI render in portrait, so they never flip even when a rotated reader is configured.
|
||||
const auto orientation = renderer.getOrientation();
|
||||
return SETTINGS.frontButtonFollowOrientation &&
|
||||
(orientation == GfxRenderer::PortraitInverted || orientation == GfxRenderer::LandscapeCounterClockwise);
|
||||
}
|
||||
|
||||
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
|
||||
const auto sideLayout = SETTINGS.sideButtonLayout;
|
||||
|
||||
@@ -49,6 +60,15 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case Button::NavNext:
|
||||
// Logical "next item" navigation: side Down + front Right, with the control axis flipped in
|
||||
// INVERTED / LANDSCAPE_CCW (frontButtonFollowOrientation) so it matches the rotated hint labels.
|
||||
return isNavDirectionSwapped() ? (mapButton(Button::Up, fn) || mapButton(Button::Left, fn))
|
||||
: (mapButton(Button::Down, fn) || mapButton(Button::Right, fn));
|
||||
case Button::NavPrevious:
|
||||
// Logical "previous item" navigation: side Up + front Left, axis-flipped in the same orientations.
|
||||
return isNavDirectionSwapped() ? (mapButton(Button::Down, fn) || mapButton(Button::Right, fn))
|
||||
: (mapButton(Button::Up, fn) || mapButton(Button::Left, fn));
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -69,9 +89,7 @@ unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
// Swap previous/next labels to match the page turn direction swap in INVERTED and LANDSCAPE_CCW.
|
||||
const bool swapLabels =
|
||||
SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED ||
|
||||
SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW);
|
||||
const bool swapLabels = isNavDirectionSwapped();
|
||||
const char* leftLabel = swapLabels ? next : previous;
|
||||
const char* rightLabel = swapLabels ? previous : next;
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
#include <HalGPIO.h>
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
class MappedInputManager {
|
||||
public:
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||
|
||||
struct Labels {
|
||||
const char* btn1;
|
||||
@@ -13,7 +15,7 @@ class MappedInputManager {
|
||||
const char* btn4;
|
||||
};
|
||||
|
||||
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
|
||||
MappedInputManager(HalGPIO& gpio, const GfxRenderer& renderer) : gpio(gpio), renderer(renderer) {}
|
||||
|
||||
void update() const { gpio.update(); }
|
||||
bool wasPressed(Button button) const;
|
||||
@@ -26,8 +28,20 @@ class MappedInputManager {
|
||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||
int getPressedFrontButton() const;
|
||||
|
||||
// True when the control axis is flipped relative to the physical buttons: the user opted into
|
||||
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
|
||||
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
|
||||
// so portrait UI (home, settings) never swaps while the reader and its menus do.
|
||||
[[nodiscard]] bool isNavDirectionSwapped() const;
|
||||
|
||||
private:
|
||||
HalGPIO& gpio;
|
||||
// Logical-to-physical button mapping depends on what the user is actually looking at: when the
|
||||
// screen is rendered rotated, the directional buttons must flip to match. The renderer is the only
|
||||
// authority on the *live* orientation (the reader rotates it and restores portrait on exit), so we
|
||||
// read it here instead of CrossPointSettings.orientation, which is just the persisted reader
|
||||
// preference and stays "rotated" even while portrait UI like home/settings is on screen.
|
||||
const GfxRenderer& renderer;
|
||||
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
};
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
namespace {
|
||||
|
||||
static uint8_t fontSizeEnumFromSettings() {
|
||||
uint8_t e = SETTINGS.fontSize;
|
||||
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
|
||||
return e;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
||||
registry_.discover();
|
||||
|
||||
@@ -30,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +78,11 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
||||
manager_.unloadAll(renderer);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
return;
|
||||
}
|
||||
auto sizes = family->availableSizes();
|
||||
uint8_t idx = sizeEnum;
|
||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
||||
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
|
||||
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||
const uint8_t wantedPt = selected ? selected->pointSize : 0;
|
||||
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
|
||||
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
|
||||
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
|
||||
@@ -94,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -104,8 +104,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
|
||||
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
||||
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
||||
@@ -151,12 +151,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
SettingInfo::Enum(
|
||||
StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_ORIENTATION_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
"orientation", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
|
||||
"extraParagraphSpacing", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||
// Option order must match TEXT_ANTIALIASING values (popup index is
|
||||
// stored directly): OFF=0, FULL=1 (legacy toggle "on"), FAST=2.
|
||||
SettingInfo::Enum(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_TEXT_AA_FULL, StrId::STR_TEXT_AA_FAST}, "textAntiAliasing",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
||||
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
||||
@@ -171,12 +175,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
{StrId::STR_LONG_PRESS_BEHAVIOR_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP,
|
||||
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
|
||||
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU, &CrossPointSettings::longPressMenuFunction,
|
||||
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION}, "longPressMenuFunction",
|
||||
StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(
|
||||
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
||||
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
|
||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
||||
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
||||
|
||||
// --- System ---
|
||||
SettingInfo::Value(
|
||||
StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes,
|
||||
@@ -240,7 +248,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
|
||||
// Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48.
|
||||
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock",
|
||||
SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock,
|
||||
{StrId::STR_HIDE, StrId::STR_DIR_LEFT, StrId::STR_DIR_RIGHT}, "statusBarClock",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
|
||||
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
// Initialize the static instance
|
||||
WifiCredentialStore WifiCredentialStore::instance;
|
||||
|
||||
@@ -89,6 +91,7 @@ bool WifiCredentialStore::loadFromBinaryFile() {
|
||||
serialization::readPod(file, count);
|
||||
|
||||
credentials.clear();
|
||||
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
|
||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
||||
WifiCredential cred;
|
||||
serialization::readString(file, cred.ssid);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ActivityManager.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <HalPowerManager.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -18,12 +19,15 @@
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle // Task handle
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -45,10 +49,10 @@ void ActivityManager::renderTaskLoop() {
|
||||
}
|
||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||
TaskHandle_t waiter = nullptr;
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
waiter = waitingTaskHandle;
|
||||
waitingTaskHandle = nullptr;
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
if (waiter) {
|
||||
xTaskNotify(waiter, 1, eIncrement);
|
||||
}
|
||||
@@ -136,8 +140,7 @@ void ActivityManager::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedUpdate) {
|
||||
requestedUpdate = false;
|
||||
if (requestedUpdate.exchange(false)) {
|
||||
// Using direct notification to signal the render task to update
|
||||
// Increment counter so multiple rapid calls won't be lost
|
||||
if (renderTaskHandle) {
|
||||
@@ -279,7 +282,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
}
|
||||
|
||||
// Atomic section to perform checks
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||
@@ -288,7 +291,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||
waitingTaskHandle = currTaskHandler;
|
||||
}
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
|
||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -63,7 +64,7 @@ class ActivityManager {
|
||||
|
||||
// Whether to trigger a render after the current loop()
|
||||
// This variable must only be set by the main loop, to avoid race conditions
|
||||
bool requestedUpdate = false;
|
||||
std::atomic<bool> requestedUpdate{false};
|
||||
|
||||
public:
|
||||
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
@@ -43,6 +43,10 @@ struct PageResult {
|
||||
struct ProgressChangeResult {
|
||||
int spineIndex = 0;
|
||||
int page = 0;
|
||||
int totalPages = 0;
|
||||
std::string xpath;
|
||||
float percentage = 0.0f;
|
||||
bool hasSavedProgress = false;
|
||||
};
|
||||
|
||||
enum class NetworkMode;
|
||||
|
||||
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
@@ -218,7 +218,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
if (hasGreyscale) {
|
||||
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
|
||||
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
|
||||
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
bitmap.rewindToData();
|
||||
@@ -325,5 +331,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
|
||||
|
||||
void SleepActivity::renderBlankSleepScreen() const {
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
std::string getFileExtension(const std::string& filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "EndOfBookOptions.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
#include "util/NextBookFinder.h"
|
||||
|
||||
namespace {
|
||||
// Display name without the file extension, mirroring the file browser rows
|
||||
std::string displayName(const std::string& filename) {
|
||||
const auto pos = filename.rfind('.');
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
|
||||
if (isLoaded.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
folder = FsHelpers::extractFolderPath(currentBookPath);
|
||||
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
|
||||
selector = 0;
|
||||
// Release-publish so the main task, which gates all access on isLoaded, never
|
||||
// observes a partially built list
|
||||
isLoaded.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
|
||||
|
||||
std::string EndOfBookOptions::fullPath(const size_t index) const {
|
||||
if (index >= names.size()) {
|
||||
return {};
|
||||
}
|
||||
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
|
||||
}
|
||||
|
||||
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
|
||||
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selector < static_cast<int>(names.size())) {
|
||||
if (openPath) {
|
||||
*openPath = fullPath(selector);
|
||||
}
|
||||
return Action::OpenBook;
|
||||
}
|
||||
return Action::GoHome; // "Home" entry selected
|
||||
}
|
||||
|
||||
// Short-press Back returns to the last page; a long press falls through to the
|
||||
// reader's own handler (file browser). Home is reached through the list's Home entry.
|
||||
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
return Action::LastPage;
|
||||
}
|
||||
|
||||
// Selection movement on the standard list navigation buttons (side Up/Down plus front
|
||||
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
|
||||
// (press-triggered by default, release-triggered when a long-press behavior is
|
||||
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
|
||||
// press-triggered turns, the press that turned the final page already fired in the
|
||||
// reader, and its release must not double-fire into this menu.
|
||||
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
|
||||
const auto triggered = [&](const MappedInputManager::Button button) {
|
||||
return usePress ? input.wasPressed(button) : input.wasReleased(button);
|
||||
};
|
||||
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
|
||||
if (triggered(MappedInputManager::Button::NavPrevious)) {
|
||||
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
|
||||
return Action::Redraw;
|
||||
}
|
||||
if (triggered(MappedInputManager::Button::NavNext)) {
|
||||
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
|
||||
return Action::Redraw;
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
if (!menuActive()) {
|
||||
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
|
||||
// the previous fixed position on the 480x800 panel and scales to other resolutions.
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
|
||||
EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
|
||||
// the physical front buttons, which is a logical side/top edge in the rotated
|
||||
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
|
||||
// positions derive from the safe-area height and font line heights so other panel
|
||||
// resolutions scale (review request on #2532).
|
||||
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int titleY = safe.y + safe.height / 8;
|
||||
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
|
||||
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
|
||||
|
||||
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
|
||||
|
||||
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
|
||||
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
|
||||
[this](const int index) {
|
||||
return index < static_cast<int>(names.size()) ? displayName(names[index])
|
||||
: std::string(tr(STR_EOB_HOME));
|
||||
});
|
||||
|
||||
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
class MappedInputManager;
|
||||
|
||||
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
|
||||
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
|
||||
// draws the end screen. With no suggestions the end screen keeps its historical
|
||||
// plain-title look and behavior.
|
||||
class EndOfBookOptions {
|
||||
public:
|
||||
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
|
||||
|
||||
static constexpr size_t MAX_SUGGESTIONS = 3;
|
||||
|
||||
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
|
||||
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
|
||||
// is the release/acquire publication point that lets the main task read the finished
|
||||
// list safely.
|
||||
void loadOnce(const std::string& currentBookPath);
|
||||
|
||||
// True when the suggestion menu is showing and should own the reader's input.
|
||||
bool menuActive() const;
|
||||
|
||||
// Menu input handling, following the standard list idiom: side Up/Down and front
|
||||
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
|
||||
// Back press returns to the last page of the book. Fills openPath when the result is
|
||||
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
|
||||
// their normal input path (keeping long-press Back to the file browser working).
|
||||
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
|
||||
|
||||
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
|
||||
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
|
||||
|
||||
private:
|
||||
std::string folder;
|
||||
// Written by the render task in loadOnce(), immutable afterwards; the main task only
|
||||
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
|
||||
std::vector<std::string> names;
|
||||
int selector = 0;
|
||||
std::atomic<bool> isLoaded{false};
|
||||
|
||||
std::string fullPath(size_t index) const;
|
||||
};
|
||||
@@ -41,6 +41,8 @@ namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
// pages per minute, first item is 1 to prevent division by zero if accessed
|
||||
constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
|
||||
constexpr size_t initialBookmarkCacheCapacity = 16;
|
||||
constexpr float bookmarkProgressEpsilon = 0.0001f;
|
||||
|
||||
int clampPercent(int percent) {
|
||||
if (percent < 0) {
|
||||
@@ -63,6 +65,36 @@ bool isInReadFolder(const std::string& path) {
|
||||
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
|
||||
}
|
||||
|
||||
struct ProgressRange {
|
||||
float start;
|
||||
float end;
|
||||
};
|
||||
|
||||
ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int spineIndex, const int page,
|
||||
const int pageCount) {
|
||||
if (pageCount <= 1) {
|
||||
return {epub->calculateProgress(spineIndex, 0.0f), epub->calculateProgress(spineIndex, 1.0f)};
|
||||
}
|
||||
|
||||
const float step = 1.0f / static_cast<float>(pageCount - 1);
|
||||
const float anchor = std::clamp(static_cast<float>(page) * step, 0.0f, 1.0f);
|
||||
const float start = std::max(0.0f, anchor - (step * 0.5f));
|
||||
const float end = std::min(1.0f, anchor + (step * 0.5f));
|
||||
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
|
||||
}
|
||||
|
||||
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount,
|
||||
const ProgressRange& pageRange) {
|
||||
if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount &&
|
||||
bookmark.computedChapterProgress == page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const float bookmarkProgress = std::clamp(bookmark.percentage, 0.0f, 1.0f);
|
||||
return bookmarkProgress + bookmarkProgressEpsilon >= pageRange.start &&
|
||||
bookmarkProgress - bookmarkProgressEpsilon <= pageRange.end;
|
||||
}
|
||||
|
||||
// Pick a non-colliding destination path inside /Read/ for a finished book.
|
||||
// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc.
|
||||
std::string buildReadFolderDestination(const std::string& srcPath) {
|
||||
@@ -165,6 +197,8 @@ void EpubReaderActivity::onEnter() {
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
|
||||
|
||||
loadCachedBookmarks();
|
||||
|
||||
// Trigger first update
|
||||
requestUpdate();
|
||||
}
|
||||
@@ -177,6 +211,14 @@ void EpubReaderActivity::onExit() {
|
||||
|
||||
APP_STATE.readerActivityLoadCount = 0;
|
||||
APP_STATE.saveToFile();
|
||||
|
||||
// Leaving mid-footnote loses the in-RAM return stack on deep sleep; persist the
|
||||
// pre-footnote position so the book reopens at the link origin, not the footnote.
|
||||
if (footnoteDepth > 0 && epub) {
|
||||
const SavedPosition& origin = savedPositions[0];
|
||||
saveProgress(origin.spineIndex, origin.pageNumber, 0);
|
||||
}
|
||||
|
||||
section.reset();
|
||||
if (pendingReadFolderMove && epub) {
|
||||
const std::string srcPath = epub->getPath();
|
||||
@@ -189,6 +231,30 @@ void EpubReaderActivity::onExit() {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openReaderMenu() {
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : 0;
|
||||
float bookProgress = 0.0f;
|
||||
if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) {
|
||||
const float chapterProgress =
|
||||
static_cast<float>(section->currentPage) / static_cast<float>(section->estimatedTotalPages());
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loop() {
|
||||
if (!epub) {
|
||||
// Should never happen
|
||||
@@ -196,6 +262,33 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive any in-progress incremental section build forward, off the page-turn critical path,
|
||||
// but only within a small window ahead of the reader: an unbounded build monopolized the
|
||||
// RenderLock and locked out page turns. The build follows the reader instead, and instant
|
||||
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
|
||||
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
||||
// isBuilding() under the lock since render() may have just finished it.
|
||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
|
||||
RenderLock lock;
|
||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
||||
// mutation, so it flags this as always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding()) {
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
requestUpdate();
|
||||
} else if (section->isBuildComplete() && applyDeferredReposition()) {
|
||||
// The chapter re-paginated since the saved progress (settings changed): we now know the
|
||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
||||
// finished. Two independent finished-book features key off this same condition.
|
||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||
@@ -256,43 +349,73 @@ void EpubReaderActivity::loop() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// Enter reader menu activity.
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
||||
// block. A Confirm release after a long-press function (bookmark/sync) fired is left
|
||||
// to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease.
|
||||
if (atEndOfBook && endOfBookOptions.menuActive() &&
|
||||
!(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) {
|
||||
std::string openPath;
|
||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
||||
case EndOfBookOptions::Action::OpenBook:
|
||||
activityManager.goToReader(openPath);
|
||||
return;
|
||||
case EndOfBookOptions::Action::GoHome:
|
||||
onGoHome();
|
||||
return;
|
||||
case EndOfBookOptions::Action::LastPage:
|
||||
currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0);
|
||||
nextPageNumber = 0;
|
||||
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::Redraw:
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
|
||||
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
||||
// following the hold does not also open the menu.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->pageCount : 0;
|
||||
float bookProgress = 0.0f;
|
||||
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty()),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
openReaderMenu();
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
|
||||
if (!showBookmarkMessage) {
|
||||
// Long-press Confirm runs the user-selected function (SETTINGS.longPressMenuFunction).
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||
switch (SETTINGS.longPressMenuFunction) {
|
||||
case CrossPointSettings::LP_MENU_BOOKMARK:
|
||||
// Hold ~0.4s drops a bookmark at the current page.
|
||||
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showBookmarkMessage) {
|
||||
addBookmark();
|
||||
showBookmarkMessage = true;
|
||||
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
|
||||
bookmarkMessageTime = millis();
|
||||
requestUpdate();
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_KOSYNC:
|
||||
// Hold ~1s launches KOReader sync. If sync can't run (no credentials stored), fall
|
||||
// through so the normal Confirm-release still opens the reader menu.
|
||||
if (mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
if (launchKOReaderSync()) {
|
||||
ignoreNextConfirmRelease = true; // sync launched or error shown; suppress menu open
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_DISABLED:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
@@ -343,8 +466,14 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// At end of the book, forward button goes home and back button returns to last page
|
||||
// At end of the book with no suggestion menu, forward button goes home and back
|
||||
// button returns to last page
|
||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
if (endOfBookOptions.menuActive()) {
|
||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
||||
return;
|
||||
}
|
||||
if (nextTriggered) {
|
||||
onGoHome();
|
||||
} else {
|
||||
@@ -472,13 +601,35 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
auto progressChangeResultHandler = [this](const ActivityResult& result) {
|
||||
loadCachedBookmarks();
|
||||
if (!result.isCancelled) {
|
||||
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||
int targetSpineIndex = sync.spineIndex;
|
||||
int targetPage = sync.page;
|
||||
const int activeTotalPages = section ? section->estimatedTotalPages() : 0;
|
||||
const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 &&
|
||||
currentSpineIndex == sync.spineIndex && sync.page >= 0 &&
|
||||
sync.page < sync.totalPages && activeTotalPages == sync.totalPages;
|
||||
|
||||
if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) {
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
CrossPointPosition fallback =
|
||||
ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages);
|
||||
targetSpineIndex = fallback.spineIndex;
|
||||
targetPage = fallback.pageNumber;
|
||||
}
|
||||
|
||||
if (currentSpineIndex != targetSpineIndex) {
|
||||
RenderLock lock(*this);
|
||||
currentSpineIndex = sync.spineIndex;
|
||||
nextPageNumber = sync.page;
|
||||
currentSpineIndex = targetSpineIndex;
|
||||
nextPageNumber = targetPage;
|
||||
section.reset();
|
||||
} else if (section && section->currentPage != targetPage) {
|
||||
RenderLock lock(*this);
|
||||
const int clampedTargetPage = std::max(0, targetPage);
|
||||
section->currentPage = clampedTargetPage;
|
||||
} else if (!section) {
|
||||
nextPageNumber = targetPage;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -578,9 +729,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
||||
if (KOREADER_STORE.hasCredentials()) {
|
||||
launchKOReaderSync();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
|
||||
startActivityForResult(
|
||||
std::make_unique<EpubReaderBookmarksActivity>(renderer, mappedInput, epub, epub->getPath()),
|
||||
progressChangeResultHandler);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::TOGGLE_BOOKMARK: {
|
||||
addBookmark();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::launchKOReaderSync() {
|
||||
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
|
||||
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
@@ -603,7 +772,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
LOG_ERR("KOSync", "Aborting sync because current progress could not be saved");
|
||||
pendingSyncSaveError = true;
|
||||
requestUpdate();
|
||||
return;
|
||||
return true; // acted: surfaced a save error to the user
|
||||
}
|
||||
|
||||
// Release Epub and Section to free ~65KB RAM for the TLS handshake.
|
||||
@@ -621,16 +790,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
activityManager.replaceActivity(std::make_unique<KOReaderSyncActivity>(
|
||||
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
|
||||
std::move(localChapterName), paragraphIndex));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
|
||||
startActivityForResult(
|
||||
std::make_unique<EpubReaderBookmarksActivity>(renderer, mappedInput, epub, epub->getPath()),
|
||||
progressChangeResultHandler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true; // acted: launched the sync activity
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
||||
@@ -687,7 +847,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
|
||||
|
||||
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
if (isForwardTurn) {
|
||||
if (section->currentPage < section->pageCount - 1) {
|
||||
// Advance within the section while there are (or may still be) more pages: either a built
|
||||
// page ahead, or the section is still building (windowed), in which case more pages exist
|
||||
// beyond the current watermark and render()'s ensure-built pump will lay them out. Only when
|
||||
// the section is fully built AND we're on its last page do we move to the next spine -- using
|
||||
// the live pageCount alone would mistake the build watermark for the end of a giant spine.
|
||||
if (section->currentPage < section->pageCount - 1 || section->isBuilding()) {
|
||||
section->currentPage++;
|
||||
} else {
|
||||
// We don't want to delete the section mid-render, so grab the semaphore
|
||||
@@ -739,8 +904,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
|
||||
// Show end of book screen
|
||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||
// Sole load site: runs on the render task (serialized by RenderLock); the main
|
||||
// task only reads the suggestions once the loaded flag is published
|
||||
endOfBookOptions.loadOnce(epub->getPath());
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
endOfBookOptions.render(renderer, mappedInput);
|
||||
renderer.displayBuffer();
|
||||
automaticPageTurnActive = false;
|
||||
showPendingSyncSaveError();
|
||||
@@ -775,16 +943,48 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
// A finalized cache serves every page as-is. A partial cache (suspended build from a
|
||||
// previous session) serves its pages instantly too, but a build must still run to lay
|
||||
// out the rest -- it re-parses from the top in the background (HTML already cached,
|
||||
// pages are deterministic) and finalizes, so the partial machinery retires itself.
|
||||
const bool cacheLoaded = section->loadSectionFile(
|
||||
SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||
if (cacheLoaded) {
|
||||
// Matching render params means identical pagination, so the saved page number is valid
|
||||
// as-is: consume any pending settings-change reposition. Without this, a chapter total
|
||||
// saved while the section was still building (i.e. a watermark, not the real count)
|
||||
// would remap the resume page against the finalized count and teleport the reader.
|
||||
cachedChapterTotalPageCount = 0;
|
||||
}
|
||||
const bool cacheComplete = cacheLoaded && !section->isPartial();
|
||||
if (!cacheComplete) {
|
||||
if (section->isPartial()) {
|
||||
LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount);
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
}
|
||||
|
||||
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
||||
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
||||
// resolve their landing page until the whole chapter is laid out, so they take the full
|
||||
// (blocking) build with the indexing popup. Everything else -- plain forward reads, resume,
|
||||
// and explicit page jumps -- only needs a specific page, so it builds incrementally to that
|
||||
// page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*)
|
||||
// is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page
|
||||
// count is known, so it never blocks the first page.
|
||||
// Only a percent jump truly needs the whole chapter up front (percent -> page needs the final
|
||||
// page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below --
|
||||
// the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0
|
||||
// without indexing the whole chapter.
|
||||
const bool needsFullBuild = pendingPercentJump;
|
||||
if (needsFullBuild) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
|
||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
@@ -794,29 +994,79 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
|
||||
// indexing popup up front only when the build will actually be slow: a large spine (its
|
||||
// whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or
|
||||
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
|
||||
// build in a blink and stay popup-free.
|
||||
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
|
||||
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
|
||||
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
|
||||
// Popup only when the build will actually be slow: a big spine whose HTML still needs
|
||||
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
|
||||
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
|
||||
// A partial cache that already covers the target page shows it instantly: never popup.
|
||||
const bool willInflate = !section->hasHtmlCache();
|
||||
const bool anchorJump = !pendingAnchor.empty();
|
||||
bool showPopup;
|
||||
if (anchorJump) {
|
||||
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
|
||||
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
|
||||
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
|
||||
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
|
||||
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
|
||||
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
|
||||
} else {
|
||||
const bool targetAvailable = target < static_cast<int>(section->pageCount);
|
||||
showPopup = !targetAvailable &&
|
||||
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
|
||||
}
|
||||
if (showPopup) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed to start section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
while (!section->isBuildComplete() &&
|
||||
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
|
||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache found, skipping build...");
|
||||
}
|
||||
|
||||
if (pendingPageJump.has_value()) {
|
||||
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
} else {
|
||||
section->currentPage = *pendingPageJump;
|
||||
}
|
||||
pendingPageJump.reset();
|
||||
} else {
|
||||
section->currentPage = nextPageNumber;
|
||||
if (section->currentPage < 0) {
|
||||
section->currentPage = 0;
|
||||
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
|
||||
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingAnchor.empty()) {
|
||||
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
|
||||
// Resolve from the pages laid out so far and/or the on-disk map (finalized or partial).
|
||||
const auto page = section->findAnchor(pendingAnchor);
|
||||
if (page) {
|
||||
section->currentPage = *page;
|
||||
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
|
||||
} else {
|
||||
@@ -825,17 +1075,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
pendingAnchor.clear();
|
||||
}
|
||||
|
||||
// handles changes in reader settings and reset to approximate position based on cached progress
|
||||
if (cachedChapterTotalPageCount > 0) {
|
||||
// only goes to relative position if spine index matches cached value
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * section->pageCount);
|
||||
section->currentPage = newPage;
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
|
||||
}
|
||||
|
||||
if (pendingPercentJump && section->pageCount > 0) {
|
||||
// Apply the pending percent jump now that we know the new section's page count.
|
||||
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
|
||||
@@ -847,6 +1086,57 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extend the build to the requested page if needed (for partials and in-progress builds).
|
||||
// This runs every render, so it covers both the first page and any forward turn that gets
|
||||
// ahead of the background builder; pages already built do no work here.
|
||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
// Start a build to extend a partial toward the requested page.
|
||||
if (!section->isBuilding() &&
|
||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||
SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering,
|
||||
SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed to start partial extension build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
// Extend until either the target page exists or the build completes.
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
||||
if (section->isBuilding()) {
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The requested page is now as built as it will get. If it still lands past the end,
|
||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
||||
// Guarded on !isBuilding() because a still-building section's pageCount is only the current
|
||||
// watermark (not the final count) and has already been driven far enough by the loops above.
|
||||
if (!section->isBuilding() && section->pageCount > 0 &&
|
||||
section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
|
||||
// Apply a deferred settings-change reposition now that the real page count is known (a no-op for
|
||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||
applyDeferredReposition();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
if (section->pageCount == 0) {
|
||||
@@ -869,10 +1159,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBookmarkFlag();
|
||||
|
||||
{
|
||||
auto p = section->loadPageFromSectionFile();
|
||||
// Unified page read: the in-progress build's in-RAM table if it has reached the page,
|
||||
// otherwise the on-disk file (finalized section, or a partial from a previous session).
|
||||
auto p = section->loadPage(section->currentPage);
|
||||
if (!p) {
|
||||
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
||||
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
|
||||
// and the destructor's suspend would otherwise commit tables into a deleted handle.
|
||||
section->abandonBuild();
|
||||
section->clearCache();
|
||||
section.reset();
|
||||
requestUpdate(); // Try again after clearing cache
|
||||
@@ -889,8 +1186,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
}
|
||||
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
||||
|
||||
showPendingSyncSaveError();
|
||||
|
||||
@@ -900,40 +1196,32 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
|
||||
if (showBookmarkMessage) {
|
||||
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
|
||||
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
if (!epub || !section || section->pageCount < 2) {
|
||||
return;
|
||||
bool EpubReaderActivity::applyDeferredReposition() {
|
||||
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the next chapter cache while the penultimate page is on screen.
|
||||
if (section->currentPage != section->pageCount - 2) {
|
||||
return;
|
||||
bool changed = false;
|
||||
// Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain
|
||||
// resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and
|
||||
// nothing moves.
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
const float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * static_cast<float>(section->pageCount));
|
||||
if (newPage < 0) newPage = 0;
|
||||
if (section->pageCount > 0 && newPage >= static_cast<int>(section->pageCount)) {
|
||||
newPage = section->pageCount - 1;
|
||||
}
|
||||
|
||||
const int nextSpineIndex = currentSpineIndex + 1;
|
||||
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
|
||||
return;
|
||||
if (newPage != section->currentPage) {
|
||||
section->currentPage = newPage;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
Section nextSection(epub, nextSpineIndex, renderer);
|
||||
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
|
||||
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||
@@ -943,22 +1231,36 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
const auto t0 = millis();
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); // scan pass
|
||||
scope.endScanAndPrewarm();
|
||||
const auto tPrewarm = millis();
|
||||
|
||||
// Force special handling for pages with images when anti-aliasing is on
|
||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
const bool pageHasImages = page->hasImages();
|
||||
const bool needsTextGrayscale = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FULL;
|
||||
const bool fastTextAA = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST;
|
||||
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
|
||||
auto renderGrayscalePass = [&]() {
|
||||
if (needsTextGrayscale) {
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
} else {
|
||||
page->renderImages(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
}
|
||||
};
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
// Fast AA dithers 2-bit glyph edges in the BW pass itself, no grayscale
|
||||
// refresh. Scoped to the page render so nothing else inherits the flag.
|
||||
renderer.setFastAntiAliasing(fastTextAA);
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
renderer.setFastAntiAliasing(false);
|
||||
renderStatusBar();
|
||||
const auto tBwRender = millis();
|
||||
|
||||
if (imagePageWithAA) {
|
||||
if (pageHasImages) {
|
||||
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
||||
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
||||
// Instead, blank only the image area and do two fast refreshes.
|
||||
@@ -971,7 +1273,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
|
||||
// Re-render page content to restore images into the blanked area
|
||||
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.setFastAntiAliasing(fastTextAA);
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
renderer.setFastAntiAliasing(false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
@@ -995,7 +1299,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
|
||||
// cost stays close to one render. Both text (drawPixel) and images
|
||||
// (DirectPixelWriter) honor the active strip target.
|
||||
if (SETTINGS.textAntiAliasing && renderer.supportsStripGrayscale()) {
|
||||
if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
|
||||
constexpr int STRIP_ROWS = 80;
|
||||
const int gh = renderer.getDisplayHeight();
|
||||
const int gwBytes = renderer.getDisplayWidthBytes();
|
||||
@@ -1011,7 +1315,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||
}
|
||||
@@ -1023,7 +1327,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||
}
|
||||
@@ -1048,22 +1352,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
} else {
|
||||
// Fallback path for a controller without strip support. grayscale rendering
|
||||
// TODO: Only do this if font supports it
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
if (needsAnyGrayscale) {
|
||||
// Save the BW frame before the grayscale passes overwrite it, restore
|
||||
// after. Only needed when grayscale actually renders.
|
||||
renderer.storeBwBuffer();
|
||||
if (!renderer.storeBwBuffer()) {
|
||||
LOG_ERR("ERS", "Failed to store BW buffer for grayscale render; skipping grayscale this page");
|
||||
const auto tEnd = millis();
|
||||
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
|
||||
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
|
||||
return;
|
||||
}
|
||||
const auto tBwStore = millis();
|
||||
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderGrayscalePass();
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
const auto tGrayLsb = millis();
|
||||
|
||||
// Render and copy to MSB buffer
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderGrayscalePass();
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
const auto tGrayMsb = millis();
|
||||
|
||||
@@ -1081,8 +1391,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
|
||||
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
|
||||
} else {
|
||||
// No anti-aliasing: BW frame already displayed above, no grayscale to
|
||||
// render, so no save/restore.
|
||||
// No text AA and no images: BW frame already displayed above, no grayscale
|
||||
// to render, so no save/restore.
|
||||
const auto tEnd = millis();
|
||||
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
|
||||
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
|
||||
@@ -1091,9 +1401,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
void EpubReaderActivity::renderStatusBar() const {
|
||||
// Calculate progress in book
|
||||
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
||||
// "page X of Y" and the progress bar don't read off the small build watermark.
|
||||
const int currentPage = section->currentPage + 1;
|
||||
const float pageCount = section->pageCount;
|
||||
const float pageCount = section->estimatedTotalPages();
|
||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||
|
||||
@@ -1124,7 +1435,8 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
title = epub->getTitle();
|
||||
}
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset);
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||
section->isBuilding());
|
||||
}
|
||||
|
||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||
@@ -1186,58 +1498,91 @@ void EpubReaderActivity::restoreSavedPosition() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loadCachedBookmarks() {
|
||||
cachedBookmarks.clear();
|
||||
if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) {
|
||||
cachedBookmarks.reserve(initialBookmarkCacheCapacity);
|
||||
}
|
||||
if (!epub) {
|
||||
currentPageBookmarked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string bmPath = BookmarkUtil::getBookmarkPath(epub->getPath());
|
||||
if (Storage.exists(bmPath.c_str())) {
|
||||
String json = Storage.readFile(bmPath.c_str());
|
||||
if (!json.isEmpty()) {
|
||||
JsonSettingsIO::loadBookmarks(cachedBookmarks, json.c_str());
|
||||
}
|
||||
}
|
||||
updateBookmarkFlag();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::addBookmark() {
|
||||
if (!section || !epub) {
|
||||
return;
|
||||
}
|
||||
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
|
||||
LOG_DBG("ERS", "Toggle bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
|
||||
int currentPage;
|
||||
int pageCount;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
pageCount = section->pageCount;
|
||||
pageCount = section->estimatedTotalPages();
|
||||
currentPage = section->currentPage;
|
||||
}
|
||||
|
||||
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
|
||||
|
||||
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
|
||||
cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
|
||||
[&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount,
|
||||
pageRange);
|
||||
}),
|
||||
cachedBookmarks.end());
|
||||
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
|
||||
bookmarkRemoved = true;
|
||||
currentPageBookmarked = false;
|
||||
} else {
|
||||
std::string pageText;
|
||||
if (currentPage >= 0 && currentPage < pageCount) {
|
||||
pageText = section->getTextFromSectionFile();
|
||||
}
|
||||
|
||||
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||
|
||||
BookmarkEntry entry;
|
||||
entry.percentage = progress.percentage;
|
||||
entry.xpath = progress.xpath;
|
||||
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
|
||||
entry.computedSpineIndex = currentSpineIndex;
|
||||
entry.computedChapterPageCount = pageCount;
|
||||
entry.computedChapterProgress = currentPage;
|
||||
cachedBookmarks.insert(cachedBookmarks.begin(), entry);
|
||||
bookmarkRemoved = false;
|
||||
currentPageBookmarked = true;
|
||||
}
|
||||
|
||||
// Add bookmark
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
|
||||
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
|
||||
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
|
||||
Storage.mkdir(bookmarksDir.c_str());
|
||||
std::vector<BookmarkEntry> bookmarks;
|
||||
if (Storage.exists(path.c_str())) {
|
||||
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
|
||||
String json = Storage.readFile(path.c_str());
|
||||
if (!json.isEmpty()) {
|
||||
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
|
||||
const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str());
|
||||
if (!ok) {
|
||||
LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
|
||||
}
|
||||
bookmarks.insert(bookmarks.begin(), entry);
|
||||
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
|
||||
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
|
||||
if (ok) {
|
||||
showBookmarkMessage = true;
|
||||
} else {
|
||||
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::updateBookmarkFlag() {
|
||||
if (!section || !epub || cachedBookmarks.empty()) {
|
||||
currentPageBookmarked = false;
|
||||
return;
|
||||
}
|
||||
const int pageCount = section->estimatedTotalPages();
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount);
|
||||
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange);
|
||||
});
|
||||
}
|
||||
|
||||
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
ScreenshotInfo info;
|
||||
info.readerType = ScreenshotInfo::ReaderType::Epub;
|
||||
@@ -1247,9 +1592,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
}
|
||||
if (section) {
|
||||
info.currentPage = section->currentPage + 1;
|
||||
info.totalPages = section->pageCount;
|
||||
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||
info.totalPages = section->estimatedTotalPages();
|
||||
if (epub && epub->getBookSize() > 0 && info.totalPages > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(info.totalPages);
|
||||
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
@@ -1261,7 +1606,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
|
||||
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "BookmarkEntry.h"
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "activities/Activity.h"
|
||||
@@ -34,6 +36,9 @@ class EpubReaderActivity final : public Activity {
|
||||
bool automaticPageTurnActive = false;
|
||||
bool showBookmarkMessage = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
|
||||
std::vector<BookmarkEntry> cachedBookmarks;
|
||||
// Tracks whether this book is currently removed from Recent Books by the
|
||||
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
||||
bool recentsEntryRemoved = false;
|
||||
@@ -41,6 +46,8 @@ class EpubReaderActivity final : public Activity {
|
||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
||||
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||
bool pendingReadFolderMove = false;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
// Footnote support
|
||||
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||
@@ -55,15 +62,46 @@ class EpubReaderActivity final : public Activity {
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
void renderStatusBar() const;
|
||||
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
// Pages laid out per incremental-build pump: on the render path (catching up to the page
|
||||
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
// ahead and resumes as the reader advances; building unbounded instead locked up input by
|
||||
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
|
||||
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
|
||||
// already laid out as a partial file on exit/sleep.
|
||||
static constexpr int BUILD_WINDOW_AHEAD = 5;
|
||||
// Show the indexing popup when an initial build must lay out more than this many pages up front
|
||||
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
|
||||
// of the small look-ahead window so ordinary landings stay popup-free.
|
||||
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
|
||||
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
bool applyDeferredReposition();
|
||||
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
void jumpToPercent(int percent);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||
// because no KOReader credentials are stored.
|
||||
bool launchKOReaderSync();
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||
void pageTurn(bool isForwardTurn);
|
||||
void loadCachedBookmarks();
|
||||
void addBookmark();
|
||||
void updateBookmarkFlag();
|
||||
|
||||
// Footnote navigation
|
||||
void navigateToHref(const std::string& href, bool savePosition = false);
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -39,14 +38,6 @@ void EpubReaderBookmarksActivity::onEnter() {
|
||||
bookmarks.shrink_to_fit();
|
||||
} else {
|
||||
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
|
||||
|
||||
// pre-compute bookmark page values for quicker rendering
|
||||
for (auto& bookmark : bookmarks) {
|
||||
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
||||
bookmark.computedSpineIndex = pos.spineIndex;
|
||||
bookmark.computedChapterPageCount = pos.totalPages;
|
||||
bookmark.computedChapterProgress = pos.pageNumber;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
|
||||
@@ -93,6 +84,14 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
selectorIndex--;
|
||||
}
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
confirmingDelete = DELETE_MODE_OFF;
|
||||
return;
|
||||
@@ -108,8 +107,17 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
auto bookmark = bookmarks.at(selectorIndex);
|
||||
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
||||
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
|
||||
ProgressChangeResult result{};
|
||||
result.xpath = bookmark.xpath;
|
||||
result.percentage = bookmark.percentage;
|
||||
result.hasSavedProgress = true;
|
||||
if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
||||
bookmark.computedSpineIndex < epub->getSpineItemsCount()) {
|
||||
result.spineIndex = bookmark.computedSpineIndex;
|
||||
result.page = bookmark.computedChapterProgress;
|
||||
result.totalPages = bookmark.computedChapterPageCount;
|
||||
}
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -186,9 +194,12 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
||||
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
||||
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
||||
return std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - " +
|
||||
std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
||||
std::to_string(bookmark.computedChapterPageCount) + " - " + tocTitle;
|
||||
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
||||
if (bookmark.computedChapterPageCount > 0) {
|
||||
subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
||||
std::to_string(bookmark.computedChapterPageCount) + " - ";
|
||||
}
|
||||
return subtitle + tocTitle;
|
||||
};
|
||||
const auto getBookmarkIcon = [isPortrait](int index) {
|
||||
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
|
||||
@@ -208,16 +219,13 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
|
||||
|
||||
GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT},
|
||||
tr(STR_HOLD_CONFIRM_TO_DELETE));
|
||||
tr(STR_HOLD_OPEN_TO_DELETE));
|
||||
}
|
||||
} else {
|
||||
GUI.drawHelpText(renderer, Rect{contentX, LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
|
||||
tr(STR_BOOKMARK_INSTRUCTIONS));
|
||||
}
|
||||
|
||||
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||
const auto confirmLabel =
|
||||
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_OPEN)) : "";
|
||||
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
|
||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user