Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f5657fb2 | ||
|
|
48aa3c8e02 | ||
|
|
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"]
|
[submodule "freeink-sdk"]
|
||||||
path = open-x4-sdk
|
path = freeink-sdk
|
||||||
url = https://github.com/crosspoint-reader/community-sdk.git
|
url = https://github.com/Free-Ink/freeink-sdk.git
|
||||||
|
branch = main
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ Mission: Provide a lightweight, high-performance reading experience focused on E
|
|||||||
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
|
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
|
||||||
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
|
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
|
||||||
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
|
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
|
||||||
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the open-x4-sdk or official docs first.
|
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first.
|
||||||
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
|
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
|
||||||
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
|
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
|
||||||
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
|
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
|
||||||
@@ -127,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
|||||||
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
||||||
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
||||||
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
|
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
|
||||||
* open-x4-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
* freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
||||||
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
|
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
|
||||||
|
|
||||||
### Hardware Abstraction Layer (HAL)
|
### Hardware Abstraction Layer (HAL)
|
||||||
|
|||||||
@@ -131,6 +131,23 @@ Convert your own TTF/OTF files into `.cpfont` files that load from the SD card.
|
|||||||
|
|
||||||
Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build.
|
Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build.
|
||||||
|
|
||||||
|
## Custom SD-card themes
|
||||||
|
|
||||||
|
Downloadable themes are packaged in the tools repo under `../crosspoint-tools/public/themes/<theme-id>/`. Each theme folder must contain a `theme.json`; optional assets such as generated BMP icons live beside it, usually under `icons/`.
|
||||||
|
|
||||||
|
See [SD-card theme creation](./docs/theme-creation.md) for the full JSON format, device-specific overrides, icon generation, CrossInk extension fields, and packaging rules.
|
||||||
|
|
||||||
|
After adding or changing a hosted theme, regenerate the download manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/generate-theme-manifest.py \
|
||||||
|
--root ../crosspoint-tools/public/themes \
|
||||||
|
--base-url http://crosspointreader.com/themes \
|
||||||
|
--output ../crosspoint-tools/public/themes/themes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The script scans every theme folder, includes every file in each package, and writes size and CRC32 values used by the device downloader. Commit changed theme package files and the regenerated `themes.json` in `crosspoint-tools`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -138,6 +155,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
|
|||||||
- [User Guide](./USER_GUIDE.md)
|
- [User Guide](./USER_GUIDE.md)
|
||||||
- [Web server usage](./docs/webserver.md)
|
- [Web server usage](./docs/webserver.md)
|
||||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||||
|
- [SD-card theme creation](./docs/theme-creation.md)
|
||||||
- [Project scope](./SCOPE.md)
|
- [Project scope](./SCOPE.md)
|
||||||
- [Contributing docs](./docs/contributing/README.md)
|
- [Contributing docs](./docs/contributing/README.md)
|
||||||
|
|
||||||
|
|||||||
+46
-12
@@ -122,19 +122,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.
|
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.
|
|
||||||
|
|
||||||
- Download the zip file.
|
|
||||||
|
|
||||||
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
|
||||||
|
|
||||||
2. On the device: File Transfer -> Calibre Wireless, then join a network.
|
If you don't already have the plugin installed:
|
||||||
|
|
||||||
3. Make sure your computer is on the same Wi-Fi network.
|
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.
|
||||||
|
|
||||||
4. In Calibre, click "Send to device" to transfer books.
|
#### 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.
|
||||||
|
|
||||||
|
<img width="420" height="385" alt="Image" src="https://github.com/user-attachments/assets/01fc7e33-a9a7-48ba-9e26-2e68d1f9daec" />
|
||||||
|
|
||||||
|
#### Uploading 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
|
### 3.6 Settings
|
||||||
|
|
||||||
@@ -150,6 +174,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
||||||
- "None" - A blank screen
|
- "None" - A blank screen
|
||||||
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
||||||
|
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
|
||||||
|
|
||||||
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
||||||
|
|
||||||
@@ -162,6 +187,8 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
||||||
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
|
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
|
||||||
|
|
||||||
|
- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled.
|
||||||
|
|
||||||
- **Status Bar**: Configure the status bar displayed while reading:
|
- **Status Bar**: Configure the status bar displayed while reading:
|
||||||
|
|
||||||
- "None" - No status bar
|
- "None" - No status bar
|
||||||
@@ -241,6 +268,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
|
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
|
||||||
- "Page Scroll" - Long-pressing scrolls a page up/down
|
- "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:
|
- **Short Power Button Click**: Controls the effect of a short click of the power button:
|
||||||
|
|
||||||
@@ -523,11 +554,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.
|
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
|
### System Navigation
|
||||||
|
|
||||||
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
* **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.
|
* **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.
|
* **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
|
### Supported Languages
|
||||||
|
|
||||||
@@ -574,9 +608,9 @@ Accessible by selecting **Chapters** from the Reader Menu.
|
|||||||
|
|
||||||
Bookmarks can be created to quickly save and restore your place in a book.
|
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.
|
Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Formats all C/C++ source and header files in the repository, excluding
|
Formats all C/C++ source and header files in the repository, excluding
|
||||||
generated, vendored, and build directories (open-x4-sdk, builtinFonts,
|
generated, vendored, and build directories (freeink-sdk, builtinFonts,
|
||||||
hyphenation tries, uzlib, .pio, *.generated.h).
|
hyphenation tries, uzlib, .pio, *.generated.h).
|
||||||
|
|
||||||
The clang-format binary path is resolved once and cached in
|
The clang-format binary path is resolved once and cached in
|
||||||
@@ -92,7 +92,7 @@ function Resolve-ClangFormat {
|
|||||||
$clangFormat = Resolve-ClangFormat
|
$clangFormat = Resolve-ClangFormat
|
||||||
|
|
||||||
$exclude = @(
|
$exclude = @(
|
||||||
'open-x4-sdk'
|
'freeink-sdk'
|
||||||
'lib\EpdFont\builtinFonts'
|
'lib\EpdFont\builtinFonts'
|
||||||
'lib\Epub\Epub\hyphenation\generated'
|
'lib\Epub\Epub\hyphenation\generated'
|
||||||
'lib\uzlib'
|
'lib\uzlib'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk]
|
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk]
|
||||||
B --> C[lib/hal wrappers]
|
B --> C[lib/hal wrappers]
|
||||||
C --> D[src/main.cpp runtime loop]
|
C --> D[src/main.cpp runtime loop]
|
||||||
D --> E[Activities layer]
|
D --> E[Activities layer]
|
||||||
@@ -195,10 +195,10 @@ When editing related source assets, regenerate via normal build steps/scripts.
|
|||||||
- `src/`: app orchestration, settings/state, and activity implementations
|
- `src/`: app orchestration, settings/state, and activity implementations
|
||||||
- `src/network/`: web server and OTA/update networking
|
- `src/network/`: web server and OTA/update networking
|
||||||
- `src/components/`: theming and shared UI components
|
- `src/components/`: theming and shared UI components
|
||||||
- `lib/hal/`: hardware abstraction wrappers around open-x4-sdk
|
- `lib/hal/`: hardware abstraction wrappers around freeink-sdk
|
||||||
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
|
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
|
||||||
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
|
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
|
||||||
- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery)
|
- `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs
|
||||||
- `docs/`: user and technical documentation
|
- `docs/`: user and technical documentation
|
||||||
|
|
||||||
## Embedded constraints that shape design
|
## Embedded constraints that shape design
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
@@ -0,0 +1,889 @@
|
|||||||
|
# SD-card theme creation
|
||||||
|
|
||||||
|
CrossPoint ships one built-in base theme, Lyra. Additional themes live on the SD card and are selected from Settings. A downloaded theme is just a folder containing a `theme.json` and optional assets such as 1-bit BMP icons.
|
||||||
|
|
||||||
|
CrossPoint ignores unknown JSON fields. Other readers, such as CrossInk, can add their own fields under a namespaced object like `extensions.crossink` without breaking CrossPoint.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
Manual install paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/.themes/<theme-id>/theme.json # hidden folder used by the downloader
|
||||||
|
/themes/<theme-id>/theme.json # visible folder for manual installs
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosted theme packages live in the tools repo under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../crosspoint-tools/public/themes/<theme-id>/theme.json
|
||||||
|
../crosspoint-tools/public/themes/<theme-id>/icons/*.bmp
|
||||||
|
```
|
||||||
|
|
||||||
|
Theme ids must be path-safe: letters, numbers, `-`, and `_` only. Spaces are not accepted because ids are used in folder names, URLs, and settings.
|
||||||
|
|
||||||
|
## Minimal theme
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"id": "my-theme",
|
||||||
|
"name": "My Theme",
|
||||||
|
"description": "Short user-facing description shown in the downloader.",
|
||||||
|
"inherits": "lyra",
|
||||||
|
"metrics": {
|
||||||
|
"homeTopPadding": 48,
|
||||||
|
"menuRowHeight": 42
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"homeMenu": {
|
||||||
|
"font": "medium",
|
||||||
|
"style": "regular",
|
||||||
|
"centeredText": true,
|
||||||
|
"selectionStyle": "underline",
|
||||||
|
"showIcons": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"x3": {
|
||||||
|
"constraints": {
|
||||||
|
"screenWidth": 480,
|
||||||
|
"screenHeight": 800,
|
||||||
|
"frontButtons": 4,
|
||||||
|
"sideButtons": "up-down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x4": {
|
||||||
|
"constraints": {
|
||||||
|
"screenWidth": 480,
|
||||||
|
"screenHeight": 800,
|
||||||
|
"frontButtons": 0,
|
||||||
|
"sideButtons": "up-down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level fields:
|
||||||
|
|
||||||
|
- `schema`: currently `1`.
|
||||||
|
- `id`: stable id used for settings, folder name, and downloads.
|
||||||
|
- `name`: display name shown in Settings and the downloader.
|
||||||
|
- `description`: short downloader text.
|
||||||
|
- `inherits`: `lyra` for normal SD themes. `classic` is accepted for manually installed themes that intentionally build from the Classic renderer.
|
||||||
|
- `metrics`: layout numbers shared across screens.
|
||||||
|
- `components`: style rules for themeable UI surfaces.
|
||||||
|
- `assets.icons`: optional icon file map.
|
||||||
|
- `devices`: optional per-device overrides keyed by `x3` or `x4`.
|
||||||
|
- `requires`: optional metadata for other tooling. CrossPoint currently ignores it.
|
||||||
|
- `extensions`: optional namespaced metadata for other firmware/apps. CrossPoint currently ignores it.
|
||||||
|
|
||||||
|
## Device overrides
|
||||||
|
|
||||||
|
The active device id is `x3` or `x4`. Any supported field under `devices.<device-id>` overrides the top-level value:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metrics": {
|
||||||
|
"homeCoverHeight": 300
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"homeRecents": {
|
||||||
|
"maxBooks": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"x3": {
|
||||||
|
"metrics": {
|
||||||
|
"homeCoverHeight": 280
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"homeRecents": {
|
||||||
|
"maxBooks": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `constraints` to document intended screen and button assumptions for builders and compatible apps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"constraints": {
|
||||||
|
"screenWidth": 480,
|
||||||
|
"screenHeight": 800,
|
||||||
|
"frontButtons": 4,
|
||||||
|
"sideButtons": "up-down"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CrossPoint parses these constraints but does not reject themes when they do not match.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
Metrics tune global spacing and layout. Any omitted metric keeps Lyra's default.
|
||||||
|
|
||||||
|
Common home/list metrics:
|
||||||
|
|
||||||
|
- `topPadding`: top inset above normal page headers.
|
||||||
|
- `headerHeight`: default header band height for non-home screens.
|
||||||
|
- `verticalSpacing`: default vertical gap between major screen regions.
|
||||||
|
- `contentSidePadding`: left/right inset used by default list and menu renderers.
|
||||||
|
- `listRowHeight`: row height for single-line lists.
|
||||||
|
- `listWithSubtitleRowHeight`: row height for two-line lists such as Recent Books.
|
||||||
|
- `menuRowHeight`: height of one home menu tile. In `launcherGrid`, this is used by `drawButtonMenu` inside each grid cell; it is not the gap between grid cells.
|
||||||
|
- `menuSpacing`: vertical spacing between items when rendering a plain one-column home menu. It does not affect `launcherGrid`, because each grid cell is rendered as a one-item menu.
|
||||||
|
- `tabSpacing`: spacing between tab labels.
|
||||||
|
- `tabBarHeight`: height of the settings tab bar.
|
||||||
|
- `scrollBarWidth`: list scrollbar width.
|
||||||
|
- `scrollBarRightOffset`: list scrollbar inset from the right edge.
|
||||||
|
- `homeTopPadding`: top inset before the home cover/recent-books area in the legacy home renderer.
|
||||||
|
- `homeCoverHeight`: cover image height used by home recents.
|
||||||
|
- `homeCoverTileHeight`: total home recents tile/slot height, including cover title space when applicable.
|
||||||
|
- `homeRecentBooksCount`: number of recent books to request/render on home.
|
||||||
|
- `homeContinueReadingInMenu`: whether Continue Reading is part of the home launcher/menu actions.
|
||||||
|
- `homeShowContinueReadingHeader`: whether the current book title can appear in the home header.
|
||||||
|
- `homeMenuTopOffset`: legacy/manual home menu offset below the cover area. SD `screens.home.layout` themes should prefer explicit layout slots such as `carouselMenuGap`.
|
||||||
|
- `buttonHintsHeight`: bottom button-hint band height.
|
||||||
|
- `sideButtonHintsWidth`: side button-hint band width.
|
||||||
|
|
||||||
|
Other supported metric groups:
|
||||||
|
|
||||||
|
- Battery: `batteryWidth`, `batteryHeight`, `batteryBarHeight`
|
||||||
|
- Reader progress/status: `progressBarHeight`, `progressBarMarginTop`, `statusBarHorizontalMargin`, `statusBarVerticalMargin`
|
||||||
|
- Keyboard: `keyboardKeyWidth`, `keyboardKeyHeight`, `keyboardKeySpacing`, `keyboardBottomKeyHeight`, `keyboardBottomKeySpacing`, `keyboardBottomAligned`, `keyboardCenteredText`, `keyboardVerticalOffset`, `keyboardTextFieldWidthPercent`, `keyboardWidthPercent`, `keyboardKeyCornerRadius`, `keyboardFillUnselected`, `keyboardOutlineAllUnselected`, `keyboardDrawSpecialOutlineWhenUnselected`, `keyboardSecondaryLabelRightPadding`, `keyboardSecondaryLabelTopPadding`, `keyboardMinArrowHeadSize`
|
||||||
|
- Popups: `popupTopOffsetRatio`, `popupMarginX`, `popupMarginY`, `popupFrameThickness`, `popupCornerRadius`, `popupTextBold`, `popupTextInverted`, `popupTextBaselineOffsetY`, `popupProgressBarHeight`, `popupProgressDrawOutline`, `popupProgressClampPercent`, `popupProgressFillInverted`, `popupProgressOutlineInverted`
|
||||||
|
- Text fields: `textFieldHorizontalPadding`, `textFieldNormalThickness`, `textFieldCursorThickness`, `textFieldLineEndOffset`
|
||||||
|
|
||||||
|
## Screen Layouts
|
||||||
|
|
||||||
|
Themes can define `screens.<screen>.layout` to place UI regions with the SDK row/column layout system. This is the preferred path for new SD themes.
|
||||||
|
|
||||||
|
Each layout node can contain:
|
||||||
|
|
||||||
|
- `id`: slot name used by widgets or firmware renderers.
|
||||||
|
- `axis`: `column` stacks children top-to-bottom; `row` lays children left-to-right.
|
||||||
|
- `gap`: pixels inserted between this node's direct children.
|
||||||
|
- `slots`: child layout nodes.
|
||||||
|
- `fixed`: exact pixel size along the parent axis.
|
||||||
|
- `flex`: proportional size after fixed children and gaps are subtracted.
|
||||||
|
- `token`: named size from `metrics`, such as `menuRow`, `recents`, `buttons`, `header`, `row`, `subtitleRow`, or `gap`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"screens": {
|
||||||
|
"home": {
|
||||||
|
"navigation": "linear",
|
||||||
|
"layout": {
|
||||||
|
"axis": "column",
|
||||||
|
"gap": 0,
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"id": "header",
|
||||||
|
"fixed": 40,
|
||||||
|
"axis": "row",
|
||||||
|
"gap": 4,
|
||||||
|
"slots": [
|
||||||
|
{ "id": "homeClock", "fixed": 52 },
|
||||||
|
{ "id": "homeTitle", "flex": 1 },
|
||||||
|
{ "id": "homeBattery", "fixed": 66 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "id": "recents", "fixed": 340 },
|
||||||
|
{ "id": "carouselMenuGap", "fixed": 36 },
|
||||||
|
{ "id": "launchers", "fixed": 192 },
|
||||||
|
{ "id": "homeSpacer", "flex": 1 },
|
||||||
|
{ "id": "buttons", "fixed": 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important layout rules:
|
||||||
|
|
||||||
|
- A parent layout's `gap` only affects its direct child slots.
|
||||||
|
- `fixed` and `flex` decide how much space a slot receives. They do not decide how a widget draws inside that slot.
|
||||||
|
- Widget-specific `gap` fields control spacing inside that widget.
|
||||||
|
- Named spacer slots such as `carouselMenuGap` and `homeSpacer` do not draw anything unless a widget targets them. They are useful for placing visible regions without manual `x`/`y` coordinates.
|
||||||
|
- If a screen layout is invalid or missing required slots, CrossPoint falls back to the built-in Lyra-safe layout for that screen.
|
||||||
|
|
||||||
|
Home `navigation` modes:
|
||||||
|
|
||||||
|
- `linear`: default. Front/side navigation buttons all move through the visible home actions as one ordered list.
|
||||||
|
- `splitAxis`: front left/right move through launcher actions; side up/down move through recent-book actions. Bottom button hints show Left/Right.
|
||||||
|
- `carousel`: front left/right move through recent-book actions; side up/down move through launcher actions. Use this when left/right should stay inside a cover carousel and up/down should enter or leave the launcher menu.
|
||||||
|
|
||||||
|
Home `initialAction` can optionally choose the default selected action when entering home normally:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"initialAction": "reader:recent"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported values match launcher `action` values. Explicit firmware navigation, such as returning to Settings from a settings submenu, still overrides this default.
|
||||||
|
|
||||||
|
### Layouts vs widgets
|
||||||
|
|
||||||
|
Layouts only create named rectangles. They do not choose whether a screen is a list, cover grid, carousel, or any other presentation.
|
||||||
|
|
||||||
|
Widgets choose what renders inside those rectangles. This keeps themes explicit and prevents firmware from guessing a grid just because a screen has a `list` slot.
|
||||||
|
|
||||||
|
For `screens.recentBooks`, use:
|
||||||
|
|
||||||
|
- No `recentBooks` screen: use the built-in Lyra recent-books screen.
|
||||||
|
- `layout` only, or a `list` widget: use the normal themed recent-books list in the `list` slot.
|
||||||
|
- A `coverGrid` widget: use FreeInkUI's cover-grid component in the target slot.
|
||||||
|
|
||||||
|
Minimal themed list example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"recentBooks": {
|
||||||
|
"layout": {
|
||||||
|
"axis": "column",
|
||||||
|
"gap": 8,
|
||||||
|
"slots": [
|
||||||
|
{ "id": "header", "fixed": 48 },
|
||||||
|
{ "id": "list", "flex": 1 },
|
||||||
|
{ "id": "buttons", "fixed": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"widgets": [
|
||||||
|
{ "slot": "list", "type": "list" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cover-grid screen example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"recentBooks": {
|
||||||
|
"layout": {
|
||||||
|
"axis": "column",
|
||||||
|
"gap": 16,
|
||||||
|
"slots": [
|
||||||
|
{ "id": "header", "fixed": 48 },
|
||||||
|
{ "id": "list", "flex": 1 },
|
||||||
|
{ "id": "buttons", "fixed": 40 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"slot": "list",
|
||||||
|
"type": "coverGrid",
|
||||||
|
"columns": 3,
|
||||||
|
"rowGap": 36,
|
||||||
|
"coverWidth": 92,
|
||||||
|
"coverHeight": 132,
|
||||||
|
"rowHeight": 172,
|
||||||
|
"labelLines": 2,
|
||||||
|
"selectionStyle": "coverFrame"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use screen-level `coverGrid`. Cover-grid settings belong on a widget with `type: "coverGrid"`.
|
||||||
|
|
||||||
|
### Home widgets
|
||||||
|
|
||||||
|
Home layouts use `screens.home.widgets` to map slot rectangles to visible content.
|
||||||
|
|
||||||
|
Supported widget types:
|
||||||
|
|
||||||
|
- `clock`: draws the clock when the device has RTC support. On devices without clock support, the slot stays empty.
|
||||||
|
- `headerTitle`: draws the normal home/header title.
|
||||||
|
- `battery`: draws the battery indicator.
|
||||||
|
- `recents`: draws the configured home recents component.
|
||||||
|
- `recentCoverGrid`: draws recent books with FreeInkUI's `coverGrid` component.
|
||||||
|
- `launcherList`: draws actions as one vertical menu inside its slot.
|
||||||
|
- `launcherGrid`: draws actions in a row/column grid inside its slot.
|
||||||
|
- `buttonHints`: draws bottom button hints.
|
||||||
|
|
||||||
|
`launcherGrid` fields:
|
||||||
|
|
||||||
|
- `slot`: slot id to render into.
|
||||||
|
- `presentation`: optional presentation style. Use `iconTabs` for icon-only launcher tabs with outlined unselected cells and filled selected cells.
|
||||||
|
- `columns`: number of grid columns.
|
||||||
|
- `rows`: optional fixed row count. If omitted, rows are derived from visible launcher count and columns.
|
||||||
|
- `gap`: pixels between grid cells, both horizontally and vertically.
|
||||||
|
- `items`: launcher actions. Each item accepts `text`, `icon`, and `action`.
|
||||||
|
|
||||||
|
All home widgets also support visual placement fields:
|
||||||
|
|
||||||
|
- `layer`: draw order. Lower layers draw first; higher layers paint on top. Widgets with the same layer keep JSON order.
|
||||||
|
- `offsetX`: moves the widget right after layout. Negative values move left.
|
||||||
|
- `offsetY`: moves the widget down after layout. Negative values move up.
|
||||||
|
- `bleed`: expands the widget draw rectangle outside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`.
|
||||||
|
- `inset`: shrinks the widget draw rectangle inside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`.
|
||||||
|
|
||||||
|
Example overlap:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": "recents",
|
||||||
|
"type": "recents",
|
||||||
|
"layer": 0,
|
||||||
|
"bleed": { "bottom": 24 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": "launchers",
|
||||||
|
"type": "launcherGrid",
|
||||||
|
"layer": 10,
|
||||||
|
"offsetY": -12,
|
||||||
|
"columns": 2,
|
||||||
|
"gap": 24
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That keeps the structural row/column layout intact, but lets the launcher grid visually overlap the recents area by 12 pixels.
|
||||||
|
|
||||||
|
`buttonHints` widget fields:
|
||||||
|
|
||||||
|
- `labels.confirm`
|
||||||
|
- `labels.previous`
|
||||||
|
- `labels.next`
|
||||||
|
- `labels.back`
|
||||||
|
|
||||||
|
Button-hint labels are localized semantic tokens, not literal UI strings. Supported tokens are `default`, `empty`, `back`, `home`, `select`, `confirm`, `open`, `toggle`, `up`, `down`, `left`, and `right`. `default` uses the firmware fallback for that navigation mode; `empty` renders no label for that button.
|
||||||
|
|
||||||
|
Example carousel hints:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": "buttons",
|
||||||
|
"type": "buttonHints",
|
||||||
|
"labels": {
|
||||||
|
"confirm": "select",
|
||||||
|
"previous": "left",
|
||||||
|
"next": "right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `components.buttonHints.layout` is `shapes` or `icons`, these same localized tokens render as button shapes/icons where supported.
|
||||||
|
|
||||||
|
Example icon tabs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot": "tabs",
|
||||||
|
"type": "launcherGrid",
|
||||||
|
"presentation": "iconTabs",
|
||||||
|
"columns": 5,
|
||||||
|
"rows": 1,
|
||||||
|
"gap": 6,
|
||||||
|
"iconSize": 32,
|
||||||
|
"selectedRadius": 5,
|
||||||
|
"items": [
|
||||||
|
{ "icon": "folder", "action": "activity:fileBrowser" },
|
||||||
|
{ "icon": "recent", "action": "activity:recentBooks" },
|
||||||
|
{ "icon": "library", "action": "activity:opds" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Launcher actions:
|
||||||
|
|
||||||
|
- `activity:fileBrowser`
|
||||||
|
- `activity:recentBooks`
|
||||||
|
- `activity:opds`
|
||||||
|
- `activity:fileTransfer`
|
||||||
|
- `activity:settings`
|
||||||
|
- `activity:reader`
|
||||||
|
|
||||||
|
For `launcherGrid`, the final cell height is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(slot height - gap * (rows - 1)) / rows
|
||||||
|
```
|
||||||
|
|
||||||
|
Then each cell calls the themed home menu renderer with one item. That means:
|
||||||
|
|
||||||
|
- Increase the widget `gap` to create more visible space between grid items.
|
||||||
|
- Increase the launcher slot `fixed` height if larger gaps need more total room.
|
||||||
|
- Use `menuRowHeight` to tune the selectable tile/text/icon band inside each cell.
|
||||||
|
- Do not expect `menuSpacing` to change `launcherGrid` spacing.
|
||||||
|
|
||||||
|
For a 3-row launcher grid with `menuRowHeight: 48` and `gap: 24`, use a launcher slot near:
|
||||||
|
|
||||||
|
```text
|
||||||
|
3 * 48 + 2 * 24 = 192
|
||||||
|
```
|
||||||
|
|
||||||
|
`recentCoverGrid` / recent-books `coverGrid` widget fields:
|
||||||
|
|
||||||
|
- `slot`: slot id to render into.
|
||||||
|
- `columns`: grid columns.
|
||||||
|
- `rows`: grid rows.
|
||||||
|
- `gap`: horizontal pixels between cells. Also used vertically when `rowGap` is omitted.
|
||||||
|
- `rowGap`: vertical pixels between cover-grid rows.
|
||||||
|
- `cellInset`: optional padding inside each cover-grid cell, before the cover and label are drawn.
|
||||||
|
- `labelInset`: optional padding inside the title label area. Use `{ "left": 5, "right": 5 }` to keep two-line titles away from cell edges.
|
||||||
|
- `coverWidth`: rendered cover width.
|
||||||
|
- `coverHeight`: rendered cover height and thumbnail size to generate.
|
||||||
|
- `placeholderIconSize`: maximum icon size for the missing-cover placeholder.
|
||||||
|
- `rowHeight`: height of each cell row, including label space.
|
||||||
|
- `labelHeight`: title label area below each cover. Use `0` to hide titles.
|
||||||
|
- `labelGap`: vertical pixels between the cover and title label block.
|
||||||
|
- `labelLines`: maximum title lines to render. Increase `rowHeight` when this is greater than `1`.
|
||||||
|
- `selectionStyle`: `fill`, `outline`, `coverFrame`, or `none`. Prefer `coverFrame` for cover grids because it frames only the thumbnail and does not depend on title wrapping.
|
||||||
|
- `startIndex`: first recent-book index to show. Use `2` when a featured area already uses the first two books.
|
||||||
|
|
||||||
|
These cover-grid widgets use FreeInkUI's `coverGrid` for layout, labels, cell styling, and selected state. CrossPoint supplies a cover painter callback so SD-card thumbnails render from the existing recent-book cache.
|
||||||
|
|
||||||
|
Cover widgets can use different visual `coverWidth` and `coverHeight` values on different screens. CrossPoint still generates and reads one largest-needed thumbnail height for the active theme, then scales/crops it into each widget. That keeps the same book cover available on home and recent-books instead of requiring separate BMPs per widget.
|
||||||
|
|
||||||
|
`featuredBookCard` fields:
|
||||||
|
|
||||||
|
- `coverWidth`, `coverHeight`: rendered cover size and thumbnail height to generate.
|
||||||
|
- `placeholderIconSize`: maximum icon size for the missing-cover placeholder.
|
||||||
|
- `coverGap`: horizontal gap between the cover and title/author text.
|
||||||
|
- `titleGap`: vertical gap below the Continue Reading label before the book card starts.
|
||||||
|
- `startIndex`: recent-book index to show.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
|
||||||
|
Most components accept:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"font": "large",
|
||||||
|
"style": "bold"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported `font` values are `small`, `medium`, and `large`.
|
||||||
|
|
||||||
|
Semantic aliases are also accepted:
|
||||||
|
|
||||||
|
- `chrome`, `caption`: same as `small`.
|
||||||
|
- `body`, `label`: same as `medium`.
|
||||||
|
- `title`, `display`: same as `large`.
|
||||||
|
|
||||||
|
Supported `style` values are `regular` and `bold`.
|
||||||
|
|
||||||
|
### Home recents
|
||||||
|
|
||||||
|
`components.homeRecents` controls the home cover area.
|
||||||
|
|
||||||
|
Supported types:
|
||||||
|
|
||||||
|
- `default`: Lyra default.
|
||||||
|
- `none`: no cover area.
|
||||||
|
- `cover-strip`: one or more cover slots.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"homeRecents": {
|
||||||
|
"type": "cover-strip",
|
||||||
|
"maxBooks": 3,
|
||||||
|
"wrap": true,
|
||||||
|
"selectionLineWidth": 3,
|
||||||
|
"inactiveSelectionLineWidth": 1,
|
||||||
|
"selectionCornerRadius": 6,
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"book": "previous",
|
||||||
|
"x": "padding",
|
||||||
|
"y": "center",
|
||||||
|
"height": 210,
|
||||||
|
"widthPercent": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"book": "selected",
|
||||||
|
"x": "center",
|
||||||
|
"y": "top",
|
||||||
|
"height": 280,
|
||||||
|
"widthPercent": 62,
|
||||||
|
"selected": true,
|
||||||
|
"title": {
|
||||||
|
"enabled": true,
|
||||||
|
"font": "large",
|
||||||
|
"style": "bold",
|
||||||
|
"maxLines": 2,
|
||||||
|
"offsetY": 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"book": "next",
|
||||||
|
"x": "right-padding",
|
||||||
|
"y": "center",
|
||||||
|
"height": 210,
|
||||||
|
"widthPercent": 62
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Slot fields:
|
||||||
|
|
||||||
|
- `book`: `selected`, `previous`, `next`, or `index`.
|
||||||
|
- `bookIndex`: zero-based index when `book` is `index`.
|
||||||
|
- `x`: `padding`, `center`, or `right-padding`.
|
||||||
|
- `y`: `top` or `center`.
|
||||||
|
- `height`: requested thumbnail height. CrossPoint generates/cache-misses thumbnails at requested sizes.
|
||||||
|
- `widthPercent`: cover width as a percent of the slot height.
|
||||||
|
- `xOffset`, `yOffset`: positional adjustments.
|
||||||
|
- `selected`: whether this slot receives the active selection outline.
|
||||||
|
- `title`: optional book title under the cover.
|
||||||
|
|
||||||
|
CrossPoint currently reads up to five cover slots.
|
||||||
|
|
||||||
|
Cover slots with `selected: true` draw after unselected slots, so selected covers appear in front. Within each group, slots draw in the same order they appear in JSON. For a carousel where the side covers sit behind the middle cover, mark the middle slot as `selected: true`.
|
||||||
|
|
||||||
|
Use `xOffset` and `yOffset` for small relative adjustments after `x`/`y` placement has been resolved:
|
||||||
|
|
||||||
|
- Positive `xOffset` moves a cover right.
|
||||||
|
- Negative `xOffset` moves a cover left.
|
||||||
|
- Positive `yOffset` moves a cover down.
|
||||||
|
- Negative `yOffset` moves a cover up.
|
||||||
|
|
||||||
|
Example carousel layering:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"book": "previous",
|
||||||
|
"x": "padding",
|
||||||
|
"y": "center",
|
||||||
|
"height": 225,
|
||||||
|
"widthPercent": 62,
|
||||||
|
"xOffset": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"book": "next",
|
||||||
|
"x": "right-padding",
|
||||||
|
"y": "center",
|
||||||
|
"height": 225,
|
||||||
|
"widthPercent": 62,
|
||||||
|
"xOffset": -32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"book": "selected",
|
||||||
|
"x": "center",
|
||||||
|
"y": "top",
|
||||||
|
"height": 300,
|
||||||
|
"widthPercent": 62,
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
In that example, the side covers are pushed toward the center, and the selected cover is drawn in the foreground.
|
||||||
|
|
||||||
|
### Home menu
|
||||||
|
|
||||||
|
`components.homeMenu` styles the home menu options.
|
||||||
|
|
||||||
|
Supported fields:
|
||||||
|
|
||||||
|
- `font`, `style`, `bold`
|
||||||
|
- `centeredText`
|
||||||
|
- `centerVertically`
|
||||||
|
- `showIcons`
|
||||||
|
- `panelWidth`
|
||||||
|
- `drawPanel`
|
||||||
|
- `panelCornerRadius`
|
||||||
|
- `selectionStyle`: `fill`, `outline`, `triangle`, `underline`, or `pill`
|
||||||
|
- `selectionCornerRadius`
|
||||||
|
- `selectionInset`
|
||||||
|
- `selectedTextInverted`
|
||||||
|
- `selectionFillBlack`
|
||||||
|
- `rowPaddingX`
|
||||||
|
- `textInsetX`
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
`components.list` styles Settings, Browse, Recent Books, and similar list rows.
|
||||||
|
|
||||||
|
Supported fields:
|
||||||
|
|
||||||
|
- `font`, `style`, `bold`
|
||||||
|
- `subtitleFontId`
|
||||||
|
- `valueFontId`
|
||||||
|
- `showIcons`
|
||||||
|
- `iconSize`
|
||||||
|
- `textGap`
|
||||||
|
- `selectionStyle`: `fill`, `outline`, or `underline`
|
||||||
|
- `selectionCornerRadius`
|
||||||
|
- `selectionFill`
|
||||||
|
- `selectionOutline`
|
||||||
|
- `selectedTextInverted`
|
||||||
|
- `rowBackgrounds`
|
||||||
|
- `centerSingleLineRows`
|
||||||
|
- `subtitleRowAutoHeight`
|
||||||
|
- `centerValueVertically`
|
||||||
|
- `rowSidePadding`
|
||||||
|
- `rowGap`
|
||||||
|
- `textInsetX`
|
||||||
|
- `selectionInsetX`
|
||||||
|
- `selectionInsetY`
|
||||||
|
- `titleOffsetY`
|
||||||
|
- `subtitleOffsetY`
|
||||||
|
- `subtitleTopPadding`
|
||||||
|
- `subtitleBottomPadding`
|
||||||
|
- `subtitleInterLineGap`
|
||||||
|
- `valueOffsetY`
|
||||||
|
- `subtitleValueOffsetY`
|
||||||
|
- `iconOffsetY`
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
`components.header` styles page headers.
|
||||||
|
|
||||||
|
Supported fields:
|
||||||
|
|
||||||
|
- `font`, `style`, `bold`
|
||||||
|
- `centeredTitle`
|
||||||
|
- `showDivider`
|
||||||
|
- `titleOffsetY`
|
||||||
|
- `batteryOffsetY`
|
||||||
|
|
||||||
|
### Tab bar
|
||||||
|
|
||||||
|
`components.tabBar` styles tabs.
|
||||||
|
|
||||||
|
Supported fields:
|
||||||
|
|
||||||
|
- `font`, `style`, `bold`
|
||||||
|
- `equalWidth`
|
||||||
|
- `selectionStyle`: `fill` or `underline`
|
||||||
|
- `selectedCornerRadius`
|
||||||
|
- `selectedTextInverted`
|
||||||
|
- `drawDivider`
|
||||||
|
- `horizontalInset`
|
||||||
|
|
||||||
|
### Button hints
|
||||||
|
|
||||||
|
`components.buttonHints` styles bottom and side button hints.
|
||||||
|
|
||||||
|
Supported fields:
|
||||||
|
|
||||||
|
- `font`, `style`, `bold`
|
||||||
|
- `layout`: `buttons`, `groups`, `shapes`, or `icons`
|
||||||
|
- `buttonWidth`
|
||||||
|
- `smallButtonHeight`
|
||||||
|
- `cornerRadius`
|
||||||
|
- `fill`
|
||||||
|
- `outline`
|
||||||
|
- `drawEmpty`
|
||||||
|
- `shapes`
|
||||||
|
- `sidePadding`
|
||||||
|
- `groupGap`
|
||||||
|
- `bottomMargin`
|
||||||
|
- `innerPadding`
|
||||||
|
- `shapeSize`
|
||||||
|
- `textOffsetY`
|
||||||
|
|
||||||
|
Use `layout: "shapes"` or `layout: "icons"` for icon-only arrows/circle/square hints.
|
||||||
|
|
||||||
|
### Reader chrome
|
||||||
|
|
||||||
|
`screens.reader.chrome` styles the reader status lane. Reader chrome still uses `screens.reader.layout` slots for placement; the chrome object controls how those slots draw.
|
||||||
|
|
||||||
|
Battery fields:
|
||||||
|
|
||||||
|
- `style`: `icon` or `bar`.
|
||||||
|
- `width`: battery glyph width in pixels.
|
||||||
|
- `height`: battery glyph height in pixels.
|
||||||
|
- `offsetY`: vertical adjustment applied after the battery is positioned in its slot. Positive values move it down; negative values move it up.
|
||||||
|
- `track`: background/track style for bar batteries: `none`, `hairline`, `outline`, or `dither`.
|
||||||
|
- `fill`: fill style for bar batteries: `solid`, `dither`, or `segments`.
|
||||||
|
- `direction`: fill direction: `left-to-right`, `right-to-left`, `center-out`, `bottom-to-top`, or `top-to-bottom`.
|
||||||
|
- `orientation`: `horizontal` or `vertical`. Vertical is also implied by `bottom-to-top` and `top-to-bottom`.
|
||||||
|
- `caps`: `square` or `pixel`. `pixel` trims the four filled corners for a softer e-ink cap.
|
||||||
|
- `segments`: number of filled blocks when `fill` is `segments`.
|
||||||
|
- `segmentGap`: pixels between segments.
|
||||||
|
- `radius`: rounded-rect radius for bar track/fill/segments. Keep this small for thin e-ink bars; `0` is square.
|
||||||
|
- `showPercentage`: whether reader chrome may draw the battery percentage when the global setting allows it.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"screens": {
|
||||||
|
"reader": {
|
||||||
|
"layout": {
|
||||||
|
"axis": "row",
|
||||||
|
"gap": 8,
|
||||||
|
"slots": [
|
||||||
|
{ "id": "bookmark", "fixed": 18 },
|
||||||
|
{ "id": "battery", "fixed": 38 },
|
||||||
|
{ "id": "title", "flex": 1 },
|
||||||
|
{ "id": "clock", "fixed": 42 },
|
||||||
|
{ "id": "progress", "fixed": 82 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chrome": {
|
||||||
|
"battery": {
|
||||||
|
"style": "bar",
|
||||||
|
"width": 38,
|
||||||
|
"height": 3,
|
||||||
|
"offsetY": 1,
|
||||||
|
"track": "none",
|
||||||
|
"fill": "solid",
|
||||||
|
"direction": "left-to-right",
|
||||||
|
"radius": 0,
|
||||||
|
"showPercentage": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Icons are optional. If both `homeMenu.showIcons` and `list.showIcons` are false, omit `assets.icons` and the icon files to reduce download size and heap use.
|
||||||
|
|
||||||
|
Supported icon keys:
|
||||||
|
|
||||||
|
- `folder`, `folder24`
|
||||||
|
- `text`, `text24`
|
||||||
|
- `image`, `image24`
|
||||||
|
- `book`, `book24`
|
||||||
|
- `file`, `file24`
|
||||||
|
- `recent`
|
||||||
|
- `settings`, `settings2`
|
||||||
|
- `transfer`
|
||||||
|
- `library`
|
||||||
|
- `wifi`
|
||||||
|
- `hotspot`
|
||||||
|
- `bookmark`
|
||||||
|
|
||||||
|
Generate firmware-matching 1-bit BMP icons:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/generate-theme-icons.py \
|
||||||
|
--icons src/components/icons \
|
||||||
|
--themes ../crosspoint-tools/public/themes
|
||||||
|
```
|
||||||
|
|
||||||
|
The script writes rotated BMP files into each `../crosspoint-tools/public/themes/<theme-id>/icons/` folder.
|
||||||
|
|
||||||
|
Reference them from `theme.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"assets": {
|
||||||
|
"icons": {
|
||||||
|
"folder": "icons/folder.bmp",
|
||||||
|
"book": "icons/book.bmp",
|
||||||
|
"settings": "icons/settings2.bmp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CrossInk and extension fields
|
||||||
|
|
||||||
|
CrossPoint only consumes the fields documented above. Unknown fields are ignored, so theme authors can include extra data for compatible apps and firmware.
|
||||||
|
|
||||||
|
Put app-specific fields under `extensions.<namespace>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"id": "crossink-stats",
|
||||||
|
"name": "CrossInk Stats",
|
||||||
|
"inherits": "lyra",
|
||||||
|
"components": {
|
||||||
|
"homeRecents": {
|
||||||
|
"type": "cover-strip",
|
||||||
|
"maxBooks": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"crossink": {
|
||||||
|
"schema": 1,
|
||||||
|
"readingStats": {
|
||||||
|
"enabled": true,
|
||||||
|
"placement": "home-footer",
|
||||||
|
"font": "small",
|
||||||
|
"style": "regular",
|
||||||
|
"show": [
|
||||||
|
"currentStreak",
|
||||||
|
"readingTime",
|
||||||
|
"pagesRead",
|
||||||
|
"percentComplete"
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"currentStreak": "streak",
|
||||||
|
"readingTime": "reading",
|
||||||
|
"pagesRead": "pages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended extension rules:
|
||||||
|
|
||||||
|
- Keep CrossPoint layout fields in `metrics`, `components`, `assets`, and `devices`.
|
||||||
|
- Keep CrossInk-only fields under `extensions.crossink`.
|
||||||
|
- Add an extension-local `schema` when the app-specific format may evolve.
|
||||||
|
- Prefer declarative fields such as `placement`, `font`, `show`, and `labels` over code-like strings.
|
||||||
|
- Keep extension data compact. CrossPoint ignores it, but it is still parsed transiently when discovering themes.
|
||||||
|
- Do not put required CrossPoint behavior only in an extension field. CrossPoint will not read it.
|
||||||
|
|
||||||
|
CrossInk can also use `requires` for compatibility metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"requires": {
|
||||||
|
"crosspoint": {
|
||||||
|
"schema": 1,
|
||||||
|
"modules": ["cover-strip"]
|
||||||
|
},
|
||||||
|
"crossink": {
|
||||||
|
"schema": 1,
|
||||||
|
"modules": ["reading-stats"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CrossPoint currently treats `requires` as metadata.
|
||||||
|
|
||||||
|
## Package manifest
|
||||||
|
|
||||||
|
After adding or changing hosted themes, regenerate `themes.json` in `crosspoint-tools`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/generate-theme-manifest.py \
|
||||||
|
--root ../crosspoint-tools/public/themes \
|
||||||
|
--base-url http://crosspointreader.com/themes \
|
||||||
|
--output ../crosspoint-tools/public/themes/themes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest generator:
|
||||||
|
|
||||||
|
- scans every `../crosspoint-tools/public/themes/<theme-id>/theme.json`
|
||||||
|
- includes every file in each theme folder
|
||||||
|
- writes per-file `size` and `crc32`
|
||||||
|
- writes the theme `id`, `name`, `description`, and `totalSize`
|
||||||
|
|
||||||
|
Commit the theme files and the regenerated manifest together in `crosspoint-tools`.
|
||||||
|
|
||||||
|
## Validation checklist
|
||||||
|
|
||||||
|
Before publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in ../crosspoint-tools/public/themes/themes.json ../crosspoint-tools/public/themes/*/theme.json; do
|
||||||
|
python3 -m json.tool "$f" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 scripts/generate-theme-manifest.py \
|
||||||
|
--root ../crosspoint-tools/public/themes \
|
||||||
|
--base-url http://crosspointreader.com/themes \
|
||||||
|
--output ../crosspoint-tools/public/themes/themes.json
|
||||||
|
|
||||||
|
pio run -e gh_release
|
||||||
|
```
|
||||||
|
|
||||||
|
On device:
|
||||||
|
|
||||||
|
1. Download the theme from Settings -> UI Theme -> Download Themes.
|
||||||
|
2. Exit the downloader and let the device silently restart to clear WiFi/TLS heap.
|
||||||
|
3. Return to Settings -> UI Theme and select the downloaded theme.
|
||||||
|
4. Check Home, Settings, Browse, Recent Books, button hints, tabs, popups, keyboard, and reader menus.
|
||||||
Submodule
+1
Submodule freeink-sdk added at 329a4bebef
@@ -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 {
|
int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const {
|
||||||
|
if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (!data->kernMatrix) {
|
if (!data->kernMatrix) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-41
@@ -115,6 +115,9 @@ void SdCardFont::freeStyleAll(PerStyle& s) {
|
|||||||
freeStyleMiniData(s);
|
freeStyleMiniData(s);
|
||||||
delete[] s.fullIntervals;
|
delete[] s.fullIntervals;
|
||||||
s.fullIntervals = nullptr;
|
s.fullIntervals = nullptr;
|
||||||
|
delete[] s.bmpIntervals;
|
||||||
|
s.bmpIntervals = nullptr;
|
||||||
|
s.intervalsAreBmp16 = false;
|
||||||
freeStyleKernLigatureData(s);
|
freeStyleKernLigatureData(s);
|
||||||
s.present = false;
|
s.present = false;
|
||||||
}
|
}
|
||||||
@@ -516,59 +519,94 @@ bool SdCardFont::load(const char* path) {
|
|||||||
styleCount_ = styleCount;
|
styleCount_ = styleCount;
|
||||||
contentHash_ = hash;
|
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++) {
|
for (uint8_t i = 0; i < MAX_STYLES; i++) {
|
||||||
auto& s = styles_[i];
|
auto& s = styles_[i];
|
||||||
if (!s.present) continue;
|
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)) {
|
if (!file.seekSet(s.intervalsFileOffset)) {
|
||||||
LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i);
|
LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i);
|
||||||
freeAll();
|
freeAll();
|
||||||
return false;
|
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,
|
// Validate interval contents before any later code (findGlobalGlyphIndex,
|
||||||
// glyph reads) trusts them. A malformed file could otherwise drive
|
// glyph reads) trusts them. A malformed file could otherwise drive
|
||||||
// out-of-range glyph indices into bogus on-disk reads.
|
// out-of-range glyph indices into bogus on-disk reads.
|
||||||
{
|
bool canUseBmp16 = s.header.glyphCount <= UINT16_MAX;
|
||||||
uint32_t expectedOffset = 0;
|
uint32_t expectedOffset = 0;
|
||||||
uint32_t prevLast = 0;
|
uint32_t prevLast = 0;
|
||||||
|
EpdUnicodeInterval iv{};
|
||||||
|
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 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));
|
||||||
|
file.close();
|
||||||
|
freeAll();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const uint32_t span = iv.last - iv.first + 1;
|
||||||
|
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
|
||||||
|
const bool spanTooBig = (span > s.header.glyphCount);
|
||||||
|
const bool offsetMismatch = (iv.offset != expectedOffset);
|
||||||
|
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
|
||||||
|
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
|
||||||
|
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
|
||||||
|
overlapsPrev, span, offsetMismatch, offsetOverruns);
|
||||||
|
file.close();
|
||||||
|
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) {
|
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)) {
|
||||||
if (iv.first > iv.last) {
|
LOG_ERR("SDCF", "Failed to read compact interval %u for style %u", j, i);
|
||||||
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));
|
|
||||||
file.close();
|
|
||||||
freeAll();
|
freeAll();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const uint32_t span = iv.last - iv.first + 1;
|
s.bmpIntervals[j] = {static_cast<uint16_t>(iv.first), static_cast<uint16_t>(iv.last),
|
||||||
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
|
static_cast<uint16_t>(iv.offset)};
|
||||||
const bool spanTooBig = (span > s.header.glyphCount);
|
}
|
||||||
const bool offsetMismatch = (iv.offset != expectedOffset);
|
s.intervalsAreBmp16 = true;
|
||||||
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
|
} else {
|
||||||
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
|
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
|
||||||
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
|
if (!s.fullIntervals) {
|
||||||
overlapsPrev, span, offsetMismatch, offsetOverruns);
|
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
|
||||||
file.close();
|
freeAll();
|
||||||
freeAll();
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
|
||||||
expectedOffset += span;
|
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
|
||||||
prevLast = iv.last;
|
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
|
||||||
|
freeAll();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,13 +641,15 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint)
|
|||||||
int right = static_cast<int>(s.header.intervalCount) - 1;
|
int right = static_cast<int>(s.header.intervalCount) - 1;
|
||||||
while (left <= right) {
|
while (left <= right) {
|
||||||
int mid = left + (right - left) / 2;
|
int mid = left + (right - left) / 2;
|
||||||
const auto& interval = s.fullIntervals[mid];
|
const uint32_t first = s.intervalsAreBmp16 ? s.bmpIntervals[mid].first : s.fullIntervals[mid].first;
|
||||||
if (codepoint < interval.first) {
|
const uint32_t last = s.intervalsAreBmp16 ? s.bmpIntervals[mid].last : s.fullIntervals[mid].last;
|
||||||
|
if (codepoint < first) {
|
||||||
right = mid - 1;
|
right = mid - 1;
|
||||||
} else if (codepoint > interval.last) {
|
} else if (codepoint > last) {
|
||||||
left = mid + 1;
|
left = mid + 1;
|
||||||
} else {
|
} 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;
|
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;
|
if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr;
|
||||||
const auto& s = self->styles_[styleIdx];
|
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)
|
// Check overflow cache first (matching both codepoint and style)
|
||||||
for (uint32_t i = 0; i < self->overflowCount_; i++) {
|
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.
|
// Returns true if advance table is populated for at least one style.
|
||||||
bool hasAdvanceTable() const;
|
bool hasAdvanceTable() const;
|
||||||
|
|
||||||
// Free mini data for all styles, restore stub EpdFontData.
|
// Free mini data for all styles and restore stub EpdFontData.
|
||||||
// Also clears the temporary advance table (built per layout pass) but
|
// Preserves the persistent advance cache so repeated layout passes can reuse
|
||||||
// preserves the persistent advance cache (reused across passes).
|
// previously fetched metrics.
|
||||||
void clearCache();
|
void clearCache();
|
||||||
|
|
||||||
// Drop the persistent advance cache. Call when unloading the SD font or
|
// 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)
|
// Full intervals loaded from file (kept in RAM for codepoint lookup)
|
||||||
EpdUnicodeInterval* fullIntervals = nullptr;
|
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).
|
// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
|
||||||
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
|
// 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);
|
unloadAll(renderer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select by ordinal position: sort available sizes, then map the font size
|
// Select the physical point size closest to the built-in reader sizes. Some
|
||||||
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the
|
// CJK font packs only ship larger sizes, so ordinal selection can make
|
||||||
// family has fewer sizes than 4, clamp to the last available size.
|
// MEDIUM load 18pt+ and produce oversized pages on small devices.
|
||||||
auto sizes = family.availableSizes();
|
const SdCardFontFileInfo* selected = family.findClosestReaderSize(fontSizeEnum);
|
||||||
if (sizes.empty()) {
|
if (!selected) {
|
||||||
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
|
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
|
||||||
return false;
|
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();
|
auto* font = new (std::nothrow) SdCardFont();
|
||||||
if (!font) {
|
if (!font) {
|
||||||
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str());
|
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str());
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ class SdCardFontManager {
|
|||||||
SdCardFontManager(const SdCardFontManager&) = delete;
|
SdCardFontManager(const SdCardFontManager&) = delete;
|
||||||
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
|
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
|
||||||
|
|
||||||
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by
|
// Load the font file whose physical point size is closest to the reader
|
||||||
// ordinal position in the family's sorted size list. Only one .cpfont file
|
// fontSizeEnum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18). Only one
|
||||||
// is loaded; other sizes remain on disk. This keeps resident interval +
|
// .cpfont file is loaded; other sizes remain on disk. This keeps resident
|
||||||
// kern/ligature tables to one size's worth of memory.
|
// interval + kern/ligature tables to one size's worth of memory.
|
||||||
// Returns true on success.
|
// Returns true on success.
|
||||||
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
|
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).
|
// Get name of currently loaded family (empty if none).
|
||||||
const std::string& currentFamilyName() const { return loadedFamilyName_; };
|
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.
|
// 0 if nothing loaded.
|
||||||
uint8_t currentPointSize() const { return loadedPointSize_; };
|
uint8_t currentPointSize() const { return loadedPointSize_; };
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,60 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t s
|
|||||||
return nullptr;
|
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 {
|
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
|
||||||
for (const auto& f : files) {
|
for (const auto& f : files) {
|
||||||
if (f.pointSize == size) return true;
|
if (f.pointSize == size) return true;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct SdCardFontFamilyInfo {
|
|||||||
std::vector<SdCardFontFileInfo> files;
|
std::vector<SdCardFontFileInfo> files;
|
||||||
|
|
||||||
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
|
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;
|
bool hasSize(uint8_t size) const;
|
||||||
std::vector<uint8_t> availableSizes() const;
|
std::vector<uint8_t> availableSizes() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ unmerged_intervals = sorted(intervals + add_ints)
|
|||||||
intervals = []
|
intervals = []
|
||||||
unvalidated_intervals = []
|
unvalidated_intervals = []
|
||||||
for i_start, i_end in unmerged_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))
|
unvalidated_intervals[-1] = (unvalidated_intervals[-1][0], max(unvalidated_intervals[-1][1], i_end))
|
||||||
continue
|
continue
|
||||||
unvalidated_intervals.append((i_start, i_end))
|
unvalidated_intervals.append((i_start, i_end))
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ INTERVAL_PRESETS = {
|
|||||||
"ascii": [(0x0020, 0x007E)],
|
"ascii": [(0x0020, 0x007E)],
|
||||||
"latin1": [(0x0080, 0x00FF)],
|
"latin1": [(0x0080, 0x00FF)],
|
||||||
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
|
"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)],
|
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
|
||||||
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
|
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
|
||||||
"hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)],
|
"hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)],
|
||||||
@@ -62,7 +63,7 @@ INTERVAL_PRESETS = {
|
|||||||
# Composite preset for English-language literary fiction including scifi/popsci.
|
# Composite preset for English-language literary fiction including scifi/popsci.
|
||||||
# Greek for physics terms, math operators, geometric shapes, uncommon
|
# Greek for physics terms, math operators, geometric shapes, uncommon
|
||||||
# dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats.
|
# 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),
|
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||||
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
||||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||||
|
|||||||
+4
-2
@@ -5,6 +5,7 @@
|
|||||||
#include <JpegToBmpConverter.h>
|
#include <JpegToBmpConverter.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <PngToBmpConverter.h>
|
#include <PngToBmpConverter.h>
|
||||||
|
#include <Utf8.h>
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
@@ -73,8 +74,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab data from opfParser into epub
|
// Grab data from opfParser into epub. Normalize titles to NFC so NFD (combining
|
||||||
bookMetadata.title = opfParser.title;
|
// mark) text renders correctly — the device fonts have no mark positioning.
|
||||||
|
bookMetadata.title = utf8ComposeNfc(opfParser.title);
|
||||||
bookMetadata.author = opfParser.author;
|
bookMetadata.author = opfParser.author;
|
||||||
bookMetadata.language = opfParser.language;
|
bookMetadata.language = opfParser.language;
|
||||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
#include <Utf8.h>
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
#include "FsHelpers.h"
|
#include "FsHelpers.h"
|
||||||
|
|
||||||
namespace {
|
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 bookBinFile[] = "/book.bin";
|
||||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||||
constexpr char tmpTocBinFile[] = "/toc.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);
|
writeTocEntry(tocFile, entry);
|
||||||
tocCount++;
|
tocCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -6,6 +6,20 @@
|
|||||||
|
|
||||||
#include <new>
|
#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) {
|
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||||
block->render(renderer, fontId, xPos + xOffset, yPos + 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 {
|
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||||
for (auto& element : elements) {
|
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; });
|
||||||
element->render(renderer, fontId, xOffset, yOffset);
|
}
|
||||||
}
|
|
||||||
|
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 {
|
bool Page::serialize(HalFile& file) const {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
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;
|
bool serialize(HalFile& file) const;
|
||||||
static std::unique_ptr<Page> deserialize(HalFile& file);
|
static std::unique_ptr<Page> deserialize(HalFile& file);
|
||||||
|
|
||||||
|
|||||||
+259
-35
@@ -24,6 +24,7 @@ constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3;
|
|||||||
// Per-word: scan enough chars to see through leading neutrals (quotes, numbers)
|
// 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.
|
// before giving up. 64 is a hedge for pathological cases like long numeric tokens.
|
||||||
constexpr int RTL_PER_WORD_PROBE_DEPTH = 64;
|
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.
|
// Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB.
|
||||||
bool mayContainRtlBytes(const char* str) {
|
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 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.
|
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
|
||||||
void stripSoftHyphensInPlace(std::string& word) {
|
void stripSoftHyphensInPlace(std::string& word) {
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
@@ -125,6 +254,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
const bool attachToPrevious) {
|
const bool attachToPrevious) {
|
||||||
if (word.empty()) return;
|
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;
|
EpdFontFamily::Style baseStyle = fontStyle;
|
||||||
if (underline) {
|
if (underline) {
|
||||||
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::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()) &&
|
const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) &&
|
||||||
BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH);
|
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.
|
// Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
|
||||||
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
|
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
|
||||||
words.push_back(std::move(word));
|
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
|
||||||
wordStyles.push_back(baseStyle);
|
|
||||||
wordContinues.push_back(attachToPrevious);
|
|
||||||
wordIsFocusSuffix.push_back(false);
|
|
||||||
if (wordStartsRtl) {
|
if (wordStartsRtl) {
|
||||||
hasRtlWord = true;
|
hasRtlWord = true;
|
||||||
}
|
}
|
||||||
@@ -166,17 +345,19 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
words.reserve(newCapacity);
|
words.reserve(newCapacity);
|
||||||
wordStyles.reserve(newCapacity);
|
wordStyles.reserve(newCapacity);
|
||||||
wordContinues.reserve(newCapacity);
|
wordContinues.reserve(newCapacity);
|
||||||
|
wordNoSpaceBefore.reserve(newCapacity);
|
||||||
wordIsFocusSuffix.reserve(newCapacity);
|
wordIsFocusSuffix.reserve(newCapacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lambda helper to process and push individual sub-segments of the string
|
// Lambda helper to process and push individual sub-segments of the string
|
||||||
// Use std::string_view to avoid heap allocations when slicing
|
// 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) {
|
if (!isWord) {
|
||||||
// Punctuation and Numbers stay regular
|
// Punctuation and Numbers stay regular
|
||||||
words.emplace_back(segment);
|
words.emplace_back(segment);
|
||||||
wordStyles.push_back(baseStyle);
|
wordStyles.push_back(baseStyle);
|
||||||
wordContinues.push_back(attach);
|
wordContinues.push_back(attach);
|
||||||
|
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||||
wordIsFocusSuffix.push_back(false);
|
wordIsFocusSuffix.push_back(false);
|
||||||
} else {
|
} else {
|
||||||
size_t charCount = 0;
|
size_t charCount = 0;
|
||||||
@@ -198,6 +379,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
words.emplace_back(segment);
|
words.emplace_back(segment);
|
||||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||||
wordContinues.push_back(attach);
|
wordContinues.push_back(attach);
|
||||||
|
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||||
wordIsFocusSuffix.push_back(false);
|
wordIsFocusSuffix.push_back(false);
|
||||||
} else {
|
} else {
|
||||||
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
|
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));
|
words.emplace_back(segment.substr(0, splitByteOffset));
|
||||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||||
wordContinues.push_back(attach);
|
wordContinues.push_back(attach);
|
||||||
|
wordNoSpaceBefore.push_back(noSpaceBefore);
|
||||||
wordIsFocusSuffix.push_back(false);
|
wordIsFocusSuffix.push_back(false);
|
||||||
|
|
||||||
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
|
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
|
||||||
words.emplace_back(segment.substr(splitByteOffset));
|
words.emplace_back(segment.substr(splitByteOffset));
|
||||||
wordStyles.push_back(baseStyle);
|
wordStyles.push_back(baseStyle);
|
||||||
wordContinues.push_back(true);
|
wordContinues.push_back(true);
|
||||||
|
wordNoSpaceBefore.push_back(false);
|
||||||
wordIsFocusSuffix.push_back(true);
|
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.
|
// Only the very first segment inherits the original attachToPrevious flag.
|
||||||
// Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
|
// 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
|
// Setup for the next segment
|
||||||
segmentStart = currentCpStart;
|
segmentStart = currentCpStart;
|
||||||
@@ -255,7 +440,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
// Process the final remaining segment
|
// Process the final remaining segment
|
||||||
size_t segmentLen = end - segmentStart;
|
size_t segmentLen = end - segmentStart;
|
||||||
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
|
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) {
|
if (wordStartsRtl) {
|
||||||
hasRtlWord = true;
|
hasRtlWord = true;
|
||||||
}
|
}
|
||||||
@@ -324,14 +510,16 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
if (hyphenationEnabled) {
|
if (hyphenationEnabled) {
|
||||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
// 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 {
|
} 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;
|
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||||
|
|
||||||
for (size_t i = 0; i < lineCount; ++i) {
|
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
|
// 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);
|
words.erase(words.begin(), words.begin() + consumed);
|
||||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
||||||
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
||||||
|
wordNoSpaceBefore.erase(wordNoSpaceBefore.begin(), wordNoSpaceBefore.begin() + consumed);
|
||||||
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.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<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()) {
|
if (words.empty()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -395,7 +585,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
|||||||
for (size_t j = i; j < totalWordCount; ++j) {
|
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
|
// Add space before word j, unless it's the first word on the line or a continuation
|
||||||
int gap = 0;
|
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 =
|
gap =
|
||||||
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
|
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
|
||||||
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
|
} 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.
|
// 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,
|
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||||
const int pageWidth, std::vector<uint16_t>& wordWidths,
|
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);
|
const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId);
|
||||||
|
|
||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
@@ -488,7 +681,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
while (currentIndex < wordWidths.size()) {
|
while (currentIndex < wordWidths.size()) {
|
||||||
const bool isFirstWord = currentIndex == lineStart;
|
const bool isFirstWord = currentIndex == lineStart;
|
||||||
int spacing = 0;
|
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]),
|
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
|
||||||
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
|
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
|
||||||
} else if (!isFirstWord && continuesVec[currentIndex]) {
|
} 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.
|
// line, while "kilometer" moves to the next line.
|
||||||
// wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment.
|
// wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment.
|
||||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
||||||
|
wordNoSpaceBefore.insert(wordNoSpaceBefore.begin() + wordIndex + 1, false);
|
||||||
|
|
||||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
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,
|
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 std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||||
const GfxRenderer& renderer, const int fontId) {
|
const GfxRenderer& renderer, const int fontId) {
|
||||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
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++) {
|
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||||
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
||||||
// Count gaps: each word after the first creates a gap, unless it's a continuation
|
// 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++;
|
actualGapCount++;
|
||||||
totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]),
|
totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]),
|
||||||
firstCodepoint(lineWords[wordIdx]), lineWordStyles[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
|
// For justified text, compute per-gap extra to distribute remaining space evenly
|
||||||
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||||
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1)
|
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||||
? spareSpace / static_cast<int>(actualGapCount)
|
? computeJustifyExtra(spareSpace, actualGapCount)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// BiDi processing: reorder words with UAX#9 in full-line context.
|
// 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();
|
reorderedStylesScratch.clear();
|
||||||
reorderedWidthsScratch.clear();
|
reorderedWidthsScratch.clear();
|
||||||
reorderedContinuesScratch.clear();
|
reorderedContinuesScratch.clear();
|
||||||
|
reorderedNoSpaceBeforeScratch.clear();
|
||||||
reorderedFocusSuffixScratch.clear();
|
reorderedFocusSuffixScratch.clear();
|
||||||
reorderedWordsScratch.reserve(visualOrderScratch.size());
|
reorderedWordsScratch.reserve(visualOrderScratch.size());
|
||||||
reorderedStylesScratch.reserve(visualOrderScratch.size());
|
reorderedStylesScratch.reserve(visualOrderScratch.size());
|
||||||
reorderedWidthsScratch.reserve(visualOrderScratch.size());
|
reorderedWidthsScratch.reserve(visualOrderScratch.size());
|
||||||
reorderedContinuesScratch.reserve(visualOrderScratch.size());
|
reorderedContinuesScratch.reserve(visualOrderScratch.size());
|
||||||
|
reorderedNoSpaceBeforeScratch.reserve(visualOrderScratch.size());
|
||||||
reorderedFocusSuffixScratch.reserve(visualOrderScratch.size());
|
reorderedFocusSuffixScratch.reserve(visualOrderScratch.size());
|
||||||
|
|
||||||
for (size_t i = 0; i < visualOrderScratch.size(); ++i) {
|
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);
|
reorderedContinuesScratch.push_back(continues);
|
||||||
|
reorderedNoSpaceBeforeScratch.push_back(!continues && noSpaceBeforeVec[lastBreakAt + src]);
|
||||||
}
|
}
|
||||||
|
|
||||||
int reorderedWordWidthSum = 0;
|
int reorderedWordWidthSum = 0;
|
||||||
@@ -747,7 +951,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
int reorderedNaturalGaps = 0;
|
int reorderedNaturalGaps = 0;
|
||||||
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
|
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
|
||||||
reorderedWordWidthSum += reorderedWidthsScratch[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++;
|
reorderedGapCount++;
|
||||||
reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
|
reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
|
||||||
firstCodepoint(reorderedWordsScratch[wordIdx]),
|
firstCodepoint(reorderedWordsScratch[wordIdx]),
|
||||||
@@ -763,10 +971,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
|
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
|
||||||
const int reorderedJustifyExtra =
|
const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||||
(effectiveAlignment == CssTextAlign::Justify && !isLastLine && reorderedGapCount >= 1)
|
? computeJustifyExtra(reorderedSpare, reorderedGapCount)
|
||||||
? reorderedSpare / static_cast<int>(reorderedGapCount)
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||||
? reorderedJustifyExtra * static_cast<int>(reorderedGapCount)
|
? reorderedJustifyExtra * static_cast<int>(reorderedGapCount)
|
||||||
@@ -799,15 +1006,20 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
int advance =
|
int advance =
|
||||||
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
||||||
firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[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) {
|
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||||
advance += reorderedJustifyExtra;
|
advance += reorderedJustifyExtra;
|
||||||
}
|
}
|
||||||
xpos += advance;
|
xpos += advance;
|
||||||
} else if (wordIdx + 1 < reorderedWidthsScratch.size()) {
|
} else if (wordIdx + 1 < reorderedWidthsScratch.size()) {
|
||||||
int gap = renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1];
|
||||||
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
|
int gap = nextNoSpace ? 0
|
||||||
reorderedStylesScratch[wordIdx]);
|
: renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
|
||||||
|
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
|
||||||
|
reorderedStylesScratch[wordIdx]);
|
||||||
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||||
gap += reorderedJustifyExtra;
|
gap += reorderedJustifyExtra;
|
||||||
}
|
}
|
||||||
@@ -839,18 +1051,23 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
// Cross-boundary kerning for continuation words
|
// Cross-boundary kerning for continuation words
|
||||||
int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[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) {
|
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||||
advance += justifyExtra;
|
advance += justifyExtra;
|
||||||
}
|
}
|
||||||
xpos -= advance;
|
xpos -= advance;
|
||||||
} else {
|
} else {
|
||||||
int gap = 0;
|
int gap = 0;
|
||||||
|
bool nextNoSpace = false;
|
||||||
if (wordIdx + 1 < lineWordCount) {
|
if (wordIdx + 1 < lineWordCount) {
|
||||||
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
|
||||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
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;
|
gap += justifyExtra;
|
||||||
}
|
}
|
||||||
xpos -= gap;
|
xpos -= gap;
|
||||||
@@ -873,18 +1090,25 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
int advance = wordWidths[lastBreakAt + wordIdx];
|
int advance = wordWidths[lastBreakAt + wordIdx];
|
||||||
advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
|
||||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[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) {
|
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
|
||||||
advance += justifyExtra;
|
advance += justifyExtra;
|
||||||
}
|
}
|
||||||
xpos += advance;
|
xpos += advance;
|
||||||
} else {
|
} else {
|
||||||
int gap = 0;
|
int gap = 0;
|
||||||
|
bool nextNoSpace = false;
|
||||||
if (wordIdx + 1 < lineWordCount) {
|
if (wordIdx + 1 < lineWordCount) {
|
||||||
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
|
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
|
||||||
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
|
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;
|
gap += justifyExtra;
|
||||||
}
|
}
|
||||||
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
|
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class GfxRenderer;
|
|||||||
class ParsedText {
|
class ParsedText {
|
||||||
std::vector<std::string> words;
|
std::vector<std::string> words;
|
||||||
std::vector<EpdFontFamily::Style> wordStyles;
|
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
|
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
bool extraParagraphSpacing;
|
bool extraParagraphSpacing;
|
||||||
@@ -27,18 +28,22 @@ class ParsedText {
|
|||||||
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
|
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
|
||||||
std::vector<uint16_t> reorderedWidthsScratch;
|
std::vector<uint16_t> reorderedWidthsScratch;
|
||||||
std::vector<bool> reorderedContinuesScratch;
|
std::vector<bool> reorderedContinuesScratch;
|
||||||
|
std::vector<bool> reorderedNoSpaceBeforeScratch;
|
||||||
std::vector<bool> reorderedFocusSuffixScratch;
|
std::vector<bool> reorderedFocusSuffixScratch;
|
||||||
std::vector<uint16_t> visualOrderScratch;
|
std::vector<uint16_t> visualOrderScratch;
|
||||||
|
|
||||||
int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const;
|
int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const;
|
||||||
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
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<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,
|
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||||
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths,
|
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,
|
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
|
||||||
int fontId);
|
int fontId);
|
||||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 26;
|
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
|
||||||
|
constexpr uint8_t SECTION_FILE_VERSION = 27;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||||
|
|||||||
@@ -13,54 +13,57 @@ struct EntityPair {
|
|||||||
|
|
||||||
// Sorted lexicographically by key to allow binary search.
|
// Sorted lexicographically by key to allow binary search.
|
||||||
static constexpr EntityPair ENTITY_LOOKUP[] = {
|
static constexpr EntityPair ENTITY_LOOKUP[] = {
|
||||||
{"Æ", "Æ"}, {"Á", "Á"}, {"Â", "Â"}, {"À", "À"}, {"Α", "Α"},
|
{"Æ", "Æ"}, {"Á", "Á"}, {"Â", "Â"}, {"À", "À"}, {"Α", "Α"},
|
||||||
{"Å", "Å"}, {"Ã", "Ã"}, {"Ä", "Ä"}, {"Β", "Β"}, {"Ç", "Ç"},
|
{"Å", "Å"}, {"Ã", "Ã"}, {"Ä", "Ä"}, {"Β", "Β"}, {"Ç", "Ç"},
|
||||||
{"Χ", "Χ"}, {"‡", "‡"}, {"Δ", "Δ"}, {"Ð", "Ð"}, {"É", "É"},
|
{"Χ", "Χ"}, {"‡", "‡"}, {"Δ", "Δ"}, {"Ð", "Ð"}, {"É", "É"},
|
||||||
{"Ê", "Ê"}, {"È", "È"}, {"Ε", "Ε"}, {"Η", "Η"}, {"Ë", "Ë"},
|
{"Ê", "Ê"}, {"È", "È"}, {"Ε", "Ε"}, {"Η", "Η"}, {"Ë", "Ë"},
|
||||||
{"Γ", "Γ"}, {"Í", "Í"}, {"Î", "Î"}, {"Ì", "Ì"}, {"Ι", "Ι"},
|
{"Γ", "Γ"}, {"Í", "Í"}, {"Î", "Î"}, {"Ì", "Ì"}, {"Ι", "Ι"},
|
||||||
{"Ï", "Ï"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, {"Ñ", "Ñ"},
|
{"Ï", "Ï"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, {"Ñ", "Ñ"},
|
||||||
{"Ν", "Ν"}, {"Œ", "Œ"}, {"Ó", "Ó"}, {"Ô", "Ô"}, {"Ò", "Ò"},
|
{"Ν", "Ν"}, {"Œ", "Œ"}, {"Ó", "Ó"}, {"Ô", "Ô"}, {"Ò", "Ò"},
|
||||||
{"Ω", "Ω"}, {"Ο", "Ο"}, {"Ø", "Ø"}, {"Õ", "Õ"}, {"Ö", "Ö"},
|
{"Ω", "Ω"}, {"Ο", "Ο"}, {"Ø", "Ø"}, {"Õ", "Õ"}, {"Ö", "Ö"},
|
||||||
{"Φ", "Φ"}, {"Π", "Π"}, {"″", "″"}, {"Ψ", "Ψ"}, {"Ρ", "Ρ"},
|
{"Φ", "Φ"}, {"Π", "Π"}, {"″", "″"}, {"Ψ", "Ψ"}, {"Ρ", "Ρ"},
|
||||||
{"Š", "Š"}, {"Σ", "Σ"}, {"Þ", "Þ"}, {"Τ", "Τ"}, {"Θ", "Θ"},
|
{"Š", "Š"}, {"Σ", "Σ"}, {"Þ", "Þ"}, {"Τ", "Τ"}, {"Θ", "Θ"},
|
||||||
{"Ú", "Ú"}, {"Û", "Û"}, {"Ù", "Ù"}, {"Υ", "Υ"}, {"Ü", "Ü"},
|
{"Ú", "Ú"}, {"Û", "Û"}, {"Ù", "Ù"}, {"Υ", "Υ"}, {"Ü", "Ü"},
|
||||||
{"Ξ", "Ξ"}, {"Ý", "Ý"}, {"Ÿ", "Ÿ"}, {"Ζ", "Ζ"}, {"á", "á"},
|
{"Ξ", "Ξ"}, {"Ý", "Ý"}, {"Ÿ", "Ÿ"}, {"Ζ", "Ζ"}, {"á", "á"},
|
||||||
{"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"α", "α"},
|
{"â", "â"}, {"´", "´"}, {"æ", "æ"}, {"à", "à"}, {"ℵ", "ℵ"},
|
||||||
{"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"}, {"≈", "≈"},
|
{"α", "α"}, {"&", "&"}, {"∧", "∧"}, {"∠", "∠"}, {"å", "å"},
|
||||||
{"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"}, {"¦", "¦"},
|
{"≈", "≈"}, {"ã", "ã"}, {"ä", "ä"}, {"„", "„"}, {"β", "β"},
|
||||||
{"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"}, {"¢", "¢"},
|
{"¦", "¦"}, {"•", "•"}, {"∩", "∩"}, {"ç", "ç"}, {"¸", "¸"},
|
||||||
{"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"}, {"©", "©"},
|
{"¢", "¢"}, {"χ", "χ"}, {"ˆ", "ˆ"}, {"♣", "♣"}, {"≅", "≅"},
|
||||||
{"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"†", "†"}, {"↓", "↓"},
|
{"©", "©"}, {"↵", "↵"}, {"∪", "∪"}, {"¤", "¤"}, {"⇓", "⇓"},
|
||||||
{"°", "°"}, {"δ", "δ"}, {"♦", "♦"}, {"÷", "÷"}, {"é", "é"},
|
{"†", "†"}, {"↓", "↓"}, {"°", "°"}, {"δ", "δ"}, {"♦", "♦"},
|
||||||
{"ê", "ê"}, {"è", "è"}, {"∅", "∅"}, {" ", " "}, {" ", " "},
|
{"÷", "÷"}, {"é", "é"}, {"ê", "ê"}, {"è", "è"}, {"∅", "∅"},
|
||||||
{"ε", "ε"}, {"≡", "≡"}, {"η", "η"}, {"ð", "ð"}, {"ë", "ë"},
|
{" ", " "}, {" ", " "}, {"ε", "ε"}, {"≡", "≡"}, {"η", "η"},
|
||||||
{"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"}, {"∀", "∀"}, {"½", "½"},
|
{"ð", "ð"}, {"ë", "ë"}, {"€", "€"}, {"∃", "∃"}, {"ƒ", "ƒ"},
|
||||||
{"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"}, {"γ", "γ"}, {"≥", "≥"},
|
{"∀", "∀"}, {"½", "½"}, {"¼", "¼"}, {"¾", "¾"}, {"⁄", "⁄"},
|
||||||
{">", ">"}, {"↔", "↔"}, {"♥", "♥"}, {"…", "…"}, {"í", "í"},
|
{"γ", "γ"}, {"≥", "≥"}, {">", ">"}, {"⇔", "⇔"}, {"↔", "↔"},
|
||||||
{"î", "î"}, {"¡", "¡"}, {"ì", "ì"}, {"∞", "∞"}, {"∫", "∫"},
|
{"♥", "♥"}, {"…", "…"}, {"í", "í"}, {"î", "î"}, {"¡", "¡"},
|
||||||
{"ι", "ι"}, {"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"},
|
{"ì", "ì"}, {"ℑ", "ℑ"}, {"∞", "∞"}, {"∫", "∫"}, {"ι", "ι"},
|
||||||
{"λ", "λ"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"}, {"“", "\u201C"},
|
{"¿", "¿"}, {"∈", "∈"}, {"ï", "ï"}, {"κ", "κ"}, {"⇐", "⇐"},
|
||||||
{"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"}, {"‎", "\u200E"},
|
{"λ", "λ"}, {"⟨", "〈"}, {"«", "«"}, {"←", "←"}, {"⌈", "⌈"},
|
||||||
{"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"}, {"—", "—"},
|
{"“", "\u201C"}, {"≤", "≤"}, {"⌊", "⌊"}, {"∗", "∗"}, {"◊", "◊"},
|
||||||
{"µ", "µ"}, {"−", "−"}, {"μ", "μ"}, {"∇", "∇"}, {" ", "\xC2\xA0"},
|
{"‎", "\u200E"}, {"‹", "‹"}, {"‘", "\u2018"}, {"<", "<"}, {"¯", "¯"},
|
||||||
{"–", "–"}, {"≠", "≠"}, {"∋", "∋"}, {"¬", "¬"}, {"∉", "∉"},
|
{"—", "—"}, {"µ", "µ"}, {"·", "·"}, {"−", "−"}, {"μ", "μ"},
|
||||||
{"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"}, {"ó", "ó"}, {"ô", "ô"},
|
{"∇", "∇"}, {" ", "\xC2\xA0"}, {"–", "–"}, {"≠", "≠"}, {"∋", "∋"},
|
||||||
{"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"}, {"ω", "ω"}, {"ο", "ο"},
|
{"¬", "¬"}, {"∉", "∉"}, {"⊄", "⊄"}, {"ñ", "ñ"}, {"ν", "ν"},
|
||||||
{"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"}, {"º", "º"}, {"ø", "ø"},
|
{"ó", "ó"}, {"ô", "ô"}, {"œ", "œ"}, {"ò", "ò"}, {"‾", "‾"},
|
||||||
{"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"}, {"¶", "¶"}, {"∂", "∂"},
|
{"ω", "ω"}, {"ο", "ο"}, {"⊕", "⊕"}, {"∨", "∨"}, {"ª", "ª"},
|
||||||
{"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"}, {"π", "π"}, {"ϖ", "ϖ"},
|
{"º", "º"}, {"ø", "ø"}, {"õ", "õ"}, {"⊗", "⊗"}, {"ö", "ö"},
|
||||||
{"±", "±"}, {"£", "£"}, {"′", "′"}, {"∏", "∏"}, {"∝", "∝"},
|
{"¶", "¶"}, {"∂", "∂"}, {"‰", "‰"}, {"⊥", "⊥"}, {"φ", "φ"},
|
||||||
{"ψ", "ψ"}, {""", "\""}, {"√", "√"}, {"»", "»"}, {"→", "→"},
|
{"π", "π"}, {"ϖ", "ϖ"}, {"±", "±"}, {"£", "£"}, {"′", "′"},
|
||||||
{"⌉", "⌉"}, {"”", "\u201D"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"},
|
{"∏", "∏"}, {"∝", "∝"}, {"ψ", "ψ"}, {""", "\""}, {"⇒", "⇒"},
|
||||||
{"‏", "\u200F"}, {"›", "›"}, {"’", "\u2019"}, {"‚", "‚"}, {"š", "š"},
|
{"√", "√"}, {"⟩", "〉"}, {"»", "»"}, {"→", "→"}, {"⌉", "⌉"},
|
||||||
{"⋅", "⋅"}, {"§", "§"}, {"­", "\xC2\xAD"}, {"σ", "σ"}, {"ς", "ς"},
|
{"”", "\u201D"}, {"ℜ", "\u211C"}, {"®", "®"}, {"⌋", "⌋"}, {"ρ", "ρ"},
|
||||||
{"∼", "∼"}, {"♠", "♠"}, {"⊂", "⊂"}, {"⊆", "⊆"}, {"∑", "∑"},
|
{"‏", "\u200F"}, {"›", "›"}, {"’", "\u2019"}, {"‚", "‚"}, {"š", "š"},
|
||||||
{"¹", "¹"}, {"²", "²"}, {"³", "³"}, {"⊃", "⊃"}, {"⊇", "⊇"},
|
{"⋅", "⋅"}, {"§", "§"}, {"­", "\xC2\xAD"}, {"σ", "σ"}, {"ς", "ς"},
|
||||||
{"ß", "ß"}, {"τ", "τ"}, {"∴", "∴"}, {"θ", "θ"}, {"ϑ", "ϑ"},
|
{"∼", "∼"}, {"♠", "♠"}, {"⊂", "⊂"}, {"⊆", "⊆"}, {"∑", "∑"},
|
||||||
{" ", " "}, {"þ", "þ"}, {"˜", "˜"}, {"×", "×"}, {"™", "™"},
|
{"¹", "¹"}, {"²", "²"}, {"³", "³"}, {"⊃", "⊃"}, {"⊇", "⊇"},
|
||||||
{"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"}, {"¨", "¨"},
|
{"ß", "ß"}, {"τ", "τ"}, {"∴", "∴"}, {"θ", "θ"}, {"ϑ", "ϑ"},
|
||||||
{"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"ξ", "ξ"}, {"ý", "ý"},
|
{" ", " "}, {"þ", "þ"}, {"˜", "˜"}, {"×", "×"}, {"™", "™"},
|
||||||
{"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"}, {"‍", "\u200D"}, {"‌", "\u200C"},
|
{"⇑", "⇑"}, {"ú", "ú"}, {"↑", "↑"}, {"û", "û"}, {"ù", "ù"},
|
||||||
|
{"¨", "¨"}, {"ϒ", "ϒ"}, {"υ", "υ"}, {"ü", "ü"}, {"℘", "℘"},
|
||||||
|
{"ξ", "ξ"}, {"ý", "ý"}, {"¥", "¥"}, {"ÿ", "ÿ"}, {"ζ", "ζ"},
|
||||||
|
{"‍", "\u200D"}, {"‌", "\u200C"},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify the table is sorted at compile time.
|
// Verify the table is sorted at compile time.
|
||||||
|
|||||||
@@ -317,6 +317,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
const bool isTocAnchor =
|
const bool isTocAnchor =
|
||||||
std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
|
std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
|
||||||
if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) {
|
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;
|
self->pendingAnchorId = idValue;
|
||||||
}
|
}
|
||||||
} else if (strcmp(atts[i], "dir") == 0) {
|
} else if (strcmp(atts[i], "dir") == 0) {
|
||||||
|
|||||||
+22
-14
@@ -3,6 +3,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace FsHelpers {
|
namespace FsHelpers {
|
||||||
@@ -36,12 +37,14 @@ std::string decodeUriEscapes(const std::string& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string normalisePath(const std::string& path) {
|
std::string normalisePath(const std::string& path) {
|
||||||
std::vector<std::string> components;
|
std::vector<std::string_view> components;
|
||||||
std::string component;
|
components.reserve(8); // Eight nested folders is more than we might expect
|
||||||
|
|
||||||
for (const auto c : path) {
|
size_t start = 0;
|
||||||
if (c == '/') {
|
for (size_t i = 0; i <= path.length(); ++i) {
|
||||||
if (!component.empty()) {
|
if (i == path.length() || path[i] == '/') {
|
||||||
|
if (i > start) {
|
||||||
|
std::string_view component(path.data() + start, i - start);
|
||||||
if (component == "..") {
|
if (component == "..") {
|
||||||
if (!components.empty()) {
|
if (!components.empty()) {
|
||||||
components.pop_back();
|
components.pop_back();
|
||||||
@@ -49,23 +52,28 @@ std::string normalisePath(const std::string& path) {
|
|||||||
} else {
|
} else {
|
||||||
components.push_back(component);
|
components.push_back(component);
|
||||||
}
|
}
|
||||||
component.clear();
|
|
||||||
}
|
}
|
||||||
} else {
|
start = i + 1;
|
||||||
component += c;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!component.empty()) {
|
if (components.empty()) {
|
||||||
components.push_back(component);
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t total_len = 0;
|
||||||
|
for (const auto& c : components) {
|
||||||
|
total_len += c.length() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string result;
|
std::string result;
|
||||||
for (const auto& c : components) {
|
result.reserve(total_len - 1);
|
||||||
if (!result.empty()) {
|
|
||||||
result += "/";
|
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;
|
return result;
|
||||||
|
|||||||
+233
-16
@@ -8,6 +8,7 @@
|
|||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
#include "FontCacheManager.h"
|
#include "FontCacheManager.h"
|
||||||
|
|
||||||
@@ -661,8 +662,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 {
|
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++) {
|
if (state) {
|
||||||
drawLine(x, fillY, x + width - 1, fillY, state);
|
fillRectImpl<Color::Black>(x, y, width, height);
|
||||||
|
} else {
|
||||||
|
fillRectImpl<Color::White>(x, y, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,26 +697,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 {
|
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
|
||||||
if (color == Color::Clear) {
|
switch (color) {
|
||||||
} else if (color == Color::Black) {
|
case Color::Clear:
|
||||||
fillRect(x, y, width, height, true);
|
break;
|
||||||
} else if (color == Color::White) {
|
case Color::Black:
|
||||||
fillRect(x, y, width, height, false);
|
fillRectImpl<Color::Black>(x, y, width, height);
|
||||||
} else if (color == Color::LightGray) {
|
break;
|
||||||
for (int fillY = y; fillY < y + height; fillY++) {
|
case Color::White:
|
||||||
for (int fillX = x; fillX < x + width; fillX++) {
|
fillRectImpl<Color::White>(x, y, width, height);
|
||||||
drawPixelDither<Color::LightGray>(fillX, fillY);
|
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) {
|
} else {
|
||||||
for (int fillY = y; fillY < y + height; fillY++) {
|
// Dither (LightGray / DarkGray). Both patterns have period 2 in logical
|
||||||
for (int fillX = x; fillX < x + width; fillX++) {
|
// (x, y), so per physical row we precompute one byte that represents the
|
||||||
drawPixelDither<Color::DarkGray>(fillX, fillY);
|
// 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,
|
void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height,
|
||||||
const int radius, const Color color) const {
|
const int radius, const Color color) const {
|
||||||
if (radius <= 0 || color == Color::Clear) {
|
if (radius <= 0 || color == Color::Clear) {
|
||||||
@@ -885,7 +1056,19 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
||||||
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
|
if (bitmap == nullptr || width <= 0 || height <= 0) return;
|
||||||
|
assert(width == height);
|
||||||
|
|
||||||
|
const int bytesPerRow = (width + 7) / 8;
|
||||||
|
for (int sourceY = 0; sourceY < height; ++sourceY) {
|
||||||
|
for (int sourceX = 0; sourceX < width; ++sourceX) {
|
||||||
|
const uint8_t rowByte = bitmap[sourceY * bytesPerRow + sourceX / 8];
|
||||||
|
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
|
||||||
|
if (background) continue;
|
||||||
|
|
||||||
|
drawPixel(x + height - 1 - sourceY, y + sourceX, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||||
@@ -1437,8 +1620,18 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
|
|||||||
int32_t widthFP = 0;
|
int32_t widthFP = 0;
|
||||||
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
|
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
|
||||||
const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style);
|
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))) {
|
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
|
||||||
int32_t advFP = sdIt->second->getAdvance(cp, styleIdx);
|
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;
|
widthFP += isSupSub ? (advFP + 1) / 2 : advFP;
|
||||||
}
|
}
|
||||||
return fp4::toPixel(widthFP);
|
return fp4::toPixel(widthFP);
|
||||||
@@ -1577,6 +1770,30 @@ size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
|
|||||||
// unused
|
// unused
|
||||||
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
|
// 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::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); }
|
||||||
|
|
||||||
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
|
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ class GfxRenderer {
|
|||||||
void drawPixelDither(int x, int y) const;
|
void drawPixelDither(int x, int y) const;
|
||||||
template <Color color>
|
template <Color color>
|
||||||
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
|
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:
|
public:
|
||||||
explicit GfxRenderer(HalDisplay& halDisplay)
|
explicit GfxRenderer(HalDisplay& halDisplay)
|
||||||
@@ -218,6 +224,16 @@ class GfxRenderer {
|
|||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||||
RenderMode getRenderMode() const { return renderMode; }
|
RenderMode getRenderMode() const { return renderMode; }
|
||||||
|
// 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 copyGrayscaleLsbBuffers() const;
|
||||||
void copyGrayscaleMsbBuffers() const;
|
void copyGrayscaleMsbBuffers() const;
|
||||||
void displayGrayBuffer() const;
|
void displayGrayBuffer() const;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ STR_ORIENTATION: "Арыентацыя чытання"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
||||||
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||||
STR_FONT_FAMILY: "Шрыфт чытання"
|
STR_FONT_FAMILY: "Шрыфт чытання"
|
||||||
STR_FONT_SIZE: "Памер шрыфту інтэрфейсу"
|
STR_FONT_SIZE: "Памер шрыфту інтэрфейсу"
|
||||||
STR_LINE_SPACING: "Міжрадковы інтэрвал"
|
STR_LINE_SPACING: "Міжрадковы інтэрвал"
|
||||||
@@ -125,6 +126,7 @@ STR_PAGE_TURN: "Перагортванне"
|
|||||||
STR_PORTRAIT: "Партрэт"
|
STR_PORTRAIT: "Партрэт"
|
||||||
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
||||||
STR_INVERTED: "Інверсія"
|
STR_INVERTED: "Інверсія"
|
||||||
|
STR_ORIENTATION_INVERTED: "Партрэт 180°"
|
||||||
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
||||||
STR_PREV_NEXT: "Назад/Наперад"
|
STR_PREV_NEXT: "Назад/Наперад"
|
||||||
STR_NEXT_PREV: "Наперад/Назад"
|
STR_NEXT_PREV: "Наперад/Назад"
|
||||||
@@ -175,6 +177,9 @@ STR_EXIT: "« Выхад"
|
|||||||
STR_HOME: "« Галоўная"
|
STR_HOME: "« Галоўная"
|
||||||
STR_SELECT: "Абраць"
|
STR_SELECT: "Абраць"
|
||||||
STR_TOGGLE: "Выбар"
|
STR_TOGGLE: "Выбар"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Пераключыць закладку"
|
||||||
|
STR_BOOKMARK_REMOVED: "Закладка выдалена."
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Утрымлівайце Адкрыць, каб выдаліць"
|
||||||
STR_CONFIRM: "Пацв."
|
STR_CONFIRM: "Пацв."
|
||||||
STR_CANCEL: "Адмена"
|
STR_CANCEL: "Адмена"
|
||||||
STR_CONNECT: "Падкл."
|
STR_CONNECT: "Падкл."
|
||||||
@@ -295,3 +300,4 @@ STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз
|
|||||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||||
|
STR_MANAGE_THEMES: "Кіраванне тэмамі"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
|
||||||
|
STR_LONG_PRESS_MENU: "Funció de pulsació llarga"
|
||||||
STR_FONT_FAMILY: "Tipus de lletra"
|
STR_FONT_FAMILY: "Tipus de lletra"
|
||||||
STR_FONT_SIZE: "Mida de la lletra (UI)"
|
STR_FONT_SIZE: "Mida de la lletra (UI)"
|
||||||
STR_LINE_SPACING: "Interlineat del lector"
|
STR_LINE_SPACING: "Interlineat del lector"
|
||||||
@@ -135,9 +136,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
|
|||||||
STR_PORTRAIT: "Vertical"
|
STR_PORTRAIT: "Vertical"
|
||||||
STR_LANDSCAPE_CW: "Horitzontal horari"
|
STR_LANDSCAPE_CW: "Horitzontal horari"
|
||||||
STR_INVERTED: "Invertit"
|
STR_INVERTED: "Invertit"
|
||||||
|
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||||
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
||||||
STR_PREV_NEXT: "Anterior/Següent"
|
STR_PREV_NEXT: "Anterior/Següent"
|
||||||
STR_NEXT_PREV: "Següent/Anterior"
|
STR_NEXT_PREV: "Següent/Anterior"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Punt de llibre"
|
||||||
STR_DISABLED: "Desactivats"
|
STR_DISABLED: "Desactivats"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
@@ -172,8 +176,7 @@ STR_DOWNLOADING: "S'està baixant..."
|
|||||||
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
||||||
STR_ERROR_MSG: "Error:"
|
STR_ERROR_MSG: "Error:"
|
||||||
STR_UNNAMED: "Sense nom"
|
STR_UNNAMED: "Sense nom"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
|
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
|
|
||||||
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
||||||
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
||||||
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
||||||
@@ -189,6 +192,7 @@ STR_HOME: "« Inici"
|
|||||||
STR_SELECT: "Selecciona"
|
STR_SELECT: "Selecciona"
|
||||||
STR_SELECTED: "Seleccionat"
|
STR_SELECTED: "Seleccionat"
|
||||||
STR_TOGGLE: "Canvia"
|
STR_TOGGLE: "Canvia"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Commuta punt de llibre"
|
||||||
STR_CONFIRM: "Confirma"
|
STR_CONFIRM: "Confirma"
|
||||||
STR_CANCEL: "Cancel·la"
|
STR_CANCEL: "Cancel·la"
|
||||||
STR_CONNECT: "Connecta"
|
STR_CONNECT: "Connecta"
|
||||||
@@ -233,6 +237,7 @@ STR_QUICK_RESUME_TIMEOUT: "Represa ràpida després del temps"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
||||||
STR_BOOKMARKS: "Punts de llibre"
|
STR_BOOKMARKS: "Punts de llibre"
|
||||||
STR_BOOKMARK_ADDED: "S'ha afegit el punt 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_OPDS_BROWSER: "Navegador OPDS"
|
||||||
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
||||||
STR_QUICK_RESUME: "Represa ràpida"
|
STR_QUICK_RESUME: "Represa ràpida"
|
||||||
@@ -294,6 +299,7 @@ STR_BOOK_S_STYLE: "Estil del llibre"
|
|||||||
STR_EMBEDDED_STYLE: "Estil incrustat"
|
STR_EMBEDDED_STYLE: "Estil incrustat"
|
||||||
STR_FOCUS_READING: "Lectura enfocada"
|
STR_FOCUS_READING: "Lectura enfocada"
|
||||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
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_FOOTNOTES: "Notes al peu"
|
||||||
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
||||||
STR_LINK: "[enllaç]"
|
STR_LINK: "[enllaç]"
|
||||||
@@ -338,6 +344,7 @@ STR_MANAGE_FONTS: "Gestiona els tipus de lletra"
|
|||||||
STR_FONT_BROWSER: "Navegador de 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_LOADING_FONT_LIST: "S'està carregant la llista de tipus de lletra..."
|
||||||
STR_NO_FONTS_AVAILABLE: "No hi ha tipus de lletra disponibles"
|
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_INSTALLED: "Tipus de lletra instal·lat!"
|
||||||
STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació del tipus de lletra"
|
STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació del tipus de lletra"
|
||||||
STR_INSTALLED: "Instal·lat"
|
STR_INSTALLED: "Instal·lat"
|
||||||
@@ -376,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
|
||||||
STR_RECOVERY_MODE: "Mode de recuperació"
|
STR_RECOVERY_MODE: "Mode de recuperació"
|
||||||
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
||||||
|
STR_MANAGE_THEMES: "Gestiona els temes"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Změna orientace"
|
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_FAMILY: "Rodina písem čtečky"
|
||||||
STR_FONT_SIZE: "Velikost písma rozhraní"
|
STR_FONT_SIZE: "Velikost písma rozhraní"
|
||||||
STR_LINE_SPACING: "Řádkování čtečky"
|
STR_LINE_SPACING: "Řádkování čtečky"
|
||||||
@@ -130,6 +131,7 @@ STR_PAGE_TURN: "Otáčení stránek"
|
|||||||
STR_PORTRAIT: "Na výšku"
|
STR_PORTRAIT: "Na výšku"
|
||||||
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
|
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
|
||||||
STR_INVERTED: "Invertovaný"
|
STR_INVERTED: "Invertovaný"
|
||||||
|
STR_ORIENTATION_INVERTED: "Na výšku 180°"
|
||||||
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
|
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
|
||||||
STR_PREV_NEXT: "Předchozí/Další"
|
STR_PREV_NEXT: "Předchozí/Další"
|
||||||
STR_NEXT_PREV: "Další/Předchozí"
|
STR_NEXT_PREV: "Další/Předchozí"
|
||||||
@@ -180,6 +182,9 @@ STR_EXIT: "« Konec"
|
|||||||
STR_HOME: "« Domů"
|
STR_HOME: "« Domů"
|
||||||
STR_SELECT: "Vybrat"
|
STR_SELECT: "Vybrat"
|
||||||
STR_TOGGLE: "Přepnout"
|
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_CONFIRM: "Potvrdit"
|
||||||
STR_CANCEL: "Zrušit"
|
STR_CANCEL: "Zrušit"
|
||||||
STR_CONNECT: "Připojit"
|
STR_CONNECT: "Připojit"
|
||||||
@@ -270,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
||||||
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
||||||
|
STR_MANAGE_THEMES: "Spravovat motivy"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación"
|
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_FAMILY: "Læser skrifttype"
|
||||||
STR_FONT_SIZE: "Læser skriftstørrelse"
|
STR_FONT_SIZE: "Læser skriftstørrelse"
|
||||||
STR_LINE_SPACING: "Linjeafstand"
|
STR_LINE_SPACING: "Linjeafstand"
|
||||||
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Sideskift"
|
|||||||
STR_PORTRAIT: "Portræt"
|
STR_PORTRAIT: "Portræt"
|
||||||
STR_LANDSCAPE_CW: "Liggende med uret"
|
STR_LANDSCAPE_CW: "Liggende med uret"
|
||||||
STR_INVERTED: "Inverteret"
|
STR_INVERTED: "Inverteret"
|
||||||
|
STR_ORIENTATION_INVERTED: "Portræt 180°"
|
||||||
STR_LANDSCAPE_CCW: "Liggende mod uret"
|
STR_LANDSCAPE_CCW: "Liggende mod uret"
|
||||||
STR_PREV_NEXT: "Forrige/Næste"
|
STR_PREV_NEXT: "Forrige/Næste"
|
||||||
STR_NEXT_PREV: "Næste/Forrige"
|
STR_NEXT_PREV: "Næste/Forrige"
|
||||||
@@ -186,6 +188,9 @@ STR_HOME: "« Hjem"
|
|||||||
STR_SELECT: "Vælg"
|
STR_SELECT: "Vælg"
|
||||||
STR_SELECTED: "Valgt"
|
STR_SELECTED: "Valgt"
|
||||||
STR_TOGGLE: "Skift"
|
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_CONFIRM: "Bekræft"
|
||||||
STR_CANCEL: "Annuller"
|
STR_CANCEL: "Annuller"
|
||||||
STR_CONNECT: "Forbind"
|
STR_CONNECT: "Forbind"
|
||||||
@@ -298,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
|||||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||||
STR_TILT_PAGE_TURN: "Vip for at vende side"
|
STR_TILT_PAGE_TURN: "Vip for at vende side"
|
||||||
|
STR_MANAGE_THEMES: "Administrer temaer"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
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_FAMILY: "Lettertype lezer"
|
||||||
STR_FONT_SIZE: "Lettergrootte lezer"
|
STR_FONT_SIZE: "Lettergrootte lezer"
|
||||||
STR_LINE_SPACING: "Regelafstand lezer"
|
STR_LINE_SPACING: "Regelafstand lezer"
|
||||||
@@ -134,7 +135,8 @@ STR_SLEEP: "Slaap"
|
|||||||
STR_PAGE_TURN: "Pagina omslaan"
|
STR_PAGE_TURN: "Pagina omslaan"
|
||||||
STR_PORTRAIT: "Staand"
|
STR_PORTRAIT: "Staand"
|
||||||
STR_LANDSCAPE_CW: "Liggend (rechtsom)"
|
STR_LANDSCAPE_CW: "Liggend (rechtsom)"
|
||||||
STR_INVERTED: "Omgekeerd"
|
STR_INVERTED: "Geïnverteerd"
|
||||||
|
STR_ORIENTATION_INVERTED: "Staand 180°"
|
||||||
STR_LANDSCAPE_CCW: "Liggend (linksom)"
|
STR_LANDSCAPE_CCW: "Liggend (linksom)"
|
||||||
STR_PREV_NEXT: "Vorige/Volgende"
|
STR_PREV_NEXT: "Vorige/Volgende"
|
||||||
STR_NEXT_PREV: "Volgende/Vorige"
|
STR_NEXT_PREV: "Volgende/Vorige"
|
||||||
@@ -186,6 +188,9 @@ STR_HOME: "« Home"
|
|||||||
STR_SELECT: "Kies"
|
STR_SELECT: "Kies"
|
||||||
STR_SELECTED: "Geselecteerd"
|
STR_SELECTED: "Geselecteerd"
|
||||||
STR_TOGGLE: "Wissel"
|
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_CONFIRM: "Bevestig"
|
||||||
STR_CANCEL: "Annuleer"
|
STR_CANCEL: "Annuleer"
|
||||||
STR_CONNECT: "Verbind"
|
STR_CONNECT: "Verbind"
|
||||||
@@ -298,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
|||||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||||
STR_TILT_PAGE_TURN: "Kantel om te bladeren"
|
STR_TILT_PAGE_TURN: "Kantel om te bladeren"
|
||||||
|
STR_MANAGE_THEMES: "Thema's beheren"
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
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_FAMILY: "Reader Font Family"
|
||||||
STR_FONT_SIZE: "Reader Font Size"
|
STR_FONT_SIZE: "Reader Font Size"
|
||||||
STR_LINE_SPACING: "Reader Line Spacing"
|
STR_LINE_SPACING: "Reader Line Spacing"
|
||||||
@@ -137,9 +139,12 @@ STR_FORCE_REFRESH: "Refresh Screen"
|
|||||||
STR_PORTRAIT: "Portrait"
|
STR_PORTRAIT: "Portrait"
|
||||||
STR_LANDSCAPE_CW: "Landscape CW"
|
STR_LANDSCAPE_CW: "Landscape CW"
|
||||||
STR_INVERTED: "Inverted"
|
STR_INVERTED: "Inverted"
|
||||||
|
STR_ORIENTATION_INVERTED: "Portrait 180°"
|
||||||
STR_LANDSCAPE_CCW: "Landscape CCW"
|
STR_LANDSCAPE_CCW: "Landscape CCW"
|
||||||
STR_PREV_NEXT: "Prev/Next"
|
STR_PREV_NEXT: "Prev/Next"
|
||||||
STR_NEXT_PREV: "Next/Prev"
|
STR_NEXT_PREV: "Next/Prev"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Bookmark"
|
||||||
STR_DISABLED: "Disabled"
|
STR_DISABLED: "Disabled"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
@@ -175,8 +180,7 @@ STR_DOWNLOADING: "Downloading..."
|
|||||||
STR_DOWNLOAD_FAILED: "Download failed"
|
STR_DOWNLOAD_FAILED: "Download failed"
|
||||||
STR_ERROR_MSG: "Error:"
|
STR_ERROR_MSG: "Error:"
|
||||||
STR_UNNAMED: "Unnamed"
|
STR_UNNAMED: "Unnamed"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Hold Confirm to Delete"
|
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Hold Confirm from the reader to create a bookmark."
|
|
||||||
STR_NO_SERVER_URL: "No server URL configured"
|
STR_NO_SERVER_URL: "No server URL configured"
|
||||||
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
||||||
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
||||||
@@ -194,6 +198,7 @@ STR_HOME: "« Home"
|
|||||||
STR_SELECT: "Select"
|
STR_SELECT: "Select"
|
||||||
STR_SELECTED: "Selected"
|
STR_SELECTED: "Selected"
|
||||||
STR_TOGGLE: "Toggle"
|
STR_TOGGLE: "Toggle"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Toggle Bookmark"
|
||||||
STR_CONFIRM: "Confirm"
|
STR_CONFIRM: "Confirm"
|
||||||
STR_CANCEL: "Cancel"
|
STR_CANCEL: "Cancel"
|
||||||
STR_CONNECT: "Connect"
|
STR_CONNECT: "Connect"
|
||||||
@@ -256,6 +261,7 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
||||||
STR_BOOKMARKS: "Bookmarks"
|
STR_BOOKMARKS: "Bookmarks"
|
||||||
STR_BOOKMARK_ADDED: "Bookmark added."
|
STR_BOOKMARK_ADDED: "Bookmark added."
|
||||||
|
STR_BOOKMARK_REMOVED: "Bookmark removed."
|
||||||
STR_OPDS_BROWSER: "OPDS Browser"
|
STR_OPDS_BROWSER: "OPDS Browser"
|
||||||
STR_SEARCH: "Search"
|
STR_SEARCH: "Search"
|
||||||
STR_COVER_CUSTOM: "Cover + Custom"
|
STR_COVER_CUSTOM: "Cover + Custom"
|
||||||
@@ -344,6 +350,7 @@ STR_INSTALLED: "Installed"
|
|||||||
STR_DOWNLOAD_ALL: "Download All"
|
STR_DOWNLOAD_ALL: "Download All"
|
||||||
STR_UPDATE_ALL: "Update All"
|
STR_UPDATE_ALL: "Update All"
|
||||||
STR_UPDATE_AVAILABLE: "Update"
|
STR_UPDATE_AVAILABLE: "Update"
|
||||||
|
STR_MANAGE_THEMES: "Manage Themes"
|
||||||
STR_CRASH_TITLE: "System Crash"
|
STR_CRASH_TITLE: "System Crash"
|
||||||
STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
|
STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
|
||||||
STR_CRASH_REASON: "Crash reason:"
|
STR_CRASH_REASON: "Crash reason:"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Törkylempijävongahdus"
|
||||||
STR_FONT_FAMILY: "Lukijan fonttiperhe"
|
STR_FONT_FAMILY: "Lukijan fonttiperhe"
|
||||||
STR_FONT_SIZE: "Käyttöliittymän fonttikoko"
|
STR_FONT_SIZE: "Käyttöliittymän fonttikoko"
|
||||||
STR_LINE_SPACING: "Lukijan riviväli"
|
STR_LINE_SPACING: "Lukijan riviväli"
|
||||||
@@ -129,7 +130,8 @@ STR_SLEEP: "Lepotila"
|
|||||||
STR_PAGE_TURN: "Sivunkääntö"
|
STR_PAGE_TURN: "Sivunkääntö"
|
||||||
STR_PORTRAIT: "Pysty"
|
STR_PORTRAIT: "Pysty"
|
||||||
STR_LANDSCAPE_CW: "Vaaka myötäpäivään"
|
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_LANDSCAPE_CCW: "Vaaka vastapäivään"
|
||||||
STR_PREV_NEXT: "Edell/Seur"
|
STR_PREV_NEXT: "Edell/Seur"
|
||||||
STR_NEXT_PREV: "Seur/Edell"
|
STR_NEXT_PREV: "Seur/Edell"
|
||||||
@@ -180,6 +182,9 @@ STR_EXIT: "« Poistu"
|
|||||||
STR_HOME: "« Koti"
|
STR_HOME: "« Koti"
|
||||||
STR_SELECT: "Valitse"
|
STR_SELECT: "Valitse"
|
||||||
STR_TOGGLE: "Vaihda"
|
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_CONFIRM: "Vahvista"
|
||||||
STR_CANCEL: "Peruuta"
|
STR_CANCEL: "Peruuta"
|
||||||
STR_CONNECT: "Yhdistä"
|
STR_CONNECT: "Yhdistä"
|
||||||
@@ -268,3 +273,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Ei koskaan"
|
STR_SLEEP_NEVER: "Ei koskaan"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
||||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||||
|
STR_MANAGE_THEMES: "Hallinnoi teemoja"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Changement d'orientation"
|
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_FAMILY: "Police de caractères du lecteur"
|
||||||
STR_FONT_SIZE: "Taille police lecteur"
|
STR_FONT_SIZE: "Taille police lecteur"
|
||||||
STR_LINE_SPACING: "Interligne"
|
STR_LINE_SPACING: "Interligne"
|
||||||
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Page suivante"
|
|||||||
STR_PORTRAIT: "Portrait"
|
STR_PORTRAIT: "Portrait"
|
||||||
STR_LANDSCAPE_CW: "Paysage"
|
STR_LANDSCAPE_CW: "Paysage"
|
||||||
STR_INVERTED: "Inversé"
|
STR_INVERTED: "Inversé"
|
||||||
|
STR_ORIENTATION_INVERTED: "Portrait 180°"
|
||||||
STR_LANDSCAPE_CCW: "Paysage inversé"
|
STR_LANDSCAPE_CCW: "Paysage inversé"
|
||||||
STR_PREV_NEXT: "Préc/Suiv"
|
STR_PREV_NEXT: "Préc/Suiv"
|
||||||
STR_NEXT_PREV: "Suiv/Préc"
|
STR_NEXT_PREV: "Suiv/Préc"
|
||||||
@@ -186,6 +188,9 @@ STR_HOME: "« Accueil"
|
|||||||
STR_SELECT: "OK"
|
STR_SELECT: "OK"
|
||||||
STR_SELECTED: "Sélectionné"
|
STR_SELECTED: "Sélectionné"
|
||||||
STR_TOGGLE: "Modifier"
|
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_CONFIRM: "Confirmer"
|
||||||
STR_CANCEL: "Annuler"
|
STR_CANCEL: "Annuler"
|
||||||
STR_CONNECT: "Connecter"
|
STR_CONNECT: "Connecter"
|
||||||
@@ -299,3 +304,4 @@ STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
|||||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||||
STR_TILT_PAGE_TURN: "Tourner par inclinaison"
|
STR_TILT_PAGE_TURN: "Tourner par inclinaison"
|
||||||
|
STR_MANAGE_THEMES: "Gérer les thèmes"
|
||||||
|
|||||||
@@ -70,16 +70,18 @@ STR_ORIENTATION: "Leseausrichtung"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
||||||
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
|
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_SKIP: "Kapitel überspringen"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern"
|
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_FAMILY: "Lese-Schriftfamilie"
|
||||||
STR_FONT_SIZE: "Schriftgröße"
|
STR_FONT_SIZE: "Schriftgröße"
|
||||||
STR_LINE_SPACING: "Lese-Zeilenabstand"
|
STR_LINE_SPACING: "Lese-Zeilenabstand"
|
||||||
STR_SCREEN_MARGIN: "Lese-Seitenränder"
|
STR_SCREEN_MARGIN: "Lese-Seitenränder"
|
||||||
STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung"
|
STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung"
|
||||||
|
STR_LONG_PRESS_MENU: "Menütaste lang drücken"
|
||||||
STR_HYPHENATION: "Silbentrennung"
|
STR_HYPHENATION: "Silbentrennung"
|
||||||
STR_TIME_TO_SLEEP: "Standby nach"
|
STR_TIME_TO_SLEEP: "Standby-Modus nach"
|
||||||
STR_REFRESH_FREQ: "Anti-Ghosting nach"
|
STR_REFRESH_FREQ: "Anti-Ghosting nach"
|
||||||
STR_KOREADER_SYNC: "KOReader-Synchr."
|
STR_KOREADER_SYNC: "KOReader-Synchr."
|
||||||
STR_CHECK_UPDATES: "Nach Updates suchen"
|
STR_CHECK_UPDATES: "Nach Updates suchen"
|
||||||
@@ -128,7 +130,8 @@ STR_PAGE_TURN: "Umblättern"
|
|||||||
STR_FORCE_REFRESH: "Bildschirm regenerieren"
|
STR_FORCE_REFRESH: "Bildschirm regenerieren"
|
||||||
STR_PORTRAIT: "Hochformat"
|
STR_PORTRAIT: "Hochformat"
|
||||||
STR_LANDSCAPE_CW: "Querformat rechts"
|
STR_LANDSCAPE_CW: "Querformat rechts"
|
||||||
STR_INVERTED: "Hochformat 180°"
|
STR_INVERTED: "Invertiert"
|
||||||
|
STR_ORIENTATION_INVERTED: "Hochformat 180°"
|
||||||
STR_LANDSCAPE_CCW: "Querformat links"
|
STR_LANDSCAPE_CCW: "Querformat links"
|
||||||
STR_PREV_NEXT: "Zurück/Weiter"
|
STR_PREV_NEXT: "Zurück/Weiter"
|
||||||
STR_NEXT_PREV: "Weiter/Zurück"
|
STR_NEXT_PREV: "Weiter/Zurück"
|
||||||
@@ -167,8 +170,7 @@ STR_DOWNLOADING: "Herunterladen…"
|
|||||||
STR_DOWNLOAD_FAILED: "Ladefehler"
|
STR_DOWNLOAD_FAILED: "Ladefehler"
|
||||||
STR_ERROR_MSG: "Fehler:"
|
STR_ERROR_MSG: "Fehler:"
|
||||||
STR_UNNAMED: "Unbenannt"
|
STR_UNNAMED: "Unbenannt"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Halte Bestätigen zum Löschen"
|
STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Halte Bestätigen im Lesemodus um ein Lesezeichen anzulegen."
|
|
||||||
STR_NO_SERVER_URL: "Keine Server-URL konfiguriert"
|
STR_NO_SERVER_URL: "Keine Server-URL konfiguriert"
|
||||||
STR_FETCH_FEED_FAILED: "Feedfehler"
|
STR_FETCH_FEED_FAILED: "Feedfehler"
|
||||||
STR_PARSE_FEED_FAILED: "Feed-Format ungültig"
|
STR_PARSE_FEED_FAILED: "Feed-Format ungültig"
|
||||||
@@ -186,6 +188,7 @@ STR_HOME: "« Start"
|
|||||||
STR_SELECT: "Auswahl"
|
STR_SELECT: "Auswahl"
|
||||||
STR_SELECTED: "Ausgewählt"
|
STR_SELECTED: "Ausgewählt"
|
||||||
STR_TOGGLE: "Ändern"
|
STR_TOGGLE: "Ändern"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Lesezeichen umschalten"
|
||||||
STR_CONFIRM: "Bestätigen"
|
STR_CONFIRM: "Bestätigen"
|
||||||
STR_CANCEL: "Abbrechen"
|
STR_CANCEL: "Abbrechen"
|
||||||
STR_CONNECT: "Verbinden"
|
STR_CONNECT: "Verbinden"
|
||||||
@@ -249,6 +252,7 @@ STR_QUICK_RESUME_TIMEOUT: "Schnelles Fortsetzen nach Timeout"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen"
|
STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen"
|
||||||
STR_BOOKMARKS: "Lesezeichen"
|
STR_BOOKMARKS: "Lesezeichen"
|
||||||
STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt."
|
STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt."
|
||||||
|
STR_BOOKMARK_REMOVED: "Lesezeichen entfernt."
|
||||||
STR_SEARCH: "Suche"
|
STR_SEARCH: "Suche"
|
||||||
STR_OPDS_BROWSER: "OPDS-Browser"
|
STR_OPDS_BROWSER: "OPDS-Browser"
|
||||||
STR_COVER_CUSTOM: "Cover + Eigenes"
|
STR_COVER_CUSTOM: "Cover + Eigenes"
|
||||||
@@ -376,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!"
|
||||||
STR_RECOVERY_MODE: "Wiederherstellungsmodus"
|
STR_RECOVERY_MODE: "Wiederherstellungsmodus"
|
||||||
STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus"
|
STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus"
|
||||||
|
STR_MANAGE_THEMES: "Designs verwalten"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "שנה כיוון מסך"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "שנה כיוון מסך"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "דג סקרן שט בים מאוכזב ולפתע מצא חברה"
|
||||||
STR_FONT_FAMILY: "גופן הקריאה"
|
STR_FONT_FAMILY: "גופן הקריאה"
|
||||||
STR_FONT_SIZE: "גודל גופן"
|
STR_FONT_SIZE: "גודל גופן"
|
||||||
STR_LINE_SPACING: "מרווח בין שורות"
|
STR_LINE_SPACING: "מרווח בין שורות"
|
||||||
@@ -133,7 +134,8 @@ STR_PAGE_TURN: "העברת דף"
|
|||||||
STR_FORCE_REFRESH: "רענון מסך מלא"
|
STR_FORCE_REFRESH: "רענון מסך מלא"
|
||||||
STR_PORTRAIT: "לאורך"
|
STR_PORTRAIT: "לאורך"
|
||||||
STR_LANDSCAPE_CW: "לרוחב (ימינה)"
|
STR_LANDSCAPE_CW: "לרוחב (ימינה)"
|
||||||
STR_INVERTED: "הפוך"
|
STR_INVERTED: "היפוך צבעים"
|
||||||
|
STR_ORIENTATION_INVERTED: "לאורך 180°"
|
||||||
STR_LANDSCAPE_CCW: "לרוחב (שמאלה)"
|
STR_LANDSCAPE_CCW: "לרוחב (שמאלה)"
|
||||||
STR_PREV_NEXT: "הקודם/הבא"
|
STR_PREV_NEXT: "הקודם/הבא"
|
||||||
STR_NEXT_PREV: "הבא/הקודם"
|
STR_NEXT_PREV: "הבא/הקודם"
|
||||||
@@ -188,6 +190,7 @@ STR_HOME: "מסך הבית »"
|
|||||||
STR_SELECT: "בחר"
|
STR_SELECT: "בחר"
|
||||||
STR_SELECTED: "נבחר"
|
STR_SELECTED: "נבחר"
|
||||||
STR_TOGGLE: "בחר"
|
STR_TOGGLE: "בחר"
|
||||||
|
STR_TOGGLE_BOOKMARK: "הוסף/הסר סימנייה"
|
||||||
STR_CONFIRM: "אישור"
|
STR_CONFIRM: "אישור"
|
||||||
STR_CANCEL: "ביטול"
|
STR_CANCEL: "ביטול"
|
||||||
STR_CONNECT: "התחבר"
|
STR_CONNECT: "התחבר"
|
||||||
@@ -359,8 +362,7 @@ STR_FRONT_BTN_FOLLOW_ORIENTATION: "התאמת לחצנים קדמיים לכיו
|
|||||||
STR_REMOVE_READ_FROM_RECENTS: "הסר ספרים שנקראו מרשימת האחרונים"
|
STR_REMOVE_READ_FROM_RECENTS: "הסר ספרים שנקראו מרשימת האחרונים"
|
||||||
STR_MOVE_FINISHED_TO_READ: "העבר ספרים שהסתיימו לתיקיית 'נקראו'"
|
STR_MOVE_FINISHED_TO_READ: "העבר ספרים שהסתיימו לתיקיית 'נקראו'"
|
||||||
STR_DISABLED: "מבוטל"
|
STR_DISABLED: "מבוטל"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "החזק לחוץ על אישור כדי למחוק"
|
STR_HOLD_OPEN_TO_DELETE: "לחיצה ארוכה על 'פתח' כדי למחוק"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "החזק לחוץ על כפתור אישור בזמן הקריאה כדי להוסיף סימנייה"
|
|
||||||
STR_CLOCK: "שעון"
|
STR_CLOCK: "שעון"
|
||||||
STR_CLOCK_UTC_OFFSET: "הפרש זמן UTC"
|
STR_CLOCK_UTC_OFFSET: "הפרש זמן UTC"
|
||||||
STR_CLOCK_FORMAT: "תבנית השעון"
|
STR_CLOCK_FORMAT: "תבנית השעון"
|
||||||
@@ -378,6 +380,15 @@ STR_CLOCK_SYNC_NO_WIFI_HINT: "התחבר תחילה לרשת אלחוטית, ו
|
|||||||
STR_CLOCK_SYNCED: "השעון סונכרן"
|
STR_CLOCK_SYNCED: "השעון סונכרן"
|
||||||
STR_BOOKMARKS: "סימניות"
|
STR_BOOKMARKS: "סימניות"
|
||||||
STR_BOOKMARK_ADDED: "הסימנייה התווספה"
|
STR_BOOKMARK_ADDED: "הסימנייה התווספה"
|
||||||
|
STR_BOOKMARK_REMOVED: "הסימנייה הוסרה"
|
||||||
STR_QUICK_RESUME: "חזרה מהירה"
|
STR_QUICK_RESUME: "חזרה מהירה"
|
||||||
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
|
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
|
||||||
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
|
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
|
||||||
|
STR_MANAGE_THEMES: "ניהול ערכות נושא"
|
||||||
|
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_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Olvasási irány"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
||||||
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
|
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_FAMILY: "Olvasó betűkészlet"
|
||||||
STR_FONT_SIZE: "Olvasó betűméret"
|
STR_FONT_SIZE: "Olvasó betűméret"
|
||||||
STR_LINE_SPACING: "Olvasó sorköz"
|
STR_LINE_SPACING: "Olvasó sorköz"
|
||||||
@@ -131,7 +132,8 @@ STR_SLEEP: "Alvás"
|
|||||||
STR_PAGE_TURN: "Lapozás"
|
STR_PAGE_TURN: "Lapozás"
|
||||||
STR_PORTRAIT: "Álló"
|
STR_PORTRAIT: "Álló"
|
||||||
STR_LANDSCAPE_CW: "Fekvő jobbra"
|
STR_LANDSCAPE_CW: "Fekvő jobbra"
|
||||||
STR_INVERTED: "Fordított"
|
STR_INVERTED: "Invertált"
|
||||||
|
STR_ORIENTATION_INVERTED: "Álló 180°"
|
||||||
STR_LANDSCAPE_CCW: "Fekvő balra"
|
STR_LANDSCAPE_CCW: "Fekvő balra"
|
||||||
STR_PREV_NEXT: "Előző/Következő"
|
STR_PREV_NEXT: "Előző/Következő"
|
||||||
STR_NEXT_PREV: "Következő/Előző"
|
STR_NEXT_PREV: "Következő/Előző"
|
||||||
@@ -183,6 +185,9 @@ STR_HOME: "« Főoldal"
|
|||||||
STR_SELECT: "Kiválasztás"
|
STR_SELECT: "Kiválasztás"
|
||||||
STR_SELECTED: "Kiválasztva"
|
STR_SELECTED: "Kiválasztva"
|
||||||
STR_TOGGLE: "Váltás"
|
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_CONFIRM: "Megerősítés"
|
||||||
STR_CANCEL: "Mégse"
|
STR_CANCEL: "Mégse"
|
||||||
STR_CONNECT: "Csatlakozás"
|
STR_CONNECT: "Csatlakozás"
|
||||||
@@ -295,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
|||||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||||
STR_TILT_PAGE_TURN: "Döntéses lapozás"
|
STR_TILT_PAGE_TURN: "Döntéses lapozás"
|
||||||
|
STR_MANAGE_THEMES: "Témák kezelése"
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Salta capitolo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Salta capitolo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientamento"
|
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_FAMILY: "Font lettore"
|
||||||
STR_FONT_SIZE: "Dimensione font"
|
STR_FONT_SIZE: "Dimensione font"
|
||||||
STR_LINE_SPACING: "Interlinea lettore"
|
STR_LINE_SPACING: "Interlinea lettore"
|
||||||
@@ -135,7 +137,8 @@ STR_PAGE_TURN: "Cambio pagina"
|
|||||||
STR_FORCE_REFRESH: "Refresh"
|
STR_FORCE_REFRESH: "Refresh"
|
||||||
STR_PORTRAIT: "Verticale"
|
STR_PORTRAIT: "Verticale"
|
||||||
STR_LANDSCAPE_CW: "Orizzontale Dx"
|
STR_LANDSCAPE_CW: "Orizzontale Dx"
|
||||||
STR_INVERTED: "Capovolto"
|
STR_INVERTED: "Invertito"
|
||||||
|
STR_ORIENTATION_INVERTED: "Verticale 180°"
|
||||||
STR_LANDSCAPE_CCW: "Orizzontale Sx"
|
STR_LANDSCAPE_CCW: "Orizzontale Sx"
|
||||||
STR_PREV_NEXT: "Prec/Succ"
|
STR_PREV_NEXT: "Prec/Succ"
|
||||||
STR_NEXT_PREV: "Succ/Prec"
|
STR_NEXT_PREV: "Succ/Prec"
|
||||||
@@ -190,6 +193,7 @@ STR_HOME: "« Home"
|
|||||||
STR_SELECT: "Seleziona"
|
STR_SELECT: "Seleziona"
|
||||||
STR_SELECTED: "Selezionato"
|
STR_SELECTED: "Selezionato"
|
||||||
STR_TOGGLE: "Cambia"
|
STR_TOGGLE: "Cambia"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Attiva/disattiva segnalibro"
|
||||||
STR_CONFIRM: "Conferma"
|
STR_CONFIRM: "Conferma"
|
||||||
STR_CANCEL: "Annulla"
|
STR_CANCEL: "Annulla"
|
||||||
STR_CONNECT: "Connetti"
|
STR_CONNECT: "Connetti"
|
||||||
@@ -355,9 +359,9 @@ STR_FIRMWARE_WRITE_FAILED: "Aggiornamento firmware non riuscito"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Non spegnere il dispositivo!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Non spegnere il dispositivo!"
|
||||||
STR_RECOVERY_MODE: "Modalità ripristino"
|
STR_RECOVERY_MODE: "Modalità ripristino"
|
||||||
STR_RECOVERY_MODE_HINT: "Metti firmware.bin nella scheda SD e selezionalo"
|
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_BOOKMARKS: "Segnalibri"
|
||||||
STR_BOOKMARK_ADDED: "Segnalibro aggiunto"
|
STR_BOOKMARK_ADDED: "Segnalibro aggiunto"
|
||||||
|
STR_BOOKMARK_REMOVED: "Segnalibro rimosso"
|
||||||
STR_CONFIRM_DELETE_BOOKMARK: "Eliminare questo segnalibro?"
|
STR_CONFIRM_DELETE_BOOKMARK: "Eliminare questo segnalibro?"
|
||||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Connettiti prima al Wi-Fi e poi riprova"
|
STR_CLOCK_SYNC_NO_WIFI_HINT: "Connettiti prima al Wi-Fi e poi riprova"
|
||||||
STR_CLOCK_SYNC_OK: "Orologio sincronizzato"
|
STR_CLOCK_SYNC_OK: "Orologio sincronizzato"
|
||||||
@@ -373,6 +377,11 @@ STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita"
|
|||||||
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
|
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
|
||||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso"
|
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare"
|
STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare"
|
||||||
|
STR_MANAGE_THEMES: "Gestisci temi"
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare"
|
||||||
STR_NEXT_FIELD: "Succ."
|
STR_NEXT_FIELD: "Succ."
|
||||||
STR_CURRENT_TIME: "Ora attuale: "
|
STR_CURRENT_TIME: "Ora attuale: "
|
||||||
STR_DISABLED: "Disattivato"
|
STR_DISABLED: "Disattivato"
|
||||||
|
STR_BOOKMARK_OPTION: "Segnalibro"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ STR_ORIENTATION: "Оқу бағдары"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
||||||
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
|
||||||
STR_FONT_FAMILY: "Оқырман қаріп тобы"
|
STR_FONT_FAMILY: "Оқырман қаріп тобы"
|
||||||
STR_FONT_SIZE: "Интерфейс қаріп өлшемі"
|
STR_FONT_SIZE: "Интерфейс қаріп өлшемі"
|
||||||
STR_LINE_SPACING: "Оқырман жол аралығы"
|
STR_LINE_SPACING: "Оқырман жол аралығы"
|
||||||
@@ -125,7 +126,8 @@ STR_SLEEP: "Ұйқы"
|
|||||||
STR_PAGE_TURN: "Бет аудару"
|
STR_PAGE_TURN: "Бет аудару"
|
||||||
STR_PORTRAIT: "Тік бағдар"
|
STR_PORTRAIT: "Тік бағдар"
|
||||||
STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)"
|
STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)"
|
||||||
STR_INVERTED: "Төңкерілген"
|
STR_INVERTED: "Инверсия"
|
||||||
|
STR_ORIENTATION_INVERTED: "Тік бағдар 180°"
|
||||||
STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)"
|
STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)"
|
||||||
STR_PREV_NEXT: "Алдыңғы/Келесі"
|
STR_PREV_NEXT: "Алдыңғы/Келесі"
|
||||||
STR_NEXT_PREV: "Келесі/Алдыңғы"
|
STR_NEXT_PREV: "Келесі/Алдыңғы"
|
||||||
@@ -176,6 +178,9 @@ STR_EXIT: "« Шығу"
|
|||||||
STR_HOME: "« Басты"
|
STR_HOME: "« Басты"
|
||||||
STR_SELECT: "Таңдау"
|
STR_SELECT: "Таңдау"
|
||||||
STR_TOGGLE: "Ауыстыру"
|
STR_TOGGLE: "Ауыстыру"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Бетбелгіні ауыстыру"
|
||||||
|
STR_BOOKMARK_REMOVED: "Бетбелгі жойылды."
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Жою үшін Ашу түймесін ұстап тұрыңыз"
|
||||||
STR_CONFIRM: "Растау"
|
STR_CONFIRM: "Растау"
|
||||||
STR_CANCEL: "Болдырмау"
|
STR_CANCEL: "Болдырмау"
|
||||||
STR_CONNECT: "Қосылу"
|
STR_CONNECT: "Қосылу"
|
||||||
@@ -294,3 +299,4 @@ STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
|||||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||||
STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару"
|
STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару"
|
||||||
|
STR_MANAGE_THEMES: "Тақырыптарды басқару"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Orientacija"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
||||||
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
|
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_FAMILY: "Šriftas"
|
||||||
STR_FONT_SIZE: "Šrifto dydis"
|
STR_FONT_SIZE: "Šrifto dydis"
|
||||||
STR_LINE_SPACING: "Tarpai tarp eilučių"
|
STR_LINE_SPACING: "Tarpai tarp eilučių"
|
||||||
@@ -131,7 +132,8 @@ STR_SLEEP: "Miegas"
|
|||||||
STR_PAGE_TURN: "Versti psl."
|
STR_PAGE_TURN: "Versti psl."
|
||||||
STR_PORTRAIT: "Stačias"
|
STR_PORTRAIT: "Stačias"
|
||||||
STR_LANDSCAPE_CW: "Gulsčias (P)"
|
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_LANDSCAPE_CCW: "Gulsčias (A)"
|
||||||
STR_PREV_NEXT: "Atgal/Pirmyn"
|
STR_PREV_NEXT: "Atgal/Pirmyn"
|
||||||
STR_NEXT_PREV: "Pirmyn/Atgal"
|
STR_NEXT_PREV: "Pirmyn/Atgal"
|
||||||
@@ -183,6 +185,9 @@ STR_HOME: "« Pradžia"
|
|||||||
STR_SELECT: "Rinktis"
|
STR_SELECT: "Rinktis"
|
||||||
STR_SELECTED: "Pasirinkta"
|
STR_SELECTED: "Pasirinkta"
|
||||||
STR_TOGGLE: "Keisti"
|
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_CONFIRM: "Gerai"
|
||||||
STR_CANCEL: "Atšaukti"
|
STR_CANCEL: "Atšaukti"
|
||||||
STR_CONNECT: "Jungtis"
|
STR_CONNECT: "Jungtis"
|
||||||
@@ -295,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
|||||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||||
STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant"
|
STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant"
|
||||||
|
STR_MANAGE_THEMES: "Tvarkyti temas"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientacja ekranu"
|
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_FAMILY: "Czcionka"
|
||||||
STR_FONT_SIZE: "Rozmiar czcionki"
|
STR_FONT_SIZE: "Rozmiar czcionki"
|
||||||
STR_LINE_SPACING: "Odstępy między wierszami"
|
STR_LINE_SPACING: "Odstępy między wierszami"
|
||||||
@@ -135,7 +136,8 @@ STR_PAGE_TURN: "Nast. str."
|
|||||||
STR_FORCE_REFRESH: "Odśwież ekran"
|
STR_FORCE_REFRESH: "Odśwież ekran"
|
||||||
STR_PORTRAIT: "Pionowo"
|
STR_PORTRAIT: "Pionowo"
|
||||||
STR_LANDSCAPE_CW: "Poziomo P"
|
STR_LANDSCAPE_CW: "Poziomo P"
|
||||||
STR_INVERTED: "Odwrócony"
|
STR_INVERTED: "Inwersja"
|
||||||
|
STR_ORIENTATION_INVERTED: "Pionowo 180°"
|
||||||
STR_LANDSCAPE_CCW: "Poziomo L"
|
STR_LANDSCAPE_CCW: "Poziomo L"
|
||||||
STR_PREV_NEXT: "Poprz./Nast."
|
STR_PREV_NEXT: "Poprz./Nast."
|
||||||
STR_NEXT_PREV: "Nast./Poprz."
|
STR_NEXT_PREV: "Nast./Poprz."
|
||||||
@@ -190,6 +192,9 @@ STR_HOME: "« Home"
|
|||||||
STR_SELECT: "Wybierz"
|
STR_SELECT: "Wybierz"
|
||||||
STR_SELECTED: "Wybrano"
|
STR_SELECTED: "Wybrano"
|
||||||
STR_TOGGLE: "Zmień"
|
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_CONFIRM: "Potwierdź"
|
||||||
STR_CANCEL: "Anuluj"
|
STR_CANCEL: "Anuluj"
|
||||||
STR_CONNECT: "Połącz"
|
STR_CONNECT: "Połącz"
|
||||||
@@ -355,3 +360,4 @@ STR_FIRMWARE_WRITE_FAILED: "Zapis oprogramowania nieudany"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!"
|
||||||
STR_RECOVERY_MODE: "Tryb przywracania"
|
STR_RECOVERY_MODE: "Tryb przywracania"
|
||||||
STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go"
|
STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go"
|
||||||
|
STR_MANAGE_THEMES: "Zarządzaj motywami"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
|
||||||
STR_FONT_FAMILY: "Fonte do leitor"
|
STR_FONT_FAMILY: "Fonte do leitor"
|
||||||
STR_FONT_SIZE: "Tam. fonte UI"
|
STR_FONT_SIZE: "Tam. fonte UI"
|
||||||
STR_LINE_SPACING: "Espaçamento entre linhas"
|
STR_LINE_SPACING: "Espaçamento entre linhas"
|
||||||
@@ -130,6 +131,7 @@ STR_PAGE_TURN: "Virar página"
|
|||||||
STR_PORTRAIT: "Retrato"
|
STR_PORTRAIT: "Retrato"
|
||||||
STR_LANDSCAPE_CW: "Paisagem H"
|
STR_LANDSCAPE_CW: "Paisagem H"
|
||||||
STR_INVERTED: "Invertido"
|
STR_INVERTED: "Invertido"
|
||||||
|
STR_ORIENTATION_INVERTED: "Retrato 180°"
|
||||||
STR_LANDSCAPE_CCW: "Paisagem AH"
|
STR_LANDSCAPE_CCW: "Paisagem AH"
|
||||||
STR_PREV_NEXT: "Ant/Próx"
|
STR_PREV_NEXT: "Ant/Próx"
|
||||||
STR_NEXT_PREV: "Próx/Ant"
|
STR_NEXT_PREV: "Próx/Ant"
|
||||||
@@ -180,6 +182,9 @@ STR_EXIT: "« Sair"
|
|||||||
STR_HOME: "« Início"
|
STR_HOME: "« Início"
|
||||||
STR_SELECT: "Escolher"
|
STR_SELECT: "Escolher"
|
||||||
STR_TOGGLE: "Alternar"
|
STR_TOGGLE: "Alternar"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||||
|
STR_BOOKMARK_REMOVED: "Marcador removido."
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
|
||||||
STR_CONFIRM: "Confirmar"
|
STR_CONFIRM: "Confirmar"
|
||||||
STR_CANCEL: "Cancelar"
|
STR_CANCEL: "Cancelar"
|
||||||
STR_CONNECT: "Conectar"
|
STR_CONNECT: "Conectar"
|
||||||
@@ -270,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Nunca"
|
STR_SLEEP_NEVER: "Nunca"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
||||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||||
|
STR_MANAGE_THEMES: "Gerenciar temas"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Schimbă orientarea"
|
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_FAMILY: "Familie font lectură"
|
||||||
STR_FONT_SIZE: "Dimensiune font"
|
STR_FONT_SIZE: "Dimensiune font"
|
||||||
STR_LINE_SPACING: "Spaţiere între rânduri"
|
STR_LINE_SPACING: "Spaţiere între rânduri"
|
||||||
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Răsfoire pagină"
|
|||||||
STR_PORTRAIT: "Vertical"
|
STR_PORTRAIT: "Vertical"
|
||||||
STR_LANDSCAPE_CW: "Orizontal dreapta"
|
STR_LANDSCAPE_CW: "Orizontal dreapta"
|
||||||
STR_INVERTED: "Invers"
|
STR_INVERTED: "Invers"
|
||||||
|
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||||
STR_LANDSCAPE_CCW: "Orizontal stânga"
|
STR_LANDSCAPE_CCW: "Orizontal stânga"
|
||||||
STR_PREV_NEXT: "Înainte/Înapoi"
|
STR_PREV_NEXT: "Înainte/Înapoi"
|
||||||
STR_NEXT_PREV: "Înapoi/Înainte"
|
STR_NEXT_PREV: "Înapoi/Înainte"
|
||||||
@@ -186,6 +188,9 @@ STR_HOME: "« Acasă"
|
|||||||
STR_SELECT: "Selectează"
|
STR_SELECT: "Selectează"
|
||||||
STR_SELECTED: "Selectat"
|
STR_SELECTED: "Selectat"
|
||||||
STR_TOGGLE: "Schimbă"
|
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_CONFIRM: "Confirmă"
|
||||||
STR_CANCEL: "Anulare"
|
STR_CANCEL: "Anulare"
|
||||||
STR_CONNECT: "Conectare"
|
STR_CONNECT: "Conectare"
|
||||||
@@ -298,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Captură ecran"
|
|||||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||||
STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare"
|
STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare"
|
||||||
|
STR_MANAGE_THEMES: "Gestionează temele"
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию"
|
||||||
|
STR_LONG_PRESS_MENU: "Долгое нажатие меню"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Съешь ещё этих мягких французских булок, да выпей же чаю"
|
||||||
STR_FONT_FAMILY: "Шрифт чтения"
|
STR_FONT_FAMILY: "Шрифт чтения"
|
||||||
STR_FONT_SIZE: "Размер шрифта интерфейса"
|
STR_FONT_SIZE: "Размер шрифта интерфейса"
|
||||||
STR_LINE_SPACING: "Межстрочный интервал"
|
STR_LINE_SPACING: "Межстрочный интервал"
|
||||||
@@ -138,10 +140,13 @@ STR_FORCE_REFRESH: "Обновление экрана"
|
|||||||
STR_PORTRAIT: "Портрет"
|
STR_PORTRAIT: "Портрет"
|
||||||
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
||||||
STR_INVERTED: "Инверсия"
|
STR_INVERTED: "Инверсия"
|
||||||
|
STR_ORIENTATION_INVERTED: "Портрет 180°"
|
||||||
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
||||||
STR_PREV_NEXT: "Назад/Вперёд"
|
STR_PREV_NEXT: "Назад/Вперёд"
|
||||||
STR_NEXT_PREV: "Вперёд/Назад"
|
STR_NEXT_PREV: "Вперёд/Назад"
|
||||||
STR_DISABLED: "Выключены"
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Закладка"
|
||||||
|
STR_DISABLED: "Выключено"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
STR_SMALL: "Маленький"
|
STR_SMALL: "Маленький"
|
||||||
@@ -176,8 +181,7 @@ STR_DOWNLOADING: "Загрузка..."
|
|||||||
STR_DOWNLOAD_FAILED: "Ошибка загрузки"
|
STR_DOWNLOAD_FAILED: "Ошибка загрузки"
|
||||||
STR_ERROR_MSG: "Ошибка:"
|
STR_ERROR_MSG: "Ошибка:"
|
||||||
STR_UNNAMED: "Без имени"
|
STR_UNNAMED: "Без имени"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Удерживайте ОТКРЫТЬ для удаления закладки"
|
STR_HOLD_OPEN_TO_DELETE: "Удерживайте Открыть для удаления"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Удерживайте ВЫБРАТЬ для добавления закладки"
|
|
||||||
STR_NO_SERVER_URL: "URL сервера не настроен"
|
STR_NO_SERVER_URL: "URL сервера не настроен"
|
||||||
STR_FETCH_FEED_FAILED: "Не удалось получить ленту"
|
STR_FETCH_FEED_FAILED: "Не удалось получить ленту"
|
||||||
STR_PARSE_FEED_FAILED: "Не удалось обработать ленту"
|
STR_PARSE_FEED_FAILED: "Не удалось обработать ленту"
|
||||||
@@ -195,6 +199,7 @@ STR_HOME: "« Главная"
|
|||||||
STR_SELECT: "Выбрать"
|
STR_SELECT: "Выбрать"
|
||||||
STR_SELECTED: "Выбран"
|
STR_SELECTED: "Выбран"
|
||||||
STR_TOGGLE: "Выбор"
|
STR_TOGGLE: "Выбор"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Переключить закладку"
|
||||||
STR_CONFIRM: "Подтв."
|
STR_CONFIRM: "Подтв."
|
||||||
STR_CANCEL: "Отмена"
|
STR_CANCEL: "Отмена"
|
||||||
STR_CONNECT: "Подкл."
|
STR_CONNECT: "Подкл."
|
||||||
@@ -257,6 +262,7 @@ STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки"
|
STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки"
|
||||||
STR_BOOKMARKS: "Закладки"
|
STR_BOOKMARKS: "Закладки"
|
||||||
STR_BOOKMARK_ADDED: "Закладка добавлена"
|
STR_BOOKMARK_ADDED: "Закладка добавлена"
|
||||||
|
STR_BOOKMARK_REMOVED: "Закладка удалена"
|
||||||
STR_OPDS_BROWSER: "OPDS браузер"
|
STR_OPDS_BROWSER: "OPDS браузер"
|
||||||
STR_SEARCH: "Поиск"
|
STR_SEARCH: "Поиск"
|
||||||
STR_COVER_CUSTOM: "Обложка + Свой"
|
STR_COVER_CUSTOM: "Обложка + Свой"
|
||||||
@@ -377,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ошибка записи прошивки"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!"
|
||||||
STR_RECOVERY_MODE: "Режим восстановления"
|
STR_RECOVERY_MODE: "Режим восстановления"
|
||||||
STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его"
|
STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его"
|
||||||
|
STR_MANAGE_THEMES: "Управление темами"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
|
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_FAMILY: "Rodina písiem čítačky"
|
||||||
STR_FONT_SIZE: "Veľkosť písma rozhrania"
|
STR_FONT_SIZE: "Veľkosť písma rozhrania"
|
||||||
STR_LINE_SPACING: "Riadkovanie čítačky"
|
STR_LINE_SPACING: "Riadkovanie čítačky"
|
||||||
@@ -136,7 +137,8 @@ STR_PAGE_TURN: "Otáčanie stránok"
|
|||||||
STR_FORCE_REFRESH: "Obnoviť obrazovku"
|
STR_FORCE_REFRESH: "Obnoviť obrazovku"
|
||||||
STR_PORTRAIT: "Na výšku"
|
STR_PORTRAIT: "Na výšku"
|
||||||
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
|
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_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
|
||||||
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
|
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
|
||||||
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
|
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
|
||||||
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Sťahovanie..."
|
|||||||
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
|
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
|
||||||
STR_ERROR_MSG: "Chyba:"
|
STR_ERROR_MSG: "Chyba:"
|
||||||
STR_UNNAMED: "Nepomenované"
|
STR_UNNAMED: "Nepomenované"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Podrž potvrdiť pre vymazanie"
|
STR_HOLD_OPEN_TO_DELETE: "Podržte Otvoriť pre vymazanie"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Podrž tlačidlo Potvrdiť pre vytvorenie záložky."
|
|
||||||
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
|
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
|
||||||
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
|
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
|
||||||
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
|
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
|
||||||
@@ -193,7 +194,8 @@ STR_EXIT: "« Koniec"
|
|||||||
STR_HOME: "« Domov"
|
STR_HOME: "« Domov"
|
||||||
STR_SELECT: "Vybrať"
|
STR_SELECT: "Vybrať"
|
||||||
STR_SELECTED: "Vybrané"
|
STR_SELECTED: "Vybrané"
|
||||||
STR_TOGGLE: "Prepnúť"
|
STR_TOGGLE: "Prepnúť"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Prepnúť záložku"
|
||||||
STR_CONFIRM: "Potvrdiť"
|
STR_CONFIRM: "Potvrdiť"
|
||||||
STR_CANCEL: "Zrušiť"
|
STR_CANCEL: "Zrušiť"
|
||||||
STR_CONNECT: "Pripojiť"
|
STR_CONNECT: "Pripojiť"
|
||||||
@@ -254,8 +256,9 @@ STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
|||||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||||
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
|
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
|
||||||
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
|
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
|
||||||
STR_BOOKMARKS: "Záložky"
|
STR_BOOKMARKS: "Záložky"
|
||||||
STR_BOOKMARK_ADDED: "Záložka pridaná."
|
STR_BOOKMARK_ADDED: "Záložka pridaná."
|
||||||
|
STR_BOOKMARK_REMOVED: "Záložka odstránená."
|
||||||
STR_OPDS_BROWSER: "Prehliadač OPDS"
|
STR_OPDS_BROWSER: "Prehliadač OPDS"
|
||||||
STR_SEARCH: "Hľadať"
|
STR_SEARCH: "Hľadať"
|
||||||
STR_COVER_CUSTOM: "Obálka + Vlastné"
|
STR_COVER_CUSTOM: "Obálka + Vlastné"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Orientacija branja"
|
|||||||
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
||||||
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
|
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_FAMILY: "Pisava bralnika"
|
||||||
STR_FONT_SIZE: "Velikost pisave"
|
STR_FONT_SIZE: "Velikost pisave"
|
||||||
STR_LINE_SPACING: "Razmik med vrsticami"
|
STR_LINE_SPACING: "Razmik med vrsticami"
|
||||||
@@ -131,7 +132,8 @@ STR_SLEEP: "Spanje"
|
|||||||
STR_PAGE_TURN: "Obračanje strani"
|
STR_PAGE_TURN: "Obračanje strani"
|
||||||
STR_PORTRAIT: "Pokončno"
|
STR_PORTRAIT: "Pokončno"
|
||||||
STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)"
|
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_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)"
|
||||||
STR_PREV_NEXT: "Nazaj/Naprej"
|
STR_PREV_NEXT: "Nazaj/Naprej"
|
||||||
STR_NEXT_PREV: "Naprej/Nazaj"
|
STR_NEXT_PREV: "Naprej/Nazaj"
|
||||||
@@ -183,6 +185,9 @@ STR_HOME: "« Domov"
|
|||||||
STR_SELECT: "Izberi"
|
STR_SELECT: "Izberi"
|
||||||
STR_SELECTED: "Izbrano"
|
STR_SELECTED: "Izbrano"
|
||||||
STR_TOGGLE: "Preklopi"
|
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_CONFIRM: "Potrdi"
|
||||||
STR_CANCEL: "Prekliči"
|
STR_CANCEL: "Prekliči"
|
||||||
STR_CONNECT: "Poveži"
|
STR_CONNECT: "Poveži"
|
||||||
@@ -295,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
|||||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||||
STR_TILT_PAGE_TURN: "Obračanje s priklonom"
|
STR_TILT_PAGE_TURN: "Obračanje s priklonom"
|
||||||
|
STR_MANAGE_THEMES: "Upravljanje tem"
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambiar orient."
|
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_FAMILY: "Tipografía"
|
||||||
STR_FONT_SIZE: "Tamaño"
|
STR_FONT_SIZE: "Tamaño"
|
||||||
STR_LINE_SPACING: "Interlineado"
|
STR_LINE_SPACING: "Interlineado"
|
||||||
@@ -136,9 +138,12 @@ STR_FORCE_REFRESH: "Refrescar pant."
|
|||||||
STR_PORTRAIT: "Vertical"
|
STR_PORTRAIT: "Vertical"
|
||||||
STR_LANDSCAPE_CW: "Horizontal (horario)"
|
STR_LANDSCAPE_CW: "Horizontal (horario)"
|
||||||
STR_INVERTED: "Invertido"
|
STR_INVERTED: "Invertido"
|
||||||
|
STR_ORIENTATION_INVERTED: "Al revés"
|
||||||
STR_LANDSCAPE_CCW: "Horizontal (antihorario)"
|
STR_LANDSCAPE_CCW: "Horizontal (antihorario)"
|
||||||
STR_PREV_NEXT: "Ant./Sig."
|
STR_PREV_NEXT: "Ant./Sig."
|
||||||
STR_NEXT_PREV: "Sig./Ant."
|
STR_NEXT_PREV: "Sig./Ant."
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Marcador"
|
||||||
STR_DISABLED: "Desactivados"
|
STR_DISABLED: "Desactivados"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
@@ -174,8 +179,7 @@ STR_DOWNLOADING: "Descargando..."
|
|||||||
STR_DOWNLOAD_FAILED: "Fallo de descarga"
|
STR_DOWNLOAD_FAILED: "Fallo de descarga"
|
||||||
STR_ERROR_MSG: "Error:"
|
STR_ERROR_MSG: "Error:"
|
||||||
STR_UNNAMED: "Sin nombre"
|
STR_UNNAMED: "Sin nombre"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Mantenga pulsado Confirmar para borrar"
|
STR_HOLD_OPEN_TO_DELETE: "Mantenga pulsado Abrir para borrar"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Mantenga pulsado Confirmar en el lector para crear un marcador."
|
|
||||||
STR_NO_SERVER_URL: "No se configuró URL de servidor"
|
STR_NO_SERVER_URL: "No se configuró URL de servidor"
|
||||||
STR_FETCH_FEED_FAILED: "Fallo al obtener el feed"
|
STR_FETCH_FEED_FAILED: "Fallo al obtener el feed"
|
||||||
STR_PARSE_FEED_FAILED: "Fallo al procesar el feed"
|
STR_PARSE_FEED_FAILED: "Fallo al procesar el feed"
|
||||||
@@ -193,6 +197,7 @@ STR_HOME: "« Inicio"
|
|||||||
STR_SELECT: "Selecc."
|
STR_SELECT: "Selecc."
|
||||||
STR_SELECTED: "Seleccionado"
|
STR_SELECTED: "Seleccionado"
|
||||||
STR_TOGGLE: "Cambiar"
|
STR_TOGGLE: "Cambiar"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||||
STR_CONFIRM: "Confirmar"
|
STR_CONFIRM: "Confirmar"
|
||||||
STR_CANCEL: "Cancelar"
|
STR_CANCEL: "Cancelar"
|
||||||
STR_CONNECT: "Conectar"
|
STR_CONNECT: "Conectar"
|
||||||
@@ -256,6 +261,7 @@ STR_QUICK_RESUME_TIMEOUT: "Reanudación rápida tras tiempo"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales"
|
STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales"
|
||||||
STR_BOOKMARKS: "Marcadores"
|
STR_BOOKMARKS: "Marcadores"
|
||||||
STR_BOOKMARK_ADDED: "Marcador añadido."
|
STR_BOOKMARK_ADDED: "Marcador añadido."
|
||||||
|
STR_BOOKMARK_REMOVED: "Marcador eliminado."
|
||||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||||
STR_SEARCH: "Buscar"
|
STR_SEARCH: "Buscar"
|
||||||
STR_COVER_CUSTOM: "Portada + Pers."
|
STR_COVER_CUSTOM: "Portada + Pers."
|
||||||
@@ -318,8 +324,9 @@ STR_BOOK_S_STYLE: "Estilo del libro"
|
|||||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||||
STR_FOCUS_READING: "Lectura enfocada"
|
STR_FOCUS_READING: "Lectura enfocada"
|
||||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
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_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_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
||||||
STR_LINK: "[enlace]"
|
STR_LINK: "[enlace]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||||
@@ -376,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Falló la escritura del firmware"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!"
|
||||||
STR_RECOVERY_MODE: "Modo de recuperación"
|
STR_RECOVERY_MODE: "Modo de recuperación"
|
||||||
STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo"
|
STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo"
|
||||||
|
STR_MANAGE_THEMES: "Gestionar temas"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ändra orientering"
|
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_FAMILY: "Eboksläsarens typsnittsfamilj"
|
||||||
STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek"
|
STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek"
|
||||||
STR_LINE_SPACING: "Eboksläsarens linjemellanrum"
|
STR_LINE_SPACING: "Eboksläsarens linjemellanrum"
|
||||||
@@ -137,6 +138,7 @@ STR_FORCE_REFRESH: "Uppdatera skärmen"
|
|||||||
STR_PORTRAIT: "Porträtt"
|
STR_PORTRAIT: "Porträtt"
|
||||||
STR_LANDSCAPE_CW: "Landskap medurs"
|
STR_LANDSCAPE_CW: "Landskap medurs"
|
||||||
STR_INVERTED: "Inverterad"
|
STR_INVERTED: "Inverterad"
|
||||||
|
STR_ORIENTATION_INVERTED: "Porträtt 180°"
|
||||||
STR_LANDSCAPE_CCW: "Landskap moturs"
|
STR_LANDSCAPE_CCW: "Landskap moturs"
|
||||||
STR_PREV_NEXT: "Förra/Nästa"
|
STR_PREV_NEXT: "Förra/Nästa"
|
||||||
STR_NEXT_PREV: "Nästa/Förra"
|
STR_NEXT_PREV: "Nästa/Förra"
|
||||||
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Laddar ner…"
|
|||||||
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
|
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
|
||||||
STR_ERROR_MSG: "Fel:"
|
STR_ERROR_MSG: "Fel:"
|
||||||
STR_UNNAMED: "Ej namngiven"
|
STR_UNNAMED: "Ej namngiven"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Håll ned Bekräfta för att radera"
|
STR_HOLD_OPEN_TO_DELETE: "Håll ned Öppna för att radera"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Håll Bekräfta i läsaren för att skapa ett bokmärke."
|
|
||||||
STR_NO_SERVER_URL: "Ingen serveradress konfigurerad"
|
STR_NO_SERVER_URL: "Ingen serveradress konfigurerad"
|
||||||
STR_FETCH_FEED_FAILED: "Misslyckades att hämta flöde"
|
STR_FETCH_FEED_FAILED: "Misslyckades att hämta flöde"
|
||||||
STR_PARSE_FEED_FAILED: "Misslyckades att analysera flöde"
|
STR_PARSE_FEED_FAILED: "Misslyckades att analysera flöde"
|
||||||
@@ -194,6 +195,7 @@ STR_HOME: "« Hem"
|
|||||||
STR_SELECT: "Välj "
|
STR_SELECT: "Välj "
|
||||||
STR_SELECTED: "Vald"
|
STR_SELECTED: "Vald"
|
||||||
STR_TOGGLE: "Växla"
|
STR_TOGGLE: "Växla"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Växla bokmärke"
|
||||||
STR_CONFIRM: "Bekräfta"
|
STR_CONFIRM: "Bekräfta"
|
||||||
STR_CANCEL: "Avbryt"
|
STR_CANCEL: "Avbryt"
|
||||||
STR_CONNECT: "Anslut"
|
STR_CONNECT: "Anslut"
|
||||||
@@ -257,6 +259,7 @@ STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar"
|
STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar"
|
||||||
STR_BOOKMARKS: "Bokmärken"
|
STR_BOOKMARKS: "Bokmärken"
|
||||||
STR_BOOKMARK_ADDED: "Bokmärke tillagt."
|
STR_BOOKMARK_ADDED: "Bokmärke tillagt."
|
||||||
|
STR_BOOKMARK_REMOVED: "Bokmärke borttaget."
|
||||||
STR_OPDS_BROWSER: "OPDS-webbläsare"
|
STR_OPDS_BROWSER: "OPDS-webbläsare"
|
||||||
STR_SEARCH: "Sök"
|
STR_SEARCH: "Sök"
|
||||||
STR_COVER_CUSTOM: "Omslag + Valfri"
|
STR_COVER_CUSTOM: "Omslag + Valfri"
|
||||||
@@ -377,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
|
||||||
STR_RECOVERY_MODE: "Återställningsläge"
|
STR_RECOVERY_MODE: "Återställningsläge"
|
||||||
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
|
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
|
||||||
|
STR_MANAGE_THEMES: "Hantera teman"
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
|
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_FAMILY: "Okuyucu Yazı Tipi Ailesi"
|
||||||
STR_FONT_SIZE: "Arayüz Yazı Boyutu"
|
STR_FONT_SIZE: "Arayüz Yazı Boyutu"
|
||||||
STR_LINE_SPACING: "Okuyucu Satır Aralığı"
|
STR_LINE_SPACING: "Okuyucu Satır Aralığı"
|
||||||
@@ -129,7 +130,8 @@ STR_SLEEP: "Uyku"
|
|||||||
STR_PAGE_TURN: "Sayfa Çevirme"
|
STR_PAGE_TURN: "Sayfa Çevirme"
|
||||||
STR_PORTRAIT: "Dikey"
|
STR_PORTRAIT: "Dikey"
|
||||||
STR_LANDSCAPE_CW: "Yatay (Saat Yönü)"
|
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_LANDSCAPE_CCW: "Yatay (Saat Yönü Tersi)"
|
||||||
STR_PREV_NEXT: "Önceki/Sonraki"
|
STR_PREV_NEXT: "Önceki/Sonraki"
|
||||||
STR_NEXT_PREV: "Sonraki/Önceki"
|
STR_NEXT_PREV: "Sonraki/Önceki"
|
||||||
@@ -180,6 +182,9 @@ STR_EXIT: "« Çıkış"
|
|||||||
STR_HOME: "« Ana Sayfa"
|
STR_HOME: "« Ana Sayfa"
|
||||||
STR_SELECT: "Seç"
|
STR_SELECT: "Seç"
|
||||||
STR_TOGGLE: "Değiştir"
|
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_CONFIRM: "Onayla"
|
||||||
STR_CANCEL: "İptal"
|
STR_CANCEL: "İptal"
|
||||||
STR_CONNECT: "Bağlan"
|
STR_CONNECT: "Bağlan"
|
||||||
@@ -298,3 +303,4 @@ STR_SELECTED: "Seçili"
|
|||||||
STR_SHOW: "Göster"
|
STR_SHOW: "Göster"
|
||||||
STR_TITLE: "Başlık"
|
STR_TITLE: "Başlık"
|
||||||
STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme"
|
STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme"
|
||||||
|
STR_MANAGE_THEMES: "Temaları Yönet"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ STR_CAT_READER: "Читач"
|
|||||||
STR_CAT_CONTROLS: "Кнопки"
|
STR_CAT_CONTROLS: "Кнопки"
|
||||||
STR_CAT_SYSTEM: "Система"
|
STR_CAT_SYSTEM: "Система"
|
||||||
STR_SLEEP_SCREEN: "Екран у режимі сну"
|
STR_SLEEP_SCREEN: "Екран у режимі сну"
|
||||||
|
STR_QUICK_RESUME_TIMEOUT: "Швидке поверн. після таймауту"
|
||||||
STR_SLEEP_COVER_MODE: "Режим заповнення"
|
STR_SLEEP_COVER_MODE: "Режим заповнення"
|
||||||
STR_HIDE_BATTERY: "Приховати % батареї"
|
STR_HIDE_BATTERY: "Приховати % батареї"
|
||||||
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
|
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
|
||||||
@@ -77,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настик
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Єхидна, ґава, їжак ще й шиплячі плазуни бігцем форсують Янцзи"
|
||||||
STR_FONT_FAMILY: "Шрифт"
|
STR_FONT_FAMILY: "Шрифт"
|
||||||
STR_FONT_SIZE: "Розмір шрифту"
|
STR_FONT_SIZE: "Розмір шрифту"
|
||||||
STR_LINE_SPACING: "Міжрядковий інтервал"
|
STR_LINE_SPACING: "Міжрядковий інтервал"
|
||||||
@@ -85,8 +87,8 @@ STR_PARA_ALIGNMENT: "Вирівнювання тексту"
|
|||||||
STR_HYPHENATION: "Перенесення слів"
|
STR_HYPHENATION: "Перенесення слів"
|
||||||
STR_TIME_TO_SLEEP: "Перехід в режим сну"
|
STR_TIME_TO_SLEEP: "Перехід в режим сну"
|
||||||
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
|
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
|
||||||
STR_REMOVE_READ_FROM_RECENTS: "Очищати прочитані книги зі списку останніх"
|
STR_REMOVE_READ_FROM_RECENTS: "Приховувати прочитані книги"
|
||||||
STR_MOVE_FINISHED_TO_READ: "Переміщати прочитані книги до теки Read"
|
STR_MOVE_FINISHED_TO_READ: "Переміщати прочит. в теку Read"
|
||||||
STR_REFRESH_FREQ: "Частота оновлення екрану"
|
STR_REFRESH_FREQ: "Частота оновлення екрану"
|
||||||
STR_KOREADER_SYNC: "Синхронізація KOReader"
|
STR_KOREADER_SYNC: "Синхронізація KOReader"
|
||||||
STR_CHECK_UPDATES: "Перевірити оновлення системи"
|
STR_CHECK_UPDATES: "Перевірити оновлення системи"
|
||||||
@@ -135,10 +137,12 @@ STR_PAGE_TURN: "Наст. сторінка"
|
|||||||
STR_FORCE_REFRESH: "Оновити екран"
|
STR_FORCE_REFRESH: "Оновити екран"
|
||||||
STR_PORTRAIT: "Книжкова"
|
STR_PORTRAIT: "Книжкова"
|
||||||
STR_LANDSCAPE_CW: "Альбом. за год."
|
STR_LANDSCAPE_CW: "Альбом. за год."
|
||||||
STR_INVERTED: "Перевернутий"
|
STR_INVERTED: "Інверсія"
|
||||||
|
STR_ORIENTATION_INVERTED: "Книжкова 180°"
|
||||||
STR_LANDSCAPE_CCW: "Альбом. проти год."
|
STR_LANDSCAPE_CCW: "Альбом. проти год."
|
||||||
STR_PREV_NEXT: "Попер/Наст"
|
STR_PREV_NEXT: "Попер/Наст"
|
||||||
STR_NEXT_PREV: "Наст/Попер"
|
STR_NEXT_PREV: "Наст/Попер"
|
||||||
|
STR_DISABLED: "Вимкнуто"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
STR_SMALL: "Малий"
|
STR_SMALL: "Малий"
|
||||||
@@ -173,6 +177,7 @@ STR_DOWNLOADING: "Завантаження..."
|
|||||||
STR_DOWNLOAD_FAILED: "Завантаження не вдалося"
|
STR_DOWNLOAD_FAILED: "Завантаження не вдалося"
|
||||||
STR_ERROR_MSG: "Помилка:"
|
STR_ERROR_MSG: "Помилка:"
|
||||||
STR_UNNAMED: "Без назви"
|
STR_UNNAMED: "Без назви"
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Утримуйте Відкрити, щоб видалити"
|
||||||
STR_NO_SERVER_URL: "URL сервера не налаштовано"
|
STR_NO_SERVER_URL: "URL сервера не налаштовано"
|
||||||
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
|
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
|
||||||
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
|
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
|
||||||
@@ -190,6 +195,7 @@ STR_HOME: "« Додому"
|
|||||||
STR_SELECT: "Вибрати"
|
STR_SELECT: "Вибрати"
|
||||||
STR_SELECTED: "Вибрано"
|
STR_SELECTED: "Вибрано"
|
||||||
STR_TOGGLE: "Обрати"
|
STR_TOGGLE: "Обрати"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Перемкнути закладку"
|
||||||
STR_CONFIRM: "Підтвердити"
|
STR_CONFIRM: "Підтвердити"
|
||||||
STR_CANCEL: "Скасувати"
|
STR_CANCEL: "Скасувати"
|
||||||
STR_CONNECT: "Приєдн."
|
STR_CONNECT: "Приєдн."
|
||||||
@@ -228,18 +234,35 @@ STR_BATTERY: "Акумулятор"
|
|||||||
STR_XTC_STATUS_BAR: "XTC Рядок прогресу"
|
STR_XTC_STATUS_BAR: "XTC Рядок прогресу"
|
||||||
STR_BOTTOM: "Низ"
|
STR_BOTTOM: "Низ"
|
||||||
STR_TOP: "Верх"
|
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_UI_THEME: "Тема інтерфейсу"
|
||||||
STR_THEME_CLASSIC: "Класична"
|
STR_THEME_CLASSIC: "Класична"
|
||||||
STR_THEME_LYRA: "Lyra"
|
STR_THEME_LYRA: "Lyra"
|
||||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||||
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
|
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
|
||||||
STR_QUICK_RESUME_TIMEOUT: "Швидке продовження після таймауту"
|
|
||||||
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
|
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
|
||||||
|
STR_BOOKMARKS: "Закладки"
|
||||||
|
STR_BOOKMARK_ADDED: "Закладку додано"
|
||||||
|
STR_BOOKMARK_REMOVED: "Закладку видалено"
|
||||||
STR_OPDS_BROWSER: "Браузер OPDS"
|
STR_OPDS_BROWSER: "Браузер OPDS"
|
||||||
STR_SEARCH: "Пошук"
|
STR_SEARCH: "Пошук"
|
||||||
STR_COVER_CUSTOM: "Обкл. + власне"
|
STR_COVER_CUSTOM: "Обкл. + власне"
|
||||||
STR_QUICK_RESUME: "Швидке продовження"
|
STR_QUICK_RESUME: "Швидке поверн."
|
||||||
STR_MENU_RECENT_BOOKS: "Останні книги"
|
STR_MENU_RECENT_BOOKS: "Останні книги"
|
||||||
STR_REMOVE_FROM_RECENTS: "Видалити з останніх книг?"
|
STR_REMOVE_FROM_RECENTS: "Видалити з останніх книг?"
|
||||||
STR_NO_RECENT_BOOKS: "Немає останніх книг"
|
STR_NO_RECENT_BOOKS: "Немає останніх книг"
|
||||||
@@ -265,6 +288,7 @@ STR_GO_HOME_BUTTON: "На головну"
|
|||||||
STR_SYNC_PROGRESS: "Прогрес синхронізації"
|
STR_SYNC_PROGRESS: "Прогрес синхронізації"
|
||||||
STR_DELETE_CACHE: "Видалити кеш книги"
|
STR_DELETE_CACHE: "Видалити кеш книги"
|
||||||
STR_DELETE: "Видалити"
|
STR_DELETE: "Видалити"
|
||||||
|
STR_CONFIRM_DELETE_BOOKMARK: "Видалити цю закладку?"
|
||||||
STR_DISPLAY_QR: "Показати сторінку як QR-код"
|
STR_DISPLAY_QR: "Показати сторінку як QR-код"
|
||||||
STR_CHAPTER_PREFIX: "Розділ: "
|
STR_CHAPTER_PREFIX: "Розділ: "
|
||||||
STR_PAGES_SEPARATOR: " сторінок | "
|
STR_PAGES_SEPARATOR: " сторінок | "
|
||||||
@@ -297,14 +321,15 @@ STR_BOOK_S_STYLE: "Стиль книги"
|
|||||||
STR_EMBEDDED_STYLE: "Вбудований стиль"
|
STR_EMBEDDED_STYLE: "Вбудований стиль"
|
||||||
STR_FOCUS_READING: "Фокусне читання"
|
STR_FOCUS_READING: "Фокусне читання"
|
||||||
STR_OPDS_SERVER_URL: "URL сервера OPDS"
|
STR_OPDS_SERVER_URL: "URL сервера OPDS"
|
||||||
|
STR_PWR_BTN_FOOTNOTE_BACK: "Швидке повернення з приміток"
|
||||||
STR_SET_SLEEP_COVER: "Як обкл."
|
STR_SET_SLEEP_COVER: "Як обкл."
|
||||||
STR_FOOTNOTES: "Примітки"
|
STR_FOOTNOTES: "Примітки"
|
||||||
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
|
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
|
||||||
STR_LINK: "[посилання]"
|
STR_LINK: "[посилання]"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||||
STR_SLEEP_NEVER: "Ніколи"
|
STR_SLEEP_NEVER: "Ніколи"
|
||||||
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
||||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
|
||||||
STR_ADD_SERVER: "Додати сервер"
|
STR_ADD_SERVER: "Додати сервер"
|
||||||
STR_SERVER_NAME: "Назва сервера"
|
STR_SERVER_NAME: "Назва сервера"
|
||||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||||
@@ -355,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!"
|
||||||
STR_RECOVERY_MODE: "Режим відновлення"
|
STR_RECOVERY_MODE: "Режим відновлення"
|
||||||
STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його"
|
STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його"
|
||||||
|
STR_MANAGE_THEMES: "Керування темами"
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
|
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_FAMILY: "Família de fonts"
|
||||||
STR_FONT_SIZE: "Grandària de la lletra (UI)"
|
STR_FONT_SIZE: "Grandària de la lletra (UI)"
|
||||||
STR_LINE_SPACING: "Interlineat del lector"
|
STR_LINE_SPACING: "Interlineat del lector"
|
||||||
@@ -139,9 +141,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
|
|||||||
STR_PORTRAIT: "Vertical"
|
STR_PORTRAIT: "Vertical"
|
||||||
STR_LANDSCAPE_CW: "Horitzontal horari"
|
STR_LANDSCAPE_CW: "Horitzontal horari"
|
||||||
STR_INVERTED: "Invertit"
|
STR_INVERTED: "Invertit"
|
||||||
|
STR_ORIENTATION_INVERTED: "Vertical 180°"
|
||||||
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
|
||||||
STR_PREV_NEXT: "Anterior/Següent"
|
STR_PREV_NEXT: "Anterior/Següent"
|
||||||
STR_NEXT_PREV: "Següent/Anterior"
|
STR_NEXT_PREV: "Següent/Anterior"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Punt de llibre"
|
||||||
STR_DISABLED: "Desactivats"
|
STR_DISABLED: "Desactivats"
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
@@ -176,8 +181,7 @@ STR_DOWNLOADING: "S'està baixant..."
|
|||||||
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
|
||||||
STR_ERROR_MSG: "Error:"
|
STR_ERROR_MSG: "Error:"
|
||||||
STR_UNNAMED: "Sense nom"
|
STR_UNNAMED: "Sense nom"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
|
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
|
|
||||||
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
|
||||||
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
|
||||||
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
|
||||||
@@ -193,6 +197,7 @@ STR_HOME: "« Inici"
|
|||||||
STR_SELECT: "Selecciona"
|
STR_SELECT: "Selecciona"
|
||||||
STR_SELECTED: "Seleccionat"
|
STR_SELECTED: "Seleccionat"
|
||||||
STR_TOGGLE: "Canvia"
|
STR_TOGGLE: "Canvia"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Canvia punt de llibre"
|
||||||
STR_CONFIRM: "Confirma"
|
STR_CONFIRM: "Confirma"
|
||||||
STR_CANCEL: "Cancel·la"
|
STR_CANCEL: "Cancel·la"
|
||||||
STR_CONNECT: "Connecta"
|
STR_CONNECT: "Connecta"
|
||||||
@@ -236,6 +241,7 @@ STR_SUNLIGHT_FADING_FIX: "Correcció de l'esvaïment pel sol"
|
|||||||
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
|
||||||
STR_BOOKMARKS: "Punts de llibre"
|
STR_BOOKMARKS: "Punts de llibre"
|
||||||
STR_BOOKMARK_ADDED: "S'ha afegit el punt 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_OPDS_BROWSER: "Navegador OPDS"
|
||||||
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
STR_COVER_CUSTOM: "Portada + Personalitzat"
|
||||||
STR_QUICK_RESUME: "Represa ràpida"
|
STR_QUICK_RESUME: "Represa ràpida"
|
||||||
@@ -297,6 +303,7 @@ STR_BOOK_S_STYLE: "Estil del llibre"
|
|||||||
STR_EMBEDDED_STYLE: "Estil incrustat"
|
STR_EMBEDDED_STYLE: "Estil incrustat"
|
||||||
STR_FOCUS_READING: "Lectura enfocada"
|
STR_FOCUS_READING: "Lectura enfocada"
|
||||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
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_FOOTNOTES: "Notes al peu"
|
||||||
STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina"
|
STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina"
|
||||||
STR_LINK: "[enllaç]"
|
STR_LINK: "[enllaç]"
|
||||||
@@ -376,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware"
|
|||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
|
||||||
STR_RECOVERY_MODE: "Mode de recuperació"
|
STR_RECOVERY_MODE: "Mode de recuperació"
|
||||||
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
||||||
|
STR_MANAGE_THEMES: "Gestiona els temes"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
|
|||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Nhảy chương"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Nhảy chương"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Đổi hướ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_FAMILY: "Phông chữ trình đọc"
|
||||||
STR_FONT_SIZE: "Cỡ chữ trình đọc"
|
STR_FONT_SIZE: "Cỡ chữ trình đọc"
|
||||||
STR_LINE_SPACING: "Giãn dòng trình đọc"
|
STR_LINE_SPACING: "Giãn dòng trình đọc"
|
||||||
@@ -136,7 +137,8 @@ STR_PAGE_TURN: "Lật trang"
|
|||||||
STR_FORCE_REFRESH: "Làm tươi màn hình"
|
STR_FORCE_REFRESH: "Làm tươi màn hình"
|
||||||
STR_PORTRAIT: "Dọc"
|
STR_PORTRAIT: "Dọc"
|
||||||
STR_LANDSCAPE_CW: "Ngang (thuận)"
|
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_LANDSCAPE_CCW: "Ngang (ngược)"
|
||||||
STR_PREV_NEXT: "Trước/Sau"
|
STR_PREV_NEXT: "Trước/Sau"
|
||||||
STR_NEXT_PREV: "Sau/Trước"
|
STR_NEXT_PREV: "Sau/Trước"
|
||||||
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Đang tải về..."
|
|||||||
STR_DOWNLOAD_FAILED: "Tải về thất bại"
|
STR_DOWNLOAD_FAILED: "Tải về thất bại"
|
||||||
STR_ERROR_MSG: "Lỗi:"
|
STR_ERROR_MSG: "Lỗi:"
|
||||||
STR_UNNAMED: "Không tên"
|
STR_UNNAMED: "Không tên"
|
||||||
STR_HOLD_CONFIRM_TO_DELETE: "Giữ Xác nhận để xóa"
|
STR_HOLD_OPEN_TO_DELETE: "Giữ Mở để xóa"
|
||||||
STR_BOOKMARK_INSTRUCTIONS: "Giữ Xác nhận trong trình đọc để tạo dấu trang."
|
|
||||||
STR_NO_SERVER_URL: "Chưa cấu hình URL máy chủ"
|
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_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"
|
STR_PARSE_FEED_FAILED: "Không phân tích được nguồn cấp"
|
||||||
@@ -194,6 +195,7 @@ STR_HOME: "« Thư viện"
|
|||||||
STR_SELECT: "Chọn"
|
STR_SELECT: "Chọn"
|
||||||
STR_SELECTED: "Đã chọn"
|
STR_SELECTED: "Đã chọn"
|
||||||
STR_TOGGLE: "Bật/Tắt"
|
STR_TOGGLE: "Bật/Tắt"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Bật/tắt dấu trang"
|
||||||
STR_CONFIRM: "Xác nhận"
|
STR_CONFIRM: "Xác nhận"
|
||||||
STR_CANCEL: "Hủy"
|
STR_CANCEL: "Hủy"
|
||||||
STR_CONNECT: "Kết nối"
|
STR_CONNECT: "Kết nối"
|
||||||
@@ -256,6 +258,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_REMAP_FRONT_BUTTONS: "Gán lại nút mặt trước"
|
||||||
STR_BOOKMARKS: "Dấu trang"
|
STR_BOOKMARKS: "Dấu trang"
|
||||||
STR_BOOKMARK_ADDED: "Đã thêm 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_OPDS_BROWSER: "Trình duyệt OPDS"
|
||||||
STR_SEARCH: "Tìm kiếm"
|
STR_SEARCH: "Tìm kiếm"
|
||||||
STR_COVER_CUSTOM: "Ảnh bìa + Tùy chỉnh"
|
STR_COVER_CUSTOM: "Ảnh bìa + Tùy chỉnh"
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ namespace {
|
|||||||
constexpr int MAX_MCU_HEIGHT = 16;
|
constexpr int MAX_MCU_HEIGHT = 16;
|
||||||
constexpr size_t JPEG_DECODER_SIZE = 20 * 1024;
|
constexpr size_t JPEG_DECODER_SIZE = 20 * 1024;
|
||||||
constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 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.
|
// Static file pointer for JPEGDEC open callback.
|
||||||
// Safe in single-threaded embedded context; never accessed concurrently.
|
// Safe in single-threaded embedded context; never accessed concurrently.
|
||||||
@@ -208,6 +209,9 @@ struct BmpConvertCtx {
|
|||||||
bool needsScaling;
|
bool needsScaling;
|
||||||
uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point
|
uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point
|
||||||
uint32_t scaleY_fp;
|
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)
|
// 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
|
// 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[]> rowAccum;
|
||||||
std::unique_ptr<uint32_t[]> rowCount;
|
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<uint8_t[]> bmpRow;
|
||||||
|
|
||||||
std::unique_ptr<AtkinsonDitherer> atkinsonDitherer;
|
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);
|
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
|
// Flush one scaled output row from Y-axis accumulators and advance currentOutY
|
||||||
static void flushScaledRow(BmpConvertCtx* ctx) {
|
static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||||
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
|
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++) {
|
for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) {
|
||||||
const uint8_t* srcRow = ctx->mcuBuf.get() + (y - blockY) * ctx->srcWidth;
|
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
|
// 1:1 — outWidth == srcWidth, write directly
|
||||||
writeOutputRow(ctx, srcRow, y);
|
writeOutputRow(ctx, srcRow, y);
|
||||||
} else {
|
} else {
|
||||||
@@ -472,6 +567,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
|||||||
needsScaling = true;
|
needsScaling = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool smoothUpscale =
|
||||||
|
progressiveDecode && needsScaling && scaleSrcWidth <= outWidth && scaleSrcHeight <= outHeight;
|
||||||
|
|
||||||
// Write BMP header with output dimensions
|
// Write BMP header with output dimensions
|
||||||
int bytesPerRow;
|
int bytesPerRow;
|
||||||
if (USE_8BIT_OUTPUT && !oneBit) {
|
if (USE_8BIT_OUTPUT && !oneBit) {
|
||||||
@@ -496,6 +594,11 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
|||||||
ctx.needsScaling = needsScaling;
|
ctx.needsScaling = needsScaling;
|
||||||
ctx.scaleX_fp = scaleX_fp;
|
ctx.scaleX_fp = scaleX_fp;
|
||||||
ctx.scaleY_fp = scaleY_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;
|
ctx.error = false;
|
||||||
|
|
||||||
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
|
// 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;
|
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.rowAccum = makeUniqueNoThrow<uint32_t[]>(outWidth);
|
||||||
ctx.rowCount = makeUniqueNoThrow<uint32_t[]>(outWidth);
|
ctx.rowCount = makeUniqueNoThrow<uint32_t[]>(outWidth);
|
||||||
if (!ctx.rowAccum || !ctx.rowCount) {
|
if (!ctx.rowAccum || !ctx.rowCount) {
|
||||||
@@ -549,6 +665,10 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
|||||||
|
|
||||||
rc = jpeg->decode(0, 0, 0);
|
rc = jpeg->decode(0, 0, 0);
|
||||||
|
|
||||||
|
if (rc == 1 && ctx.smoothUpscale && !ctx.error) {
|
||||||
|
finishSmoothUpscale(&ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if (rc != 1 || ctx.error) {
|
if (rc != 1 || ctx.error) {
|
||||||
LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError());
|
LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError());
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ int parseIndex(const std::string& xpath, const char* prefix, bool last = false)
|
|||||||
|
|
||||||
int parseCharOffset(const std::string& xpath) {
|
int parseCharOffset(const std::string& xpath) {
|
||||||
const size_t textPos = xpath.rfind("text()");
|
const size_t textPos = xpath.rfind("text()");
|
||||||
if (textPos == std::string::npos) return 0;
|
const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.');
|
||||||
const size_t dotPos = xpath.find('.', textPos);
|
|
||||||
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0;
|
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0;
|
||||||
int val = 0;
|
int val = 0;
|
||||||
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
|
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()) {
|
if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) {
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +127,7 @@ struct XPathStep {
|
|||||||
|
|
||||||
static constexpr int MAX_XPATH_DEPTH = 16;
|
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.
|
// 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"
|
// Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51"
|
||||||
// Fills steps with: {div,1}, {ul,1}, {li,4}
|
// 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;
|
if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0;
|
||||||
size_t pos = afterBracket + 1 + strlen(kBody);
|
size_t pos = afterBracket + 1 + strlen(kBody);
|
||||||
|
|
||||||
const size_t textPos = xpath.rfind("/text()");
|
size_t stepsEnd = xpath.rfind("/text()");
|
||||||
if (textPos == std::string::npos || textPos <= pos) return 0;
|
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;
|
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 slash = xpath.find('/', pos);
|
||||||
const size_t segEnd = (slash < textPos) ? slash : textPos;
|
const size_t segEnd = (slash < stepsEnd) ? slash : stepsEnd;
|
||||||
|
|
||||||
XPathStep& step = steps[count];
|
XPathStep& step = steps[count];
|
||||||
const size_t bracket = xpath.find('[', pos);
|
const size_t bracket = xpath.find('[', pos);
|
||||||
@@ -166,7 +178,7 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH])
|
|||||||
}
|
}
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
pos = (slash < textPos) ? slash + 1 : textPos;
|
pos = (slash < stepsEnd) ? slash + 1 : stepsEnd;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
@@ -223,10 +235,148 @@ class ParagraphStreamer final : public Print {
|
|||||||
char capturedAnchorId[MAX_ANCHOR_ID] = {};
|
char capturedAnchorId[MAX_ANCHOR_ID] = {};
|
||||||
int capturedAnchorIdLen = 0;
|
int capturedAnchorIdLen = 0;
|
||||||
bool capturingAnchorTag = false;
|
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 =
|
bool inAttrQuote =
|
||||||
false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close)
|
false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close)
|
||||||
char attrQuoteChar = 0;
|
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() {
|
void onVisibleCodepoint() {
|
||||||
totalVisChars++;
|
totalVisChars++;
|
||||||
@@ -286,15 +436,19 @@ class ParagraphStreamer final : public Print {
|
|||||||
void onOpenTag() {
|
void onOpenTag() {
|
||||||
htmlDepth++;
|
htmlDepth++;
|
||||||
|
|
||||||
|
if (nonVisibleDepth > 0 || isNonVisibleTag()) {
|
||||||
|
nonVisibleDepth++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (stepCount == 0) {
|
if (stepCount == 0) {
|
||||||
if (strcasecmp(tagName, "p") == 0) onLegacyP();
|
if (strcasecmp(tagName, "p") == 0) onLegacyP();
|
||||||
return;
|
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) {
|
if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) {
|
||||||
capturingAnchorTag = true;
|
beginAnchorIdScan();
|
||||||
idState = ID_SCAN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (revDone) return;
|
if (revDone) return;
|
||||||
@@ -315,6 +469,7 @@ class ParagraphStreamer final : public Print {
|
|||||||
stepEnteredAtDepth[matchedDepth] = htmlDepth;
|
stepEnteredAtDepth[matchedDepth] = htmlDepth;
|
||||||
matchedDepth++;
|
matchedDepth++;
|
||||||
if (matchedDepth == stepCount) {
|
if (matchedDepth == stepCount) {
|
||||||
|
beginAnchorIdScan();
|
||||||
paragraphAtMatch = pCount;
|
paragraphAtMatch = pCount;
|
||||||
liCountAtMatch = liCount;
|
liCountAtMatch = liCount;
|
||||||
revPFound = true;
|
revPFound = true;
|
||||||
@@ -332,6 +487,12 @@ class ParagraphStreamer final : public Print {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onCloseTag() {
|
void onCloseTag() {
|
||||||
|
if (nonVisibleDepth > 0) {
|
||||||
|
nonVisibleDepth--;
|
||||||
|
if (htmlDepth > 0) htmlDepth--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy mode: each direct child element closing advances the text node index.
|
// Legacy mode: each direct child element closing advances the text node index.
|
||||||
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) {
|
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) {
|
||||||
currentTextNode++;
|
currentTextNode++;
|
||||||
@@ -419,42 +580,12 @@ class ParagraphStreamer final : public Print {
|
|||||||
attrQuoteChar = 0;
|
attrQuoteChar = 0;
|
||||||
}
|
}
|
||||||
if (capturingAnchorTag) {
|
if (capturingAnchorTag) {
|
||||||
switch (idState) {
|
scanAnchorAttribute(c);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Only treat '/' as self-closing when outside a quoted attribute value.
|
// Only treat '/' as self-closing when outside a quoted attribute value.
|
||||||
if (c == '/' && !inAttrQuote) {
|
if (c == '/' && !inAttrQuote) {
|
||||||
|
endAnchorIdScan();
|
||||||
onCloseTag();
|
onCloseTag();
|
||||||
capturingAnchorTag = false;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -512,10 +643,13 @@ class ParagraphStreamer final : public Print {
|
|||||||
tagNameLen = 0;
|
tagNameLen = 0;
|
||||||
tagIsClose = false;
|
tagIsClose = false;
|
||||||
capturingAnchorTag = false;
|
capturingAnchorTag = false;
|
||||||
idState = ID_SCAN;
|
resetAnchorAttrScan();
|
||||||
inAttrQuote = false;
|
inAttrQuote = false;
|
||||||
attrQuoteChar = 0;
|
attrQuoteChar = 0;
|
||||||
} else if (c == '>') {
|
} else if (c == '>') {
|
||||||
|
if (tagState == TAG_ATTRS) {
|
||||||
|
endAnchorIdScan();
|
||||||
|
}
|
||||||
globalInTag = false;
|
globalInTag = false;
|
||||||
inAttrQuote = false;
|
inAttrQuote = false;
|
||||||
if (tagState == TAG_IN_NAME && tagNameLen > 0) {
|
if (tagState == TAG_IN_NAME && tagNameLen > 0) {
|
||||||
@@ -529,6 +663,9 @@ class ParagraphStreamer final : public Print {
|
|||||||
tagState = TAG_IDLE;
|
tagState = TAG_IDLE;
|
||||||
} else if (globalInTag) {
|
} else if (globalInTag) {
|
||||||
processByteInTag(c);
|
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 {
|
} else {
|
||||||
if (c == '&') {
|
if (c == '&') {
|
||||||
globalInEntity = true;
|
globalInEntity = true;
|
||||||
@@ -762,4 +899,4 @@ std::string ProgressMapper::generateXPath(const std::shared_ptr<Epub>& epub, int
|
|||||||
|
|
||||||
const int p = s.paragraphCount();
|
const int p = s.paragraphCount();
|
||||||
return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base;
|
return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,73 @@
|
|||||||
#include "Utf8.h"
|
#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) {
|
int utf8CodepointLen(const unsigned char c) {
|
||||||
if (c < 0x80) return 1; // 0xxxxxxx
|
if (c < 0x80) return 1; // 0xxxxxxx
|
||||||
if ((c >> 5) == 0x6) return 2; // 110xxxxx
|
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.
|
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
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.
|
// 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
|
// Returns the new length (<= len). If the buffer ends mid-sequence, the
|
||||||
// incomplete trailing bytes are excluded.
|
// 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,
|
// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation,
|
||||||
// and fullwidth forms — the ranges where word boundaries are implicit per character.
|
// and fullwidth forms — the ranges where word boundaries are implicit per character.
|
||||||
inline bool utf8IsCjkBreakable(const uint32_t cp) {
|
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 >= 0x3040 && cp <= 0x309F) // Hiragana
|
||||||
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|
||||||
|
|| (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo
|
||||||
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|
||||||
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|
||||||
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|
||||||
|
|| (cp >= 0xD7B0 && cp <= 0xD7FF) // Hangul Jamo Extended-B
|
||||||
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|
||||||
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|
||||||
|| (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation
|
|| (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]);
|
||||||
+35
-46
@@ -173,53 +173,37 @@ bool Xtc::generateCoverBmp() const {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write 1-bit BMP header (top-down row order)
|
||||||
|
BmpHeader bmpHeader;
|
||||||
|
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
|
||||||
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
|
||||||
|
|
||||||
|
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
|
||||||
|
|
||||||
|
// Write bitmap data
|
||||||
|
// BMP requires 4-byte row alignment
|
||||||
|
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
|
||||||
|
|
||||||
if (bitDepth == 2) {
|
if (bitDepth == 2) {
|
||||||
// XTH 2-bit mode: preserve all 4 gray levels in a 2-bit BMP so the sleep
|
// XTH 2-bit mode: Two bit planes, column-major order
|
||||||
// screen's grayscale pass can render them (a 1-bit cover would silently
|
|
||||||
// disable it). Source is two bit planes, column-major order:
|
|
||||||
// - Columns scanned right to left (x = width-1 down to 0)
|
// - Columns scanned right to left (x = width-1 down to 0)
|
||||||
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
||||||
// - First plane: Bit1, Second plane: Bit2
|
// - First plane: Bit1, Second plane: Bit2
|
||||||
// - Pixel value = (bit1 << 1) | bit2: 0=white, 1=dark gray,
|
// - Pixel value = (bit1 << 1) | bit2
|
||||||
// 2=light gray, 3=black
|
|
||||||
// 70-byte 2-bit BMP header (14 file + 40 DIB + 4-entry gray palette),
|
|
||||||
// top-down rows. Only the size and dimension fields vary; patch them in.
|
|
||||||
// clang-format off
|
|
||||||
uint8_t hdr[70] = {
|
|
||||||
'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, // file header
|
|
||||||
40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, // DIB: w/h patched
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0x13, 0x0B, 0, 0, 0x13, 0x0B, 0, 0, // 0, 2835 DPI
|
|
||||||
4, 0, 0, 0, 4, 0, 0, 0, // 4 palette colors
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00, // black, dark gray
|
|
||||||
0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00}; // light gray, white
|
|
||||||
// clang-format on
|
|
||||||
const uint32_t rowSize2 = ((static_cast<uint32_t>(pageInfo.width) * 2 + 31) / 32) * 4;
|
|
||||||
const uint32_t imageSize = rowSize2 * pageInfo.height;
|
|
||||||
const uint32_t fileSize = sizeof(hdr) + imageSize;
|
|
||||||
const int32_t topDownHeight = -static_cast<int32_t>(pageInfo.height);
|
|
||||||
memcpy(hdr + 2, &fileSize, 4);
|
|
||||||
memcpy(hdr + 18, &pageInfo.width, 2); // biWidth (upper bytes stay 0)
|
|
||||||
memcpy(hdr + 22, &topDownHeight, 4); // negative biHeight = top-down
|
|
||||||
memcpy(hdr + 34, &imageSize, 4);
|
|
||||||
coverBmp.write(hdr, sizeof(hdr));
|
|
||||||
|
|
||||||
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
|
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
|
||||||
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
||||||
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
||||||
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
|
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
|
||||||
|
|
||||||
// 2 bits per pixel, MSB first, rows padded to 4 bytes
|
// Allocate a row buffer for 1-bit output
|
||||||
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize2));
|
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
|
||||||
if (!rowBuffer) {
|
if (!rowBuffer) {
|
||||||
free(pageBuffer);
|
free(pageBuffer);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XTH value -> BMP palette index (palette: 0=black, 1=dark, 2=light, 3=white)
|
|
||||||
static constexpr uint8_t kXthToBmp[4] = {3, 1, 2, 0};
|
|
||||||
|
|
||||||
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
||||||
memset(rowBuffer, 0x00, rowSize2);
|
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
|
||||||
|
|
||||||
for (uint16_t x = 0; x < pageInfo.width; x++) {
|
for (uint16_t x = 0; x < pageInfo.width; x++) {
|
||||||
// Column-major, right to left: column index = (width - 1 - x)
|
// Column-major, right to left: column index = (width - 1 - x)
|
||||||
@@ -232,22 +216,28 @@ bool Xtc::generateCoverBmp() const {
|
|||||||
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
||||||
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
||||||
|
|
||||||
const uint8_t bmpVal = kXthToBmp[pixelValue];
|
// Threshold: 0=white (1); 1,2,3=black (0)
|
||||||
rowBuffer[(x * 2) / 8] |= bmpVal << (6 - ((x * 2) % 8));
|
if (pixelValue >= 1) {
|
||||||
|
// Set bit to 0 (black) in BMP format
|
||||||
|
const size_t dstByte = x / 8;
|
||||||
|
const size_t dstBit = 7 - (x % 8);
|
||||||
|
rowBuffer[dstByte] &= ~(1 << dstBit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row buffer is rowSize2 bytes and zero-padded, write it whole
|
// Write converted row
|
||||||
coverBmp.write(rowBuffer, rowSize2);
|
coverBmp.write(rowBuffer, dstRowSize);
|
||||||
|
|
||||||
|
// Pad to 4-byte boundary
|
||||||
|
uint8_t padding[4] = {0, 0, 0, 0};
|
||||||
|
size_t paddingSize = rowSize - dstRowSize;
|
||||||
|
if (paddingSize > 0) {
|
||||||
|
coverBmp.write(padding, paddingSize);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
free(rowBuffer);
|
free(rowBuffer);
|
||||||
} else {
|
} else {
|
||||||
// Write 1-bit BMP header (top-down row order)
|
|
||||||
BmpHeader bmpHeader;
|
|
||||||
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
|
|
||||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
|
|
||||||
|
|
||||||
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
|
|
||||||
// 1-bit source: write directly with proper padding
|
// 1-bit source: write directly with proper padding
|
||||||
const size_t srcRowSize = (pageInfo.width + 7) / 8;
|
const size_t srcRowSize = (pageInfo.width + 7) / 8;
|
||||||
|
|
||||||
@@ -432,10 +422,9 @@ bool Xtc::generateThumbBmp(int height) const {
|
|||||||
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
||||||
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
||||||
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
||||||
// pixelValue: 0=white, 1=dark gray, 2=light gray, 3=black —
|
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
|
||||||
// same semantics as the cover's kXthToBmp mapping above
|
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
|
||||||
static constexpr uint8_t kXthToGray[4] = {255, 85, 170, 0};
|
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
|
||||||
grayValue = kXthToGray[pixelValue];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+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
|
// Continue out of block with data set
|
||||||
} else if (fileStat.method == ZIP_METHOD_DEFLATED) {
|
} else if (fileStat.method == ZIP_METHOD_DEFLATED) {
|
||||||
// Read out deflated content from file
|
auto* fileReadBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||||
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
|
if (!fileReadBuffer) {
|
||||||
if (deflatedData == nullptr) {
|
LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
|
||||||
LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
|
|
||||||
free(data);
|
free(data);
|
||||||
return nullptr;
|
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) {
|
if (!ctx.reader.init(true)) {
|
||||||
LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
|
LOG_ERR("ZIP", "Failed to init inflate reader");
|
||||||
free(deflatedData);
|
free(fileReadBuffer);
|
||||||
free(data);
|
free(data);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
ctx.reader.setReadCallback(zipReadCallback);
|
||||||
|
|
||||||
bool success = false;
|
if (!ctx.reader.read(data, inflatedDataSize)) {
|
||||||
{
|
|
||||||
InflateReader r;
|
|
||||||
r.init(false);
|
|
||||||
r.setSource(deflatedData, deflatedDataSize);
|
|
||||||
success = r.read(data, inflatedDataSize);
|
|
||||||
}
|
|
||||||
free(deflatedData);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("ZIP", "Failed to inflate file");
|
LOG_ERR("ZIP", "Failed to inflate file");
|
||||||
|
free(fileReadBuffer);
|
||||||
free(data);
|
free(data);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
free(fileReadBuffer);
|
||||||
|
|
||||||
// Continue out of block with data set
|
// Continue out of block with data set
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -81,6 +81,27 @@ void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* m
|
|||||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
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::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
|
||||||
|
|
||||||
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ class HalDisplay {
|
|||||||
// Access to frame buffer
|
// Access to frame buffer
|
||||||
uint8_t* getFrameBuffer() const;
|
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 copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
|
||||||
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
||||||
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
|
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
|
extra_configs = platformio.local.ini
|
||||||
|
|
||||||
[crosspoint]
|
[crosspoint]
|
||||||
version = 1.3.0
|
version = 1.4.1
|
||||||
|
|
||||||
[base]
|
[base]
|
||||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
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
|
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||||
|
-DFREEINK_DEVICE_X4=1
|
||||||
|
-DFREEINK_DEVICE_X3=1
|
||||||
-Wno-bidi-chars
|
-Wno-bidi-chars
|
||||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||||
-fno-exceptions
|
-fno-exceptions
|
||||||
@@ -57,10 +59,14 @@ extra_scripts =
|
|||||||
|
|
||||||
; Libraries
|
; Libraries
|
||||||
lib_deps =
|
lib_deps =
|
||||||
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
|
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
|
||||||
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
|
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||||
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
|
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
||||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||||
|
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
|
bblanchon/ArduinoJson @ 7.4.2
|
||||||
ricmoo/QRCode @ 0.0.1
|
ricmoo/QRCode @ 0.0.1
|
||||||
bitbank2/PNGdec @ 1.1.6
|
bitbank2/PNGdec @ 1.1.6
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export compiled 1-bit UI icon headers as BMP assets for SD themes."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ICON_HEADERS = [
|
||||||
|
"book.h",
|
||||||
|
"book24.h",
|
||||||
|
"bookmark.h",
|
||||||
|
"cover.h",
|
||||||
|
"file24.h",
|
||||||
|
"folder.h",
|
||||||
|
"folder24.h",
|
||||||
|
"hotspot.h",
|
||||||
|
"image24.h",
|
||||||
|
"library.h",
|
||||||
|
"recent.h",
|
||||||
|
"settings2.h",
|
||||||
|
"text24.h",
|
||||||
|
"transfer.h",
|
||||||
|
"wifi.h",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_icon_header(path: Path):
|
||||||
|
text = path.read_text()
|
||||||
|
size_match = re.search(r"//\s*size:\s*(\d+)x(\d+)", text)
|
||||||
|
if not size_match:
|
||||||
|
raise ValueError(f"missing size comment in {path}")
|
||||||
|
width = int(size_match.group(1))
|
||||||
|
height = int(size_match.group(2))
|
||||||
|
|
||||||
|
bitmap_match = re.search(r"static\s+const\s+uint8_t\s+\w+\s*\[\]\s*=\s*\{(?P<body>.*?)\};", text, re.DOTALL)
|
||||||
|
if not bitmap_match:
|
||||||
|
raise ValueError(f"missing bitmap data in {path}")
|
||||||
|
bitmap_body = bitmap_match.group("body")
|
||||||
|
|
||||||
|
values = [int(m.group(1), 16) for m in re.finditer(r"0x([0-9A-Fa-f]{2})", bitmap_body)]
|
||||||
|
expected = ((width + 7) // 8) * height
|
||||||
|
if len(values) != expected:
|
||||||
|
raise ValueError(f"{path}: expected {expected} bytes, found {len(values)}")
|
||||||
|
return width, height, bytes(values)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bit(bitmap: bytes, width: int, x: int, y: int) -> int:
|
||||||
|
stride = (width + 7) // 8
|
||||||
|
return (bitmap[y * stride + x // 8] >> (7 - (x % 8))) & 1
|
||||||
|
|
||||||
|
|
||||||
|
def set_bit(buf: bytearray, width: int, x: int, y: int, value: int):
|
||||||
|
stride = (width + 7) // 8
|
||||||
|
if value:
|
||||||
|
buf[y * stride + x // 8] |= 1 << (7 - (x % 8))
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_1bit_cw(width: int, height: int, bitmap: bytes):
|
||||||
|
rotated_width = height
|
||||||
|
rotated_height = width
|
||||||
|
rotated = bytearray(((rotated_width + 7) // 8) * rotated_height)
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
set_bit(rotated, rotated_width, height - 1 - y, x, get_bit(bitmap, width, x, y))
|
||||||
|
return rotated_width, rotated_height, bytes(rotated)
|
||||||
|
|
||||||
|
|
||||||
|
def write_1bit_bmp(path: Path, width: int, height: int, bitmap: bytes):
|
||||||
|
src_stride = (width + 7) // 8
|
||||||
|
dst_stride = ((width + 31) // 32) * 4
|
||||||
|
pixel_bytes = dst_stride * height
|
||||||
|
pixel_offset = 14 + 40 + 8
|
||||||
|
file_size = pixel_offset + pixel_bytes
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("wb") as out:
|
||||||
|
# BITMAPFILEHEADER
|
||||||
|
out.write(b"BM")
|
||||||
|
out.write(struct.pack("<IHHI", file_size, 0, 0, pixel_offset))
|
||||||
|
# BITMAPINFOHEADER. Negative height stores rows top-down.
|
||||||
|
out.write(struct.pack("<IiiHHIIiiII", 40, width, -height, 1, 1, 0, pixel_bytes, 0, 0, 2, 0))
|
||||||
|
# Palette index 0 = black, index 1 = white. Existing icon arrays use 1s
|
||||||
|
# for white/transparent background and 0s for ink.
|
||||||
|
out.write(bytes([0, 0, 0, 0, 255, 255, 255, 0]))
|
||||||
|
for y in range(height):
|
||||||
|
row = bitmap[y * src_stride : (y + 1) * src_stride]
|
||||||
|
out.write(row)
|
||||||
|
out.write(b"\x00" * (dst_stride - src_stride))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--icons", default="src/components/icons")
|
||||||
|
parser.add_argument("--themes", default="../crosspoint-tools/public/themes")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
icon_root = Path(args.icons)
|
||||||
|
theme_root = Path(args.themes)
|
||||||
|
|
||||||
|
parsed = []
|
||||||
|
for header in ICON_HEADERS:
|
||||||
|
icon_path = icon_root / header
|
||||||
|
width, height, data = parse_icon_header(icon_path)
|
||||||
|
width, height, data = rotate_1bit_cw(width, height, data)
|
||||||
|
parsed.append((icon_path.stem, width, height, data))
|
||||||
|
|
||||||
|
for theme_dir in sorted(theme_root.iterdir()):
|
||||||
|
if not theme_dir.is_dir() or not (theme_dir / "theme.json").exists():
|
||||||
|
continue
|
||||||
|
for name, width, height, data in parsed:
|
||||||
|
write_1bit_bmp(theme_dir / "icons" / f"{name}.bmp", width, height, data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a themes.json manifest from SD theme package folders."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def safe_theme_dirs(root: Path):
|
||||||
|
for child in sorted(root.iterdir()):
|
||||||
|
if not child.is_dir() or child.name.startswith(".") or child.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
theme_json = child / "theme.json"
|
||||||
|
if theme_json.exists():
|
||||||
|
yield child
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(root: Path, base_url: str):
|
||||||
|
themes = []
|
||||||
|
for theme_dir in safe_theme_dirs(root):
|
||||||
|
theme_doc = json.loads((theme_dir / "theme.json").read_text(encoding="utf-8"))
|
||||||
|
files = []
|
||||||
|
total = 0
|
||||||
|
for file_path in sorted(p for p in theme_dir.rglob("*") if p.is_file()):
|
||||||
|
rel = file_path.relative_to(theme_dir).as_posix()
|
||||||
|
data = file_path.read_bytes()
|
||||||
|
total += len(data)
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": rel,
|
||||||
|
"url": f"{theme_dir.name}/{rel}",
|
||||||
|
"size": len(data),
|
||||||
|
"crc32": zlib.crc32(data) & 0xFFFFFFFF,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
themes.append(
|
||||||
|
{
|
||||||
|
"id": theme_doc["id"],
|
||||||
|
"name": theme_doc.get("name", theme_doc["id"]),
|
||||||
|
"version": theme_doc.get("version", 1),
|
||||||
|
"description": theme_doc.get("description", ""),
|
||||||
|
"files": files,
|
||||||
|
"totalSize": total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"version": 1, "baseUrl": base_url, "themes": themes}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default="../crosspoint-tools/public/themes")
|
||||||
|
parser.add_argument("--base-url", required=True)
|
||||||
|
parser.add_argument("--output", default="../crosspoint-tools/public/themes/themes.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manifest = build_manifest(Path(args.root), args.base_url)
|
||||||
|
output = Path(args.output)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -65,6 +65,8 @@ class CrossPointSettings {
|
|||||||
XTC_STATUS_BAR_MODE_COUNT
|
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 {
|
enum ORIENTATION {
|
||||||
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
||||||
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
||||||
@@ -136,6 +138,17 @@ class CrossPointSettings {
|
|||||||
// Short power button press actions
|
// Short power button press actions
|
||||||
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, FOOTNOTES = 4, SHORT_PWRBTN_COUNT };
|
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
|
// Hide battery percentage
|
||||||
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
|
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
|
||||||
|
|
||||||
@@ -148,7 +161,7 @@ class CrossPointSettings {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// UI Theme
|
// UI Theme
|
||||||
enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 };
|
enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3, UI_THEME_COUNT = 4 };
|
||||||
|
|
||||||
// Image rendering in EPUB reader
|
// Image rendering in EPUB reader
|
||||||
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
||||||
@@ -177,7 +190,7 @@ class CrossPointSettings {
|
|||||||
uint8_t statusBarBattery = 1;
|
uint8_t statusBarBattery = 1;
|
||||||
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
||||||
// Clock display in status bar (X3 only, requires DS3231 RTC)
|
// 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.
|
// 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.
|
// 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).
|
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
|
||||||
@@ -226,6 +239,9 @@ class CrossPointSettings {
|
|||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press page turn button behavior
|
// Long-press page turn button behavior
|
||||||
uint8_t longPressButtonBehavior = OFF;
|
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
|
// UI Theme
|
||||||
uint8_t uiTheme = LYRA;
|
uint8_t uiTheme = LYRA;
|
||||||
// Sunlight fading compensation
|
// Sunlight fading compensation
|
||||||
@@ -238,6 +254,8 @@ class CrossPointSettings {
|
|||||||
uint8_t focusReadingEnabled = 0;
|
uint8_t focusReadingEnabled = 0;
|
||||||
// SD card font family name (empty = use built-in fontFamily)
|
// SD card font family name (empty = use built-in fontFamily)
|
||||||
char sdFontFamilyName[32] = "";
|
char sdFontFamilyName[32] = "";
|
||||||
|
// SD card UI theme id/name (empty = use built-in Lyra)
|
||||||
|
char sdThemeName[32] = "";
|
||||||
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
|
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
|
||||||
uint8_t showHiddenFiles = 0;
|
uint8_t showHiddenFiles = 0;
|
||||||
// Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on)
|
// Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on)
|
||||||
|
|||||||
@@ -144,10 +144,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
|||||||
doc["frontButtonRight"] = s.frontButtonRight;
|
doc["frontButtonRight"] = s.frontButtonRight;
|
||||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
doc["fontFamily"] = s.fontFamily;
|
doc["fontFamily"] = s.fontFamily;
|
||||||
|
// UI theme — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
|
doc["uiTheme"] = s.uiTheme;
|
||||||
// SD card font family name — not in SettingsList, save manually
|
// SD card font family name — not in SettingsList, save manually
|
||||||
if (s.sdFontFamilyName[0] != '\0') {
|
if (s.sdFontFamilyName[0] != '\0') {
|
||||||
doc["sdFontFamilyName"] = s.sdFontFamilyName;
|
doc["sdFontFamilyName"] = s.sdFontFamilyName;
|
||||||
}
|
}
|
||||||
|
// SD card UI theme id/name — dynamic setting, save manually.
|
||||||
|
if (s.sdThemeName[0] != '\0') {
|
||||||
|
doc["sdThemeName"] = s.sdThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||||
@@ -246,10 +252,17 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
|||||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
||||||
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
||||||
|
// UI theme — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
|
s.uiTheme = clamp(doc["uiTheme"] | (uint8_t)CrossPointSettings::LYRA, (uint8_t)CrossPointSettings::UI_THEME_COUNT,
|
||||||
|
(uint8_t)CrossPointSettings::LYRA);
|
||||||
// SD card font family name — not in SettingsList, load manually
|
// SD card font family name — not in SettingsList, load manually
|
||||||
const char* sfn = doc["sdFontFamilyName"] | "";
|
const char* sfn = doc["sdFontFamilyName"] | "";
|
||||||
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
|
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
|
||||||
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
|
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
|
||||||
|
// SD card UI theme id/name — not in SettingsList, load manually.
|
||||||
|
const char* stn = doc["sdThemeName"] | "";
|
||||||
|
strncpy(s.sdThemeName, stn, sizeof(s.sdThemeName) - 1);
|
||||||
|
s.sdThemeName[sizeof(s.sdThemeName) - 1] = '\0';
|
||||||
if (storedFontFamily == CrossPointSettings::LEGACY_OPENDYSLEXIC && s.sdFontFamilyName[0] == '\0') {
|
if (storedFontFamily == CrossPointSettings::LEGACY_OPENDYSLEXIC && s.sdFontFamilyName[0] == '\0') {
|
||||||
s.fontFamily = CrossPointSettings::NOTOSERIF;
|
s.fontFamily = CrossPointSettings::NOTOSERIF;
|
||||||
strncpy(s.sdFontFamilyName, "OpenDyslexic", sizeof(s.sdFontFamilyName) - 1);
|
strncpy(s.sdFontFamilyName, "OpenDyslexic", sizeof(s.sdFontFamilyName) - 1);
|
||||||
@@ -423,6 +436,9 @@ bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks,
|
|||||||
obj["xpath"] = bookmark.xpath;
|
obj["xpath"] = bookmark.xpath;
|
||||||
obj["percentage"] = bookmark.percentage;
|
obj["percentage"] = bookmark.percentage;
|
||||||
obj["summary"] = bookmark.summary;
|
obj["summary"] = bookmark.summary;
|
||||||
|
obj["si"] = bookmark.computedSpineIndex;
|
||||||
|
obj["pc"] = bookmark.computedChapterPageCount;
|
||||||
|
obj["pp"] = bookmark.computedChapterProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
String json;
|
String json;
|
||||||
@@ -447,6 +463,9 @@ bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const
|
|||||||
bookmark.xpath = obj["xpath"] | std::string("");
|
bookmark.xpath = obj["xpath"] | std::string("");
|
||||||
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
|
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
|
||||||
bookmark.summary = obj["summary"] | std::string("");
|
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());
|
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.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 {
|
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
|
||||||
const auto sideLayout = SETTINGS.sideButtonLayout;
|
const auto sideLayout = SETTINGS.sideButtonLayout;
|
||||||
|
|
||||||
@@ -49,6 +60,15 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
|||||||
default:
|
default:
|
||||||
return false;
|
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;
|
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,
|
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||||
const char* next) const {
|
const char* next) const {
|
||||||
// Swap previous/next labels to match the page turn direction swap in INVERTED and LANDSCAPE_CCW.
|
// Swap previous/next labels to match the page turn direction swap in INVERTED and LANDSCAPE_CCW.
|
||||||
const bool swapLabels =
|
const bool swapLabels = isNavDirectionSwapped();
|
||||||
SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED ||
|
|
||||||
SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW);
|
|
||||||
const char* leftLabel = swapLabels ? next : previous;
|
const char* leftLabel = swapLabels ? next : previous;
|
||||||
const char* rightLabel = swapLabels ? previous : next;
|
const char* rightLabel = swapLabels ? previous : next;
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
#include <HalGPIO.h>
|
#include <HalGPIO.h>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
|
||||||
class MappedInputManager {
|
class MappedInputManager {
|
||||||
public:
|
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 {
|
struct Labels {
|
||||||
const char* btn1;
|
const char* btn1;
|
||||||
@@ -13,7 +15,7 @@ class MappedInputManager {
|
|||||||
const char* btn4;
|
const char* btn4;
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
|
MappedInputManager(HalGPIO& gpio, const GfxRenderer& renderer) : gpio(gpio), renderer(renderer) {}
|
||||||
|
|
||||||
void update() const { gpio.update(); }
|
void update() const { gpio.update(); }
|
||||||
bool wasPressed(Button button) const;
|
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).
|
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||||
int getPressedFrontButton() const;
|
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:
|
private:
|
||||||
HalGPIO& gpio;
|
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;
|
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,16 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
static uint8_t fontSizeEnumFromSettings() {
|
static uint8_t fontSizeEnumFromSettings() {
|
||||||
uint8_t e = SETTINGS.fontSize;
|
uint8_t e = SETTINGS.fontSize;
|
||||||
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
|
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
||||||
registry_.discover();
|
registry_.discover();
|
||||||
|
|
||||||
@@ -74,10 +78,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
|||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto sizes = family->availableSizes();
|
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||||
uint8_t idx = sizeEnum;
|
const uint8_t wantedPt = selected ? selected->pointSize : 0;
|
||||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
|
||||||
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
|
|
||||||
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
|
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
|
||||||
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
|
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
|
||||||
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
|
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
|
||||||
|
|||||||
+79
-8
@@ -13,6 +13,7 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "activities/settings/SettingsActivity.h"
|
#include "activities/settings/SettingsActivity.h"
|
||||||
|
#include "components/themes/SdCardThemeRegistry.h"
|
||||||
|
|
||||||
// Build the font family setting dynamically. When registry is non-null, SD card fonts
|
// Build the font family setting dynamically. When registry is non-null, SD card fonts
|
||||||
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
||||||
@@ -90,6 +91,63 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the UI theme setting dynamically. Firmware themes keep their existing
|
||||||
|
// indexes; SD card themes are appended after them.
|
||||||
|
inline SettingInfo buildUiThemeSetting(const SdCardThemeRegistry* registry) {
|
||||||
|
std::vector<std::string> allStringValues;
|
||||||
|
allStringValues.push_back(I18N.get(StrId::STR_THEME_CLASSIC));
|
||||||
|
allStringValues.push_back(I18N.get(StrId::STR_THEME_LYRA));
|
||||||
|
allStringValues.push_back(I18N.get(StrId::STR_THEME_LYRA_EXTENDED));
|
||||||
|
allStringValues.push_back(I18N.get(StrId::STR_THEME_ROUNDEDRAFF));
|
||||||
|
|
||||||
|
std::vector<std::string> sdThemeIds;
|
||||||
|
if (registry) {
|
||||||
|
const auto& themes = registry->getThemes();
|
||||||
|
sdThemeIds.reserve(themes.size());
|
||||||
|
for (const auto& theme : themes) {
|
||||||
|
allStringValues.push_back(theme.name);
|
||||||
|
sdThemeIds.push_back(theme.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingInfo s;
|
||||||
|
s.nameId = StrId::STR_UI_THEME;
|
||||||
|
s.type = SettingType::ENUM;
|
||||||
|
s.enumStringValues = std::move(allStringValues);
|
||||||
|
s.key = "uiTheme";
|
||||||
|
s.category = StrId::STR_CAT_DISPLAY;
|
||||||
|
|
||||||
|
s.valueGetter = [sdThemeIds]() -> uint8_t {
|
||||||
|
if (SETTINGS.sdThemeName[0] != '\0') {
|
||||||
|
for (int i = 0; i < static_cast<int>(sdThemeIds.size()); i++) {
|
||||||
|
if (sdThemeIds[i] == SETTINGS.sdThemeName) {
|
||||||
|
return static_cast<uint8_t>(CrossPointSettings::UI_THEME_COUNT + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SETTINGS.uiTheme < CrossPointSettings::UI_THEME_COUNT ? SETTINGS.uiTheme : CrossPointSettings::LYRA;
|
||||||
|
};
|
||||||
|
|
||||||
|
s.valueSetter = [sdThemeIds](uint8_t v) {
|
||||||
|
if (v < CrossPointSettings::UI_THEME_COUNT) {
|
||||||
|
SETTINGS.uiTheme = v;
|
||||||
|
SETTINGS.sdThemeName[0] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SETTINGS.uiTheme = CrossPointSettings::UI_THEME::LYRA;
|
||||||
|
const int sdIdx = v - CrossPointSettings::UI_THEME_COUNT;
|
||||||
|
if (sdIdx < static_cast<int>(sdThemeIds.size())) {
|
||||||
|
strncpy(SETTINGS.sdThemeName, sdThemeIds[sdIdx].c_str(), sizeof(SETTINGS.sdThemeName) - 1);
|
||||||
|
SETTINGS.sdThemeName[sizeof(SETTINGS.sdThemeName) - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
SETTINGS.sdThemeName[0] = '\0';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
// Shared settings list used by both the device settings UI and the web settings API.
|
// Shared settings list used by both the device settings UI and the web settings API.
|
||||||
// Each entry has a key (for JSON API) and category (for grouping).
|
// Each entry has a key (for JSON API) and category (for grouping).
|
||||||
// ACTION-type entries and entries without a key are device-only.
|
// ACTION-type entries and entries without a key are device-only.
|
||||||
@@ -99,7 +157,8 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
|||||||
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
||||||
// font-family entry is replaced in a per-call copy with a registry-aware
|
// font-family entry is replaced in a per-call copy with a registry-aware
|
||||||
// version. Callers without SD fonts pay only a vector copy.
|
// version. Callers without SD fonts pay only a vector copy.
|
||||||
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
|
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* fontRegistry = nullptr,
|
||||||
|
const SdCardThemeRegistry* themeRegistry = nullptr) {
|
||||||
static const std::vector<SettingInfo> baseList = [] {
|
static const std::vector<SettingInfo> baseList = [] {
|
||||||
std::vector<SettingInfo> v = {
|
std::vector<SettingInfo> v = {
|
||||||
// --- Display ---
|
// --- Display ---
|
||||||
@@ -151,9 +210,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
StrId::STR_CAT_READER),
|
StrId::STR_CAT_READER),
|
||||||
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
|
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
|
||||||
StrId::STR_CAT_READER),
|
StrId::STR_CAT_READER),
|
||||||
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
SettingInfo::Enum(
|
||||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||||
"orientation", StrId::STR_CAT_READER),
|
{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,
|
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
|
||||||
"extraParagraphSpacing", StrId::STR_CAT_READER),
|
"extraParagraphSpacing", StrId::STR_CAT_READER),
|
||||||
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||||
@@ -171,12 +231,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_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP,
|
||||||
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
|
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
|
||||||
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
|
"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(
|
SettingInfo::Enum(
|
||||||
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
||||||
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
|
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
|
||||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||||
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
||||||
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
SettingInfo::Value(
|
SettingInfo::Value(
|
||||||
StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes,
|
StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes,
|
||||||
@@ -240,8 +304,9 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||||
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
|
// 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.
|
// 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_CUSTOMISE_STATUS_BAR),
|
{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},
|
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
|
||||||
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
|
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||||
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
|
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
|
||||||
@@ -268,10 +333,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
}();
|
}();
|
||||||
|
|
||||||
std::vector<SettingInfo> v = baseList;
|
std::vector<SettingInfo> v = baseList;
|
||||||
if (registry && registry->getFamilyCount() > 0) {
|
{
|
||||||
|
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_UI_THEME; });
|
||||||
|
if (it != v.end()) {
|
||||||
|
*it = buildUiThemeSetting(themeRegistry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fontRegistry && fontRegistry->getFamilyCount() > 0) {
|
||||||
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
||||||
if (it != v.end()) {
|
if (it != v.end()) {
|
||||||
*it = buildFontFamilySetting(registry);
|
*it = buildFontFamilySetting(fontRegistry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#include "ThemeInstaller.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
|
ThemeInstaller::ThemeInstaller(SdCardThemeRegistry& registry) : registry_(registry) {}
|
||||||
|
|
||||||
|
bool ThemeInstaller::isValidThemeId(const char* id) {
|
||||||
|
if (id == nullptr || id[0] == '\0') return false;
|
||||||
|
if (strstr(id, "..") != nullptr || strchr(id, '/') != nullptr || strchr(id, '\\') != nullptr) return false;
|
||||||
|
for (const char* p = id; *p; ++p) {
|
||||||
|
const char c = *p;
|
||||||
|
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ThemeInstaller::isValidRelativePath(const char* path) {
|
||||||
|
if (path == nullptr || path[0] == '\0' || path[0] == '/') return false;
|
||||||
|
if (strstr(path, "..") != nullptr || strchr(path, '\\') != nullptr) return false;
|
||||||
|
|
||||||
|
bool segmentHasChar = false;
|
||||||
|
for (const char* p = path; *p; ++p) {
|
||||||
|
const char c = *p;
|
||||||
|
if (c == '/') {
|
||||||
|
if (!segmentHasChar) return false;
|
||||||
|
segmentHasChar = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_' && c != '.') return false;
|
||||||
|
segmentHasChar = true;
|
||||||
|
}
|
||||||
|
return segmentHasChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ThemeInstaller::ensureThemeDir(const char* themeId) {
|
||||||
|
if (!isValidThemeId(themeId)) return false;
|
||||||
|
const char* root = SdCardThemeRegistry::findThemeRoot(themeId);
|
||||||
|
if (!root) root = SdCardThemeRegistry::defaultWriteRoot();
|
||||||
|
|
||||||
|
if (!Storage.exists(root) && !Storage.mkdir(root)) {
|
||||||
|
LOG_ERR("THEME", "Failed to create themes dir: %s", root);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
char dirPath[180];
|
||||||
|
const int written = snprintf(dirPath, sizeof(dirPath), "%s/%s", root, themeId);
|
||||||
|
if (written < 0 || static_cast<size_t>(written) >= sizeof(dirPath)) {
|
||||||
|
LOG_ERR("THEME", "Theme dir path too long: %s", themeId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Storage.exists(dirPath) && !Storage.mkdir(dirPath)) {
|
||||||
|
LOG_ERR("THEME", "Failed to create theme dir: %s", dirPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ThemeInstaller::ensureParentDirs(const char* fullPath) {
|
||||||
|
if (!fullPath) return false;
|
||||||
|
char dir[180];
|
||||||
|
const int written = snprintf(dir, sizeof(dir), "%s", fullPath);
|
||||||
|
if (written < 0 || static_cast<size_t>(written) >= sizeof(dir)) {
|
||||||
|
LOG_ERR("THEME", "Theme parent path too long");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* slash = strrchr(dir, '/');
|
||||||
|
if (!slash) return true;
|
||||||
|
*slash = '\0';
|
||||||
|
return Storage.ensureDirectoryExists(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ThemeInstaller::validateThemeFile(const char* path) {
|
||||||
|
HalFile file;
|
||||||
|
if (!Storage.openFileForRead("THEME", path, file)) return false;
|
||||||
|
const bool ok = file.fileSize() > 0;
|
||||||
|
file.close();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ThemeInstaller::buildThemePath(const char* themeId, const char* relativePath, char* outBuf, size_t outBufSize) {
|
||||||
|
if (!themeId || !relativePath || !outBuf || outBufSize == 0) return false;
|
||||||
|
const char* root = SdCardThemeRegistry::findThemeRoot(themeId);
|
||||||
|
if (!root) root = SdCardThemeRegistry::defaultWriteRoot();
|
||||||
|
const int written = snprintf(outBuf, outBufSize, "%s/%s/%s", root, themeId, relativePath);
|
||||||
|
if (written < 0 || static_cast<size_t>(written) >= outBufSize) {
|
||||||
|
LOG_ERR("THEME", "Theme file path too long: %s/%s", themeId, relativePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeInstaller::Error ThemeInstaller::deleteTheme(const char* themeId) {
|
||||||
|
if (!isValidThemeId(themeId)) return Error::INVALID_THEME_ID;
|
||||||
|
|
||||||
|
const char* roots[] = {SdCardThemeRegistry::THEMES_DIR_HIDDEN, SdCardThemeRegistry::THEMES_DIR_VISIBLE};
|
||||||
|
for (const char* root : roots) {
|
||||||
|
char dirPath[180];
|
||||||
|
const int written = snprintf(dirPath, sizeof(dirPath), "%s/%s", root, themeId);
|
||||||
|
if (written < 0 || static_cast<size_t>(written) >= sizeof(dirPath)) {
|
||||||
|
LOG_ERR("THEME", "Theme dir path too long: %s", themeId);
|
||||||
|
return Error::INVALID_THEME_ID;
|
||||||
|
}
|
||||||
|
if (!Storage.exists(dirPath)) continue;
|
||||||
|
if (!Storage.removeDir(dirPath)) {
|
||||||
|
LOG_ERR("THEME", "Failed to remove theme dir: %s", dirPath);
|
||||||
|
return Error::SD_WRITE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(SETTINGS.sdThemeName, themeId) == 0) {
|
||||||
|
SETTINGS.sdThemeName[0] = '\0';
|
||||||
|
SETTINGS.uiTheme = CrossPointSettings::LYRA;
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
}
|
||||||
|
return Error::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ThemeInstaller::refreshRegistry() { registry_.discover(); }
|
||||||
|
|
||||||
|
bool ThemeInstaller::isThemeInstalled(const char* themeId) const { return registry_.findTheme(themeId) != nullptr; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include "components/themes/SdCardThemeRegistry.h"
|
||||||
|
|
||||||
|
class ThemeInstaller {
|
||||||
|
public:
|
||||||
|
enum class Error {
|
||||||
|
OK,
|
||||||
|
INVALID_THEME_ID,
|
||||||
|
INVALID_FILE,
|
||||||
|
SD_WRITE_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit ThemeInstaller(SdCardThemeRegistry& registry);
|
||||||
|
|
||||||
|
static bool isValidThemeId(const char* id);
|
||||||
|
static bool isValidRelativePath(const char* path);
|
||||||
|
bool ensureThemeDir(const char* themeId);
|
||||||
|
bool ensureParentDirs(const char* fullPath);
|
||||||
|
bool validateThemeFile(const char* path);
|
||||||
|
static bool buildThemePath(const char* themeId, const char* relativePath, char* outBuf, size_t outBufSize);
|
||||||
|
Error deleteTheme(const char* themeId);
|
||||||
|
void refreshRegistry();
|
||||||
|
bool isThemeInstalled(const char* themeId) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
SdCardThemeRegistry& registry_;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "ActivityManager.h"
|
#include "ActivityManager.h"
|
||||||
|
|
||||||
|
#include <FontCacheManager.h>
|
||||||
#include <HalPowerManager.h>
|
#include <HalPowerManager.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
#include "boot_sleep/BootActivity.h"
|
#include "boot_sleep/BootActivity.h"
|
||||||
#include "boot_sleep/SleepActivity.h"
|
#include "boot_sleep/SleepActivity.h"
|
||||||
#include "browser/OpdsBookBrowserActivity.h"
|
#include "browser/OpdsBookBrowserActivity.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
#include "home/CrashActivity.h"
|
#include "home/CrashActivity.h"
|
||||||
#include "home/FileBrowserActivity.h"
|
#include "home/FileBrowserActivity.h"
|
||||||
#include "home/HomeActivity.h"
|
#include "home/HomeActivity.h"
|
||||||
@@ -169,6 +171,7 @@ void ActivityManager::replaceActivity(std::unique_ptr<Activity>&& newActivity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ActivityManager::goToFileTransfer() {
|
void ActivityManager::goToFileTransfer() {
|
||||||
|
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||||
replaceActivity(std::make_unique<CrossPointWebServerActivity>(renderer, mappedInput));
|
replaceActivity(std::make_unique<CrossPointWebServerActivity>(renderer, mappedInput));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +186,7 @@ void ActivityManager::goToRecentBooks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ActivityManager::goToBrowser() {
|
void ActivityManager::goToBrowser() {
|
||||||
|
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||||
const auto& servers = OPDS_STORE.getServers();
|
const auto& servers = OPDS_STORE.getServers();
|
||||||
// Skip the server picker when there's only one server configured
|
// Skip the server picker when there's only one server configured
|
||||||
if (servers.size() == 1) {
|
if (servers.size() == 1) {
|
||||||
@@ -193,6 +197,7 @@ void ActivityManager::goToBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ActivityManager::goToReader(std::string path) {
|
void ActivityManager::goToReader(std::string path) {
|
||||||
|
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||||
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +227,7 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) {
|
|||||||
initialMenuItem = HomeMenuItem::SETTINGS_MENU;
|
initialMenuItem = HomeMenuItem::SETTINGS_MENU;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UITheme::getInstance().reload();
|
||||||
replaceActivity(std::make_unique<HomeActivity>(renderer, mappedInput, initialMenuItem));
|
replaceActivity(std::make_unique<HomeActivity>(renderer, mappedInput, initialMenuItem));
|
||||||
}
|
}
|
||||||
void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique<CrashActivity>(renderer, mappedInput)); }
|
void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique<CrashActivity>(renderer, mappedInput)); }
|
||||||
|
|||||||
@@ -218,7 +218,15 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
if (hasGreyscale) {
|
||||||
|
// OEM grayscale pipeline base: on X3 this displays the frame with the
|
||||||
|
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
|
||||||
|
// in the calibrated state the gray nudge refresh expects; on X4 it is a
|
||||||
|
// plain HALF refresh (previous behavior).
|
||||||
|
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
|
||||||
|
} else {
|
||||||
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
bitmap.rewindToData();
|
bitmap.rewindToData();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <Memory.h>
|
#include <Memory.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
@@ -355,30 +356,60 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
(mode == Mode::PickFirmware)
|
(mode == Mode::PickFirmware)
|
||||||
? std::string(tr(STR_SELECT_FIRMWARE_FILE))
|
? std::string(tr(STR_SELECT_FIRMWARE_FILE))
|
||||||
: ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1));
|
: ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1));
|
||||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str());
|
|
||||||
|
const ThemeScreenSpec* screenSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::FileBrowser);
|
||||||
|
ThemeLayoutSlots slots;
|
||||||
|
Rect headerRect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
|
||||||
|
Rect listRect;
|
||||||
|
Rect pathRect;
|
||||||
|
Rect buttonsRect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
|
||||||
|
|
||||||
const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
||||||
const int pathReserved = pathLineHeight + metrics.verticalSpacing;
|
const int pathReserved = pathLineHeight + metrics.verticalSpacing;
|
||||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
if (screenSpec != nullptr) {
|
||||||
const int contentHeight =
|
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
|
||||||
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
headerRect = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
|
||||||
if (files.empty()) {
|
listRect = findThemeSlot(slots, "list");
|
||||||
|
pathRect = findThemeSlot(slots, "path");
|
||||||
|
buttonsRect = findThemeSlot(slots, "buttons");
|
||||||
|
if (listRect.width <= 0 || listRect.height <= 0) {
|
||||||
|
LOG_ERR("FileBrowser", "Invalid SD file layout: slots=%d; using built-in layout", static_cast<int>(slots.size()));
|
||||||
|
screenSpec = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (screenSpec == nullptr) {
|
||||||
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
const int contentHeight =
|
||||||
|
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||||
|
headerRect = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
|
||||||
|
listRect = Rect{0, contentTop, pageWidth, contentHeight};
|
||||||
|
pathRect = Rect{metrics.contentSidePadding,
|
||||||
|
pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight,
|
||||||
|
pageWidth - metrics.contentSidePadding * 2, pathLineHeight};
|
||||||
|
buttonsRect = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerRect.width > 0 && headerRect.height > 0) {
|
||||||
|
GUI.drawHeader(renderer, headerRect, folderName.c_str());
|
||||||
|
}
|
||||||
|
if (listRect.width <= 0 || listRect.height <= 0) {
|
||||||
|
// Malformed theme layout: no list slot to draw into.
|
||||||
|
} else if (files.empty()) {
|
||||||
const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND);
|
const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND);
|
||||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, emptyMsg);
|
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, listRect.y + 20, emptyMsg);
|
||||||
} else {
|
} else {
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
renderer, listRect, files.size(), selectorIndex, [this](int index) { return getFileName(files[index]); },
|
||||||
[this](int index) { return getFileName(files[index]); }, nullptr,
|
nullptr, [this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||||
[this](int index) { return UITheme::getFileIcon(files[index]); },
|
|
||||||
[this](int index) { return getFileExtension(files[index]); }, false);
|
[this](int index) { return getFileExtension(files[index]); }, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full path display
|
// Full path display
|
||||||
{
|
if (pathRect.width > 0 && pathRect.height > 0) {
|
||||||
const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight;
|
const int pathY = pathRect.y;
|
||||||
const int separatorY = pathY - metrics.verticalSpacing / 2;
|
const int separatorY = pathRect.y - metrics.verticalSpacing / 2;
|
||||||
renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true);
|
renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true);
|
||||||
const int pathMaxWidth = pageWidth - metrics.contentSidePadding * 2;
|
const int pathMaxWidth = pathRect.width;
|
||||||
// Left-truncate so the deepest directory is always visible
|
// Left-truncate so the deepest directory is always visible
|
||||||
const char* pathStr = basepath.c_str();
|
const char* pathStr = basepath.c_str();
|
||||||
const char* pathDisplay = pathStr;
|
const char* pathDisplay = pathStr;
|
||||||
@@ -397,7 +428,7 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p);
|
snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p);
|
||||||
pathDisplay = leftTruncBuf;
|
pathDisplay = leftTruncBuf;
|
||||||
}
|
}
|
||||||
renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay);
|
renderer.drawText(SMALL_FONT_ID, pathRect.x, pathY, pathDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Help text
|
// Help text
|
||||||
@@ -408,7 +439,9 @@ void FileBrowserActivity::render(RenderLock&&) {
|
|||||||
const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN));
|
const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN));
|
||||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP),
|
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP),
|
||||||
files.empty() ? "" : tr(STR_DIR_DOWN));
|
files.empty() ? "" : tr(STR_DIR_DOWN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
if (buttonsRect.width > 0 && buttonsRect.height > 0) {
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
#include "HomeActivity.h"
|
#include "HomeActivity.h"
|
||||||
|
|
||||||
#include <Bitmap.h>
|
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
#include <Utf8.h>
|
#include <Memory.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <algorithm>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
|
||||||
#include "CrossPointState.h"
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
|
||||||
|
|
||||||
int HomeActivity::getMenuItemCount() const {
|
void HomeActivity::buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const {
|
||||||
int count = 4; // File Browser, Recents, File transfer, Settings
|
buildThemeHomeActions(UITheme::getInstance().getHomeScreenSpec(), recentBooks, hasOpdsServers, actions);
|
||||||
if (!recentBooks.empty()) {
|
}
|
||||||
count += recentBooks.size();
|
|
||||||
}
|
const std::vector<ThemeHomeActionEntry>& HomeActivity::refreshHomeActions() {
|
||||||
if (hasOpdsServers) {
|
buildHomeActions(homeActions);
|
||||||
count++;
|
return homeActions;
|
||||||
}
|
}
|
||||||
return count;
|
|
||||||
|
int HomeActivity::getMenuItemCount() { return static_cast<int>(refreshHomeActions().size()); }
|
||||||
|
|
||||||
|
bool HomeActivity::storeCoverBufferCallback(void* userData) {
|
||||||
|
auto* activity = static_cast<HomeActivity*>(userData);
|
||||||
|
return activity != nullptr && activity->storeCoverBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HomeActivity::restoreCoverBufferCallback(void* userData) {
|
||||||
|
auto* activity = static_cast<HomeActivity*>(userData);
|
||||||
|
return activity != nullptr && activity->restoreCoverBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void HomeActivity::loadRecentBooks(int maxBooks) {
|
void HomeActivity::loadRecentBooks(int maxBooks) {
|
||||||
@@ -51,7 +57,7 @@ void HomeActivity::loadRecentBooks(int maxBooks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void HomeActivity::loadRecentCovers(int coverHeight) {
|
void HomeActivity::loadRecentCovers(const std::vector<int>& coverHeights) {
|
||||||
recentsLoading = true;
|
recentsLoading = true;
|
||||||
bool showingLoading = false;
|
bool showingLoading = false;
|
||||||
Rect popupRect;
|
Rect popupRect;
|
||||||
@@ -59,8 +65,16 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
int progress = 0;
|
int progress = 0;
|
||||||
for (RecentBook& book : recentBooks) {
|
for (RecentBook& book : recentBooks) {
|
||||||
if (!book.coverBmpPath.empty()) {
|
if (!book.coverBmpPath.empty()) {
|
||||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
bool hasMissingThumb = false;
|
||||||
if (!Storage.exists(coverPath.c_str())) {
|
for (const int coverHeight : coverHeights) {
|
||||||
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||||
|
if (!Storage.exists(coverPath.c_str())) {
|
||||||
|
hasMissingThumb = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMissingThumb) {
|
||||||
// If epub, try to load the metadata for title/author and cover
|
// If epub, try to load the metadata for title/author and cover
|
||||||
if (FsHelpers::hasEpubExtension(book.path)) {
|
if (FsHelpers::hasEpubExtension(book.path)) {
|
||||||
Epub epub(book.path, "/.crosspoint");
|
Epub epub(book.path, "/.crosspoint");
|
||||||
@@ -73,7 +87,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
||||||
}
|
}
|
||||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||||
bool success = epub.generateThumbBmp(coverHeight);
|
bool success = true;
|
||||||
|
for (const int coverHeight : coverHeights) {
|
||||||
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||||
|
if (!Storage.exists(coverPath.c_str())) {
|
||||||
|
success = epub.generateThumbBmp(coverHeight) && success;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!success) {
|
if (!success) {
|
||||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||||
book.coverBmpPath = "";
|
book.coverBmpPath = "";
|
||||||
@@ -90,7 +110,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
||||||
}
|
}
|
||||||
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
|
||||||
bool success = xtc.generateThumbBmp(coverHeight);
|
bool success = true;
|
||||||
|
for (const int coverHeight : coverHeights) {
|
||||||
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||||
|
if (!Storage.exists(coverPath.c_str())) {
|
||||||
|
success = xtc.generateThumbBmp(coverHeight) && success;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!success) {
|
if (!success) {
|
||||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||||
book.coverBmpPath = "";
|
book.coverBmpPath = "";
|
||||||
@@ -115,9 +141,46 @@ void HomeActivity::onEnter() {
|
|||||||
|
|
||||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
loadRecentBooks(metrics.homeRecentBooksCount);
|
loadRecentBooks(metrics.homeRecentBooksCount);
|
||||||
|
LOG_DBG("HOME", "Loaded %d/%d recent book(s) for home theme", static_cast<int>(recentBooks.size()),
|
||||||
|
metrics.homeRecentBooksCount);
|
||||||
|
|
||||||
const auto base = static_cast<int>(recentBooks.size());
|
const auto& actions = refreshHomeActions();
|
||||||
selectorIndex = initialMenuItem == HomeMenuItem::NONE ? 0 : base + menuItemToIndex(initialMenuItem, hasOpdsServers);
|
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
|
||||||
|
selectorIndex = 0;
|
||||||
|
bool hasWantedAction = initialMenuItem != HomeMenuItem::NONE;
|
||||||
|
const auto wantedAction = [this]() {
|
||||||
|
switch (initialMenuItem) {
|
||||||
|
case HomeMenuItem::RECENTS:
|
||||||
|
return ThemeHomeAction::RecentBooks;
|
||||||
|
case HomeMenuItem::OPDS_BROWSER:
|
||||||
|
return ThemeHomeAction::OpdsBrowser;
|
||||||
|
case HomeMenuItem::FILE_TRANSFER:
|
||||||
|
return ThemeHomeAction::FileTransfer;
|
||||||
|
case HomeMenuItem::SETTINGS_MENU:
|
||||||
|
return ThemeHomeAction::Settings;
|
||||||
|
case HomeMenuItem::FILE_BROWSER:
|
||||||
|
case HomeMenuItem::NONE:
|
||||||
|
default:
|
||||||
|
return ThemeHomeAction::FileBrowser;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
ThemeHomeAction selectedEntryAction = wantedAction;
|
||||||
|
if (!hasWantedAction && homeSpec != nullptr && homeSpec->hasInitialAction) {
|
||||||
|
hasWantedAction = true;
|
||||||
|
selectedEntryAction = homeSpec->initialAction;
|
||||||
|
}
|
||||||
|
if (hasWantedAction) {
|
||||||
|
for (int i = 0; i < static_cast<int>(actions.size()); ++i) {
|
||||||
|
if (actions[i].action == selectedEntryAction) {
|
||||||
|
selectorIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coverSelectorIndex = !recentBooks.empty() && selectorIndex < static_cast<int>(actions.size()) &&
|
||||||
|
actions[selectorIndex].action == ThemeHomeAction::RecentBook
|
||||||
|
? actions[selectorIndex].value
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
@@ -137,72 +200,123 @@ bool HomeActivity::storeCoverBuffer() {
|
|||||||
freeCoverBuffer();
|
freeCoverBuffer();
|
||||||
const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH);
|
const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH);
|
||||||
if (needed == 0) return false;
|
if (needed == 0) return false;
|
||||||
coverBuffer = static_cast<uint8_t*>(malloc(needed));
|
coverBuffer = makeUniqueNoThrow<uint8_t[]>(needed);
|
||||||
if (!coverBuffer) {
|
if (!coverBuffer) {
|
||||||
LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed);
|
LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
coverBufferSize = needed;
|
coverBufferSize = needed;
|
||||||
if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize)) {
|
if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer.get(),
|
||||||
free(coverBuffer);
|
coverBufferSize)) {
|
||||||
coverBuffer = nullptr;
|
coverBuffer.reset();
|
||||||
coverBufferSize = 0;
|
coverBufferSize = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
coverBufferSelectorIndex = coverSelectorIndex;
|
||||||
|
const auto& actions = refreshHomeActions();
|
||||||
|
coverBufferStripSelected = selectorIndex >= 0 && selectorIndex < static_cast<int>(actions.size()) &&
|
||||||
|
actions[selectorIndex].action == ThemeHomeAction::RecentBook;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool HomeActivity::restoreCoverBuffer() {
|
bool HomeActivity::restoreCoverBuffer() {
|
||||||
if (!coverBuffer || coverRectW <= 0 || coverRectH <= 0) return false;
|
if (!coverBuffer || coverRectW <= 0 || coverRectH <= 0) return false;
|
||||||
return renderer.copyBufferToRegion(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize);
|
return renderer.copyBufferToRegion(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer.get(),
|
||||||
|
coverBufferSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
void HomeActivity::freeCoverBuffer() {
|
void HomeActivity::freeCoverBuffer() {
|
||||||
if (coverBuffer) {
|
coverBuffer.reset();
|
||||||
free(coverBuffer);
|
|
||||||
coverBuffer = nullptr;
|
|
||||||
}
|
|
||||||
coverBufferSize = 0;
|
coverBufferSize = 0;
|
||||||
coverBufferStored = false;
|
coverBufferStored = false;
|
||||||
|
coverBufferSelectorIndex = -1;
|
||||||
|
coverBufferStripSelected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void HomeActivity::loop() {
|
void HomeActivity::loop() {
|
||||||
const int menuCount = getMenuItemCount();
|
const int menuCount = getMenuItemCount();
|
||||||
|
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
|
||||||
|
|
||||||
buttonNavigator.onNext([this, menuCount] {
|
auto updateCoverSelection = [this]() {
|
||||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
const auto& actions = refreshHomeActions();
|
||||||
requestUpdate();
|
if (selectorIndex < static_cast<int>(actions.size()) &&
|
||||||
});
|
actions[selectorIndex].action == ThemeHomeAction::RecentBook) {
|
||||||
|
coverSelectorIndex = actions[selectorIndex].value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
buttonNavigator.onPrevious([this, menuCount] {
|
auto moveWithin = [this, &updateCoverSelection](bool wantRecentBook, int delta) {
|
||||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
const auto& actions = refreshHomeActions();
|
||||||
|
if (actions.empty()) return;
|
||||||
|
|
||||||
|
navigationIndices.clear();
|
||||||
|
navigationIndices.reserve(actions.size());
|
||||||
|
for (int i = 0; i < static_cast<int>(actions.size()); ++i) {
|
||||||
|
if ((actions[i].action == ThemeHomeAction::RecentBook) == wantRecentBook) {
|
||||||
|
navigationIndices.push_back(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (navigationIndices.empty()) return;
|
||||||
|
|
||||||
|
auto current = std::find(navigationIndices.begin(), navigationIndices.end(), selectorIndex);
|
||||||
|
int groupIndex = current == navigationIndices.end() ? (delta > 0 ? -1 : 0)
|
||||||
|
: static_cast<int>(current - navigationIndices.begin());
|
||||||
|
groupIndex =
|
||||||
|
(groupIndex + delta + static_cast<int>(navigationIndices.size())) % static_cast<int>(navigationIndices.size());
|
||||||
|
selectorIndex = navigationIndices[groupIndex];
|
||||||
|
updateCoverSelection();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (homeSpec != nullptr && homeSpec->navigation == ThemeHomeNavigationMode::SplitAxis) {
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [moveWithin] { moveWithin(false, 1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [moveWithin] { moveWithin(false, -1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [moveWithin] { moveWithin(true, 1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [moveWithin] { moveWithin(true, -1); });
|
||||||
|
} else if (homeSpec != nullptr && homeSpec->navigation == ThemeHomeNavigationMode::CarouselAxis) {
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [moveWithin] { moveWithin(true, 1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [moveWithin] { moveWithin(true, -1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [moveWithin] { moveWithin(false, 1); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [moveWithin] { moveWithin(false, -1); });
|
||||||
|
} else {
|
||||||
|
buttonNavigator.onNext([this, menuCount, &updateCoverSelection] {
|
||||||
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||||
|
updateCoverSelection();
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPrevious([this, menuCount, &updateCoverSelection] {
|
||||||
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
||||||
|
updateCoverSelection();
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
if (selectorIndex < recentBooks.size()) {
|
const auto& actions = refreshHomeActions();
|
||||||
onSelectBook(recentBooks[selectorIndex].path);
|
if (selectorIndex < 0 || selectorIndex >= static_cast<int>(actions.size())) return;
|
||||||
} else {
|
const auto& entry = actions[selectorIndex];
|
||||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
switch (entry.action) {
|
||||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
case ThemeHomeAction::RecentBook:
|
||||||
case HomeMenuItem::FILE_BROWSER:
|
if (entry.value >= 0 && entry.value < static_cast<int>(recentBooks.size()))
|
||||||
onFileBrowserOpen();
|
onSelectBook(recentBooks[entry.value].path);
|
||||||
break;
|
break;
|
||||||
case HomeMenuItem::RECENTS:
|
case ThemeHomeAction::RecentBooks:
|
||||||
onRecentsOpen();
|
onRecentsOpen();
|
||||||
break;
|
break;
|
||||||
case HomeMenuItem::OPDS_BROWSER:
|
case ThemeHomeAction::OpdsBrowser:
|
||||||
onOpdsBrowserOpen();
|
onOpdsBrowserOpen();
|
||||||
break;
|
break;
|
||||||
case HomeMenuItem::FILE_TRANSFER:
|
case ThemeHomeAction::FileTransfer:
|
||||||
onFileTransferOpen();
|
onFileTransferOpen();
|
||||||
break;
|
break;
|
||||||
case HomeMenuItem::SETTINGS_MENU:
|
case ThemeHomeAction::Settings:
|
||||||
onSettingsOpen();
|
onSettingsOpen();
|
||||||
break;
|
break;
|
||||||
default:
|
case ThemeHomeAction::FileBrowser:
|
||||||
break;
|
default:
|
||||||
}
|
onFileBrowserOpen();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,24 +325,86 @@ void HomeActivity::render(RenderLock&&) {
|
|||||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
constexpr int coverCacheBleed = 12;
|
||||||
|
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
|
||||||
|
|
||||||
|
if (homeSpec != nullptr) {
|
||||||
|
const auto& actions = refreshHomeActions();
|
||||||
|
ThemeHomeRenderContext context{renderer,
|
||||||
|
mappedInput,
|
||||||
|
metrics,
|
||||||
|
*homeSpec,
|
||||||
|
recentBooks,
|
||||||
|
actions,
|
||||||
|
hasOpdsServers,
|
||||||
|
selectorIndex,
|
||||||
|
coverSelectorIndex,
|
||||||
|
coverRendered,
|
||||||
|
coverBufferStored,
|
||||||
|
coverBufferSelectorIndex,
|
||||||
|
coverBufferStripSelected,
|
||||||
|
coverRectX,
|
||||||
|
coverRectY,
|
||||||
|
coverRectW,
|
||||||
|
coverRectH,
|
||||||
|
this,
|
||||||
|
&HomeActivity::storeCoverBufferCallback,
|
||||||
|
&HomeActivity::restoreCoverBufferCallback};
|
||||||
|
if (renderThemeHome(context)) {
|
||||||
|
if (!firstRenderDone) {
|
||||||
|
firstRenderDone = true;
|
||||||
|
requestUpdate();
|
||||||
|
} else if (!recentsLoaded && !recentsLoading && !UITheme::getInstance().getHomeCoverThumbHeights().empty()) {
|
||||||
|
recentsLoading = true;
|
||||||
|
loadRecentCovers(UITheme::getInstance().getHomeCoverThumbHeights());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool hasCoverArea = metrics.homeCoverTileHeight > 0 && metrics.homeCoverHeight > 0;
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
|
||||||
|
|
||||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding},
|
|
||||||
metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr);
|
|
||||||
|
|
||||||
// Record the tile rect so storeCoverBuffer (called from the theme) knows
|
// Record the tile rect so storeCoverBuffer (called from the theme) knows
|
||||||
// which sub-region of the framebuffer to snapshot. ~16 KB in Portrait
|
// which sub-region of the framebuffer to snapshot. Include a small bleed
|
||||||
// instead of the 48 KB full framebuffer the previous bind captured.
|
// because cover-strip themes can draw selection outlines just outside the
|
||||||
|
// nominal cover tile.
|
||||||
coverRectX = 0;
|
coverRectX = 0;
|
||||||
coverRectY = metrics.homeTopPadding;
|
coverRectY = hasCoverArea ? std::max(0, metrics.homeTopPadding - coverCacheBleed) : 0;
|
||||||
coverRectW = pageWidth;
|
coverRectW = pageWidth;
|
||||||
coverRectH = metrics.homeCoverTileHeight;
|
coverRectH = hasCoverArea
|
||||||
|
? std::min(pageHeight - coverRectY,
|
||||||
|
metrics.homeCoverTileHeight + (metrics.homeTopPadding - coverRectY) + coverCacheBleed)
|
||||||
|
: 0;
|
||||||
|
|
||||||
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding},
|
||||||
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
|
metrics.homeContinueReadingInMenu && metrics.homeShowContinueReadingHeader && !recentBooks.empty()
|
||||||
std::bind(&HomeActivity::storeCoverBuffer, this));
|
? recentBooks[std::min(coverSelectorIndex, static_cast<int>(recentBooks.size()) - 1)].title.c_str()
|
||||||
|
: nullptr);
|
||||||
|
|
||||||
|
const bool selectorSensitiveCoverCache = GUI.homeCoverCacheDependsOnSelector();
|
||||||
|
const bool coverStripSelected = metrics.homeContinueReadingInMenu
|
||||||
|
? selectorIndex == 0 && !recentBooks.empty()
|
||||||
|
: selectorIndex < static_cast<int>(recentBooks.size());
|
||||||
|
const bool coverCacheMatches = !selectorSensitiveCoverCache || (coverBufferSelectorIndex == coverSelectorIndex &&
|
||||||
|
coverBufferStripSelected == coverStripSelected);
|
||||||
|
if (hasCoverArea && coverBufferStored && !coverCacheMatches) {
|
||||||
|
freeCoverBuffer();
|
||||||
|
coverRendered = false;
|
||||||
|
}
|
||||||
|
bool bufferRestored = hasCoverArea && coverBufferStored && coverCacheMatches && restoreCoverBuffer();
|
||||||
|
|
||||||
|
if (hasCoverArea) {
|
||||||
|
GUI.drawRecentBookCover(
|
||||||
|
renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks,
|
||||||
|
coverSelectorIndex, coverRendered, coverBufferStored, bufferRestored, [this]() { return storeCoverBuffer(); },
|
||||||
|
coverStripSelected);
|
||||||
|
} else {
|
||||||
|
coverRendered = false;
|
||||||
|
coverBufferStored = false;
|
||||||
|
bufferRestored = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Build menu items dynamically
|
// Build menu items dynamically
|
||||||
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
|
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
|
||||||
@@ -264,9 +440,9 @@ void HomeActivity::render(RenderLock&&) {
|
|||||||
if (!firstRenderDone) {
|
if (!firstRenderDone) {
|
||||||
firstRenderDone = true;
|
firstRenderDone = true;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else if (!recentsLoaded && !recentsLoading) {
|
} else if (!recentsLoaded && !recentsLoading && !UITheme::getInstance().getHomeCoverThumbHeights().empty()) {
|
||||||
recentsLoading = true;
|
recentsLoading = true;
|
||||||
loadRecentCovers(metrics.homeCoverHeight);
|
loadRecentCovers(UITheme::getInstance().getHomeCoverThumbHeights());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <functional>
|
#include <memory>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "./FileBrowserActivity.h"
|
#include "./FileBrowserActivity.h"
|
||||||
|
#include "./ThemeHomeRenderer.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
struct RecentBook;
|
struct RecentBook;
|
||||||
struct Rect;
|
|
||||||
|
|
||||||
class HomeActivity final : public Activity {
|
class HomeActivity final : public Activity {
|
||||||
ButtonNavigator buttonNavigator;
|
ButtonNavigator buttonNavigator;
|
||||||
int selectorIndex = 0;
|
int selectorIndex = 0;
|
||||||
|
int coverSelectorIndex = 0;
|
||||||
bool recentsLoading = false;
|
bool recentsLoading = false;
|
||||||
bool recentsLoaded = false;
|
bool recentsLoaded = false;
|
||||||
bool firstRenderDone = false;
|
bool firstRenderDone = false;
|
||||||
bool hasOpdsServers = false;
|
bool hasOpdsServers = false;
|
||||||
bool coverRendered = false; // Track if cover has been rendered once
|
bool coverRendered = false;
|
||||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
bool coverBufferStored = false;
|
||||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
std::unique_ptr<uint8_t[]> coverBuffer;
|
||||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
size_t coverBufferSize = 0;
|
||||||
|
int coverBufferSelectorIndex = -1;
|
||||||
|
bool coverBufferStripSelected = false;
|
||||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||||
// needs to cover this region, not the entire framebuffer, so we cache the
|
// needs to cover this region, not the entire framebuffer, so we cache the
|
||||||
// tile instead of all 48 KB. Set in render() before the call.
|
// tile instead of all 48 KB. Set in render() before the call.
|
||||||
@@ -28,33 +31,10 @@ class HomeActivity final : public Activity {
|
|||||||
int coverRectW = 0;
|
int coverRectW = 0;
|
||||||
int coverRectH = 0;
|
int coverRectH = 0;
|
||||||
std::vector<RecentBook> recentBooks;
|
std::vector<RecentBook> recentBooks;
|
||||||
|
std::vector<ThemeHomeActionEntry> homeActions;
|
||||||
|
std::vector<int> navigationIndices;
|
||||||
const HomeMenuItem initialMenuItem;
|
const HomeMenuItem initialMenuItem;
|
||||||
|
|
||||||
// Convert HomeMenuItem to menu index (used in onEnter)
|
|
||||||
static int menuItemToIndex(HomeMenuItem item, bool hasOpdsUrl) {
|
|
||||||
int i = 0;
|
|
||||||
if (item == HomeMenuItem::FILE_BROWSER) return i;
|
|
||||||
++i;
|
|
||||||
if (item == HomeMenuItem::RECENTS) return i;
|
|
||||||
++i;
|
|
||||||
if (item == HomeMenuItem::OPDS_BROWSER) return hasOpdsUrl ? i : 0;
|
|
||||||
if (hasOpdsUrl) ++i;
|
|
||||||
if (item == HomeMenuItem::FILE_TRANSFER) return i;
|
|
||||||
++i;
|
|
||||||
if (item == HomeMenuItem::SETTINGS_MENU) return i;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert menu index to HomeMenuItem (used in loop)
|
|
||||||
static HomeMenuItem indexToMenuItem(int idx, bool hasOpdsUrl) {
|
|
||||||
int i = 0;
|
|
||||||
if (idx == i++) return HomeMenuItem::FILE_BROWSER;
|
|
||||||
if (idx == i++) return HomeMenuItem::RECENTS;
|
|
||||||
if (hasOpdsUrl && idx == i++) return HomeMenuItem::OPDS_BROWSER;
|
|
||||||
if (idx == i++) return HomeMenuItem::FILE_TRANSFER;
|
|
||||||
if (idx == i) return HomeMenuItem::SETTINGS_MENU;
|
|
||||||
return HomeMenuItem::NONE;
|
|
||||||
}
|
|
||||||
void onSelectBook(const std::string& path);
|
void onSelectBook(const std::string& path);
|
||||||
void onFileBrowserOpen();
|
void onFileBrowserOpen();
|
||||||
void onRecentsOpen();
|
void onRecentsOpen();
|
||||||
@@ -62,12 +42,16 @@ class HomeActivity final : public Activity {
|
|||||||
void onFileTransferOpen();
|
void onFileTransferOpen();
|
||||||
void onOpdsBrowserOpen();
|
void onOpdsBrowserOpen();
|
||||||
|
|
||||||
int getMenuItemCount() const;
|
void buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const;
|
||||||
bool storeCoverBuffer(); // Store frame buffer for cover image
|
const std::vector<ThemeHomeActionEntry>& refreshHomeActions();
|
||||||
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
|
int getMenuItemCount();
|
||||||
void freeCoverBuffer(); // Free the stored cover buffer
|
static bool storeCoverBufferCallback(void* userData);
|
||||||
|
static bool restoreCoverBufferCallback(void* userData);
|
||||||
|
bool storeCoverBuffer();
|
||||||
|
bool restoreCoverBuffer();
|
||||||
|
void freeCoverBuffer();
|
||||||
void loadRecentBooks(int maxBooks);
|
void loadRecentBooks(int maxBooks);
|
||||||
void loadRecentCovers(int coverHeight);
|
void loadRecentCovers(const std::vector<int>& coverHeights);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#include "RecentBookCoverPainter.h"
|
||||||
|
|
||||||
|
#include <Bitmap.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "RecentBooksStore.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "components/icons/cover.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kCoverIconSourceSize = 32;
|
||||||
|
|
||||||
|
void drawScaledCoverIcon(const GfxRenderer& renderer, int x, int y, int size) {
|
||||||
|
if (size <= 0) return;
|
||||||
|
constexpr int bytesPerRow = kCoverIconSourceSize / 8;
|
||||||
|
for (int destY = 0; destY < size; ++destY) {
|
||||||
|
const int sourceY = destY * kCoverIconSourceSize / size;
|
||||||
|
for (int destX = 0; destX < size; ++destX) {
|
||||||
|
const int sourceX = destX * kCoverIconSourceSize / size;
|
||||||
|
const uint8_t rowByte = CoverIcon[sourceY * bytesPerRow + sourceX / 8];
|
||||||
|
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
|
||||||
|
if (background) continue;
|
||||||
|
renderer.drawPixel(x + size - 1 - destY, y + destX, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void drawDefaultRecentCover(const GfxRenderer& renderer, freeink::ui::Rect rect, int placeholderIconSize) {
|
||||||
|
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
|
||||||
|
const freeink::ui::Rect coverRect = rect;
|
||||||
|
renderer.drawRect(coverRect.x, coverRect.y, coverRect.width, coverRect.height, true);
|
||||||
|
renderer.fillRect(coverRect.x, coverRect.y + coverRect.height / 3, coverRect.width, 2 * coverRect.height / 3, true);
|
||||||
|
const int whiteBandHeight = std::max(1, coverRect.height / 3);
|
||||||
|
const int maxIconSize = std::max(1, std::min({coverRect.width - 12, whiteBandHeight - 4, coverRect.height - 12}));
|
||||||
|
const int iconSize = std::min(placeholderIconSize > 0 ? placeholderIconSize : 32, maxIconSize);
|
||||||
|
drawScaledCoverIcon(renderer, coverRect.x + std::max(0, (coverRect.width - iconSize) / 2),
|
||||||
|
coverRect.y + std::max(0, (whiteBandHeight - iconSize) / 2), iconSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paintRecentBookCoverByIndex(freeink::ui::Rect rect, int bookIndex, void* userData) {
|
||||||
|
auto* data = static_cast<RecentBookCoverPainterData*>(userData);
|
||||||
|
if (data == nullptr || data->renderer == nullptr || data->books == nullptr) return false;
|
||||||
|
if (bookIndex < 0 || bookIndex >= static_cast<int>(data->books->size())) return false;
|
||||||
|
|
||||||
|
const RecentBook& book = (*data->books)[bookIndex];
|
||||||
|
if (book.coverBmpPath.empty()) {
|
||||||
|
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int thumbHeight = data->coverHeight > 0 ? data->coverHeight : rect.height;
|
||||||
|
const std::string coverBmpPath = UITheme::getCoverThumbPath(book.coverBmpPath, thumbHeight);
|
||||||
|
HalFile file;
|
||||||
|
if (!Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||||
|
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap bitmap(file);
|
||||||
|
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
||||||
|
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
data->renderer->fillRect(rect.x, rect.y, rect.width, rect.height, false);
|
||||||
|
float cropX = 0.0f;
|
||||||
|
float cropY = 0.0f;
|
||||||
|
const float bitmapAspect = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||||
|
const float targetAspect = static_cast<float>(rect.width) / static_cast<float>(rect.height);
|
||||||
|
if (bitmapAspect > targetAspect) {
|
||||||
|
cropX = std::max(0.0f, 1.0f - targetAspect / bitmapAspect);
|
||||||
|
} else if (bitmapAspect < targetAspect) {
|
||||||
|
cropY = std::max(0.0f, 1.0f - bitmapAspect / targetAspect);
|
||||||
|
}
|
||||||
|
data->renderer->drawBitmap(bitmap, rect.x, rect.y, rect.width, rect.height, cropX, cropY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paintRecentCoverGridCover(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::CoverGridItem& item,
|
||||||
|
uint16_t, void* userData) {
|
||||||
|
return paintRecentBookCoverByIndex(rect, item.actionValue, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paintBookCardCover(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::BookCardProps& props,
|
||||||
|
void* userData) {
|
||||||
|
return paintRecentBookCoverByIndex(rect, props.value, userData);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <FreeInkUI.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
struct RecentBook;
|
||||||
|
|
||||||
|
struct RecentBookCoverPainterData {
|
||||||
|
const GfxRenderer* renderer = nullptr;
|
||||||
|
const std::vector<RecentBook>* books = nullptr;
|
||||||
|
int coverHeight = 0;
|
||||||
|
int placeholderIconSize = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void drawDefaultRecentCover(const GfxRenderer& renderer, freeink::ui::Rect rect, int placeholderIconSize = 0);
|
||||||
|
bool paintRecentBookCoverByIndex(freeink::ui::Rect rect, int bookIndex, void* userData);
|
||||||
|
bool paintRecentCoverGridCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect,
|
||||||
|
const freeink::ui::CoverGridItem& item, uint16_t index, void* userData);
|
||||||
|
bool paintBookCardCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect,
|
||||||
|
const freeink::ui::BookCardProps& props, void* userData);
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
#include "RecentBooksActivity.h"
|
#include "RecentBooksActivity.h"
|
||||||
|
|
||||||
|
#include <FreeInkUIGfxRenderer.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "RecentBookCoverPainter.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "activities/util/ConfirmationActivity.h"
|
#include "activities/util/ConfirmationActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
@@ -16,10 +20,118 @@
|
|||||||
namespace {
|
namespace {
|
||||||
// Hold threshold for the long-press "remove from list" action (firmware convention).
|
// Hold threshold for the long-press "remove from list" action (firmware convention).
|
||||||
constexpr unsigned long LONG_PRESS_MS = 1000;
|
constexpr unsigned long LONG_PRESS_MS = 1000;
|
||||||
|
|
||||||
|
struct RecentBooksRects {
|
||||||
|
Rect header;
|
||||||
|
Rect list;
|
||||||
|
Rect buttons;
|
||||||
|
bool themed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RecentBooksCoverGridItemProviderData {
|
||||||
|
const std::vector<RecentBook>* recentBooks = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
freeink::ui::CoverGridItem provideRecentBooksCoverGridItem(uint16_t index, void* userData) {
|
||||||
|
auto* data = static_cast<RecentBooksCoverGridItemProviderData*>(userData);
|
||||||
|
if (data == nullptr || data->recentBooks == nullptr || index >= data->recentBooks->size()) return {};
|
||||||
|
return freeink::ui::coverGridItem((*data->recentBooks)[index].title.c_str(), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeCoverGridWidgetSpec* recentBooksCoverGridWidget(const ThemeScreenSpec* screenSpec) {
|
||||||
|
if (screenSpec == nullptr) return nullptr;
|
||||||
|
const auto it = std::find_if(screenSpec->widgets.begin(), screenSpec->widgets.end(),
|
||||||
|
[](const auto& widget) { return widget.type == ThemeScreenWidgetType::CoverGrid; });
|
||||||
|
return it == screenSpec->widgets.end() ? nullptr : &it->coverGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeCoverGridWidgetSpec normalizedRecentBooksCoverGridSpec(const ThemeCoverGridWidgetSpec& source) {
|
||||||
|
ThemeCoverGridWidgetSpec spec = source;
|
||||||
|
if (!spec.configured) {
|
||||||
|
spec.columns = 3;
|
||||||
|
spec.gap = 14;
|
||||||
|
spec.rowGap = 20;
|
||||||
|
spec.coverWidth = 92;
|
||||||
|
spec.coverHeight = 132;
|
||||||
|
spec.rowHeight = 172;
|
||||||
|
spec.labelHeight = 34;
|
||||||
|
spec.labelLines = 2;
|
||||||
|
spec.selectedRadius = 0;
|
||||||
|
spec.selectionStyle = ThemeWidgetSelectionStyle::CoverFrame;
|
||||||
|
spec.cellInset.top = 5;
|
||||||
|
spec.labelInset.left = 5;
|
||||||
|
spec.labelInset.right = 5;
|
||||||
|
}
|
||||||
|
spec.columns = std::max(1, spec.columns);
|
||||||
|
spec.rowGap = spec.rowGap >= 0 ? spec.rowGap : std::max(0, spec.gap);
|
||||||
|
spec.coverHeight = spec.coverHeight > 0 ? spec.coverHeight : 132;
|
||||||
|
spec.coverWidth = spec.coverWidth > 0 ? spec.coverWidth : std::max(1, spec.coverHeight * 62 / 100);
|
||||||
|
spec.placeholderIconSize = std::max(0, spec.placeholderIconSize);
|
||||||
|
spec.labelHeight = std::max(0, spec.labelHeight);
|
||||||
|
spec.labelGap = std::max(0, spec.labelGap);
|
||||||
|
spec.labelLines = std::max(1, std::min(3, spec.labelLines));
|
||||||
|
spec.rowHeight = spec.rowHeight > 0 ? spec.rowHeight : spec.coverHeight + spec.labelHeight + 6;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecentBooksRects resolveRecentBooksRects(GfxRenderer& renderer, const ThemeMetrics& metrics,
|
||||||
|
const ThemeScreenSpec*& screenSpec) {
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
RecentBooksRects rects;
|
||||||
|
|
||||||
|
if (screenSpec != nullptr) {
|
||||||
|
ThemeLayoutSlots slots;
|
||||||
|
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
|
||||||
|
rects.header = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
|
||||||
|
rects.list = findThemeSlot(slots, "list");
|
||||||
|
rects.buttons = findThemeSlot(slots, "buttons");
|
||||||
|
if (rects.list.width > 0 && rects.list.height > 0) {
|
||||||
|
rects.themed = true;
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
LOG_ERR("RecentBooks", "Invalid SD recent layout: slots=%d; using built-in layout", static_cast<int>(slots.size()));
|
||||||
|
screenSpec = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||||
|
rects.header = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
|
||||||
|
rects.list = Rect{0, contentTop, pageWidth, contentHeight};
|
||||||
|
rects.buttons = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
int recentBooksCoverGridPageItems(Rect listRect, const ThemeCoverGridWidgetSpec& spec) {
|
||||||
|
return std::max<int>(1, freeink::ui::coverGridVisibleCells(
|
||||||
|
freeink::ui::makeRect(listRect.x, listRect.y, listRect.width, listRect.height),
|
||||||
|
std::min<int>(std::max(1, spec.columns), 12), freeink::ui::clampI16(spec.rowHeight, 1),
|
||||||
|
freeink::ui::clampI16(spec.rowGap)));
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::ui::Insets toFreeInkInsets(const ThemeEdgeInsets& insets) {
|
||||||
|
return freeink::ui::makeInsets(insets.top, insets.right, insets.bottom, insets.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::ui::StyleSet recentBooksGridStyles(const ThemeCoverGridWidgetSpec& spec) {
|
||||||
|
return freeink::ui::selectedOutlineListRowStyles(spec.selectedRadius);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void RecentBooksActivity::loadRecentBooks() { recentBooks = RECENT_BOOKS.getBooks(); }
|
void RecentBooksActivity::loadRecentBooks() { recentBooks = RECENT_BOOKS.getBooks(); }
|
||||||
|
|
||||||
|
int RecentBooksActivity::getPageItems() {
|
||||||
|
auto& theme = UITheme::getInstance();
|
||||||
|
const ThemeScreenSpec* screenSpec = theme.getScreenSpec(ThemeScreenKind::RecentBooks);
|
||||||
|
const auto rects = resolveRecentBooksRects(renderer, theme.getMetrics(), screenSpec);
|
||||||
|
if (rects.themed) {
|
||||||
|
const ThemeCoverGridWidgetSpec* grid = recentBooksCoverGridWidget(screenSpec);
|
||||||
|
if (grid != nullptr) return recentBooksCoverGridPageItems(rects.list, normalizedRecentBooksCoverGridSpec(*grid));
|
||||||
|
}
|
||||||
|
return theme.getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
void RecentBooksActivity::onEnter() {
|
void RecentBooksActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
|
|
||||||
@@ -42,7 +154,7 @@ void RecentBooksActivity::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RecentBooksActivity::loop() {
|
void RecentBooksActivity::loop() {
|
||||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
const int pageItems = getPageItems();
|
||||||
|
|
||||||
// After a long-press has fired, swallow input until Confirm is physically released
|
// After a long-press has fired, swallow input until Confirm is physically released
|
||||||
// (so the release doesn't also open the book; re-arm only once the button is up).
|
// (so the release doesn't also open the book; re-arm only once the button is up).
|
||||||
@@ -124,28 +236,68 @@ void RecentBooksActivity::promptRemoveBook(const std::string& path, const std::s
|
|||||||
void RecentBooksActivity::render(RenderLock&&) {
|
void RecentBooksActivity::render(RenderLock&&) {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
auto& theme = UITheme::getInstance();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto& metrics = theme.getMetrics();
|
||||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
const ThemeScreenSpec* screenSpec = theme.getScreenSpec(ThemeScreenKind::RecentBooks);
|
||||||
|
const auto rects = resolveRecentBooksRects(renderer, metrics, screenSpec);
|
||||||
|
const ThemeCoverGridWidgetSpec* coverGridWidget = rects.themed ? recentBooksCoverGridWidget(screenSpec) : nullptr;
|
||||||
|
|
||||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_MENU_RECENT_BOOKS));
|
if (rects.header.width > 0 && rects.header.height > 0) {
|
||||||
|
GUI.drawHeader(renderer, rects.header, tr(STR_MENU_RECENT_BOOKS));
|
||||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
}
|
||||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
|
||||||
|
|
||||||
// Recent tab
|
// Recent tab
|
||||||
if (recentBooks.empty()) {
|
if (rects.list.width <= 0 || rects.list.height <= 0) {
|
||||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, tr(STR_NO_RECENT_BOOKS));
|
// Malformed theme layout: no list slot to draw into.
|
||||||
|
} else if (recentBooks.empty()) {
|
||||||
|
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, rects.list.y + 20, tr(STR_NO_RECENT_BOOKS));
|
||||||
|
} else if (coverGridWidget != nullptr) {
|
||||||
|
#if FREEINK_HAVE_GFX_RENDERER
|
||||||
|
const auto gridSpec = normalizedRecentBooksCoverGridSpec(*coverGridWidget);
|
||||||
|
freeink::ui::GfxRendererFrame<> ui(renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
|
||||||
|
RecentBookCoverPainterData painterData{
|
||||||
|
&renderer, &recentBooks, UITheme::getInstance().getRecentBooksCoverThumbHeight(), gridSpec.placeholderIconSize};
|
||||||
|
RecentBooksCoverGridItemProviderData itemProviderData{&recentBooks};
|
||||||
|
const int pageItems = recentBooksCoverGridPageItems(rects.list, gridSpec);
|
||||||
|
freeink::ui::CoverGridProps props;
|
||||||
|
props.itemProvider = provideRecentBooksCoverGridItem;
|
||||||
|
props.itemProviderUserData = &itemProviderData;
|
||||||
|
props.count = static_cast<uint16_t>(std::min<size_t>(recentBooks.size(), 65535));
|
||||||
|
props.topIndex = freeink::ui::coverGridTopIndexFor(
|
||||||
|
static_cast<uint16_t>(selectorIndex), static_cast<uint16_t>(std::min<size_t>(recentBooks.size(), 65535)),
|
||||||
|
std::min<int>(std::max(1, gridSpec.columns), 12), static_cast<uint16_t>(pageItems));
|
||||||
|
props.selectedIndex = static_cast<int16_t>(selectorIndex);
|
||||||
|
props.columns = static_cast<uint8_t>(std::min(std::max(1, gridSpec.columns), 12));
|
||||||
|
props.gap = freeink::ui::clampI16(gridSpec.gap);
|
||||||
|
props.rowGap = freeink::ui::clampI16(gridSpec.rowGap);
|
||||||
|
props.cellInset = toFreeInkInsets(gridSpec.cellInset);
|
||||||
|
props.labelInset = toFreeInkInsets(gridSpec.labelInset);
|
||||||
|
props.coverSize = freeink::ui::makeSize(gridSpec.coverWidth, gridSpec.coverHeight);
|
||||||
|
props.rowHeight = freeink::ui::clampI16(gridSpec.rowHeight, 1);
|
||||||
|
props.labelHeight = freeink::ui::clampI16(gridSpec.labelHeight);
|
||||||
|
props.labelGap = freeink::ui::clampI16(gridSpec.labelGap);
|
||||||
|
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_SMALL;
|
||||||
|
props.titleText.maxLines = static_cast<uint8_t>(std::max(1, std::min(3, gridSpec.labelLines)));
|
||||||
|
props.cellStyles = recentBooksGridStyles(gridSpec);
|
||||||
|
props.selectionIndicator = freeink::ui::CoverGridSelectionIndicator::CoverFrame;
|
||||||
|
props.selectedCoverFrameRadius = freeink::ui::clampRadius(gridSpec.selectedRadius);
|
||||||
|
props.coverPainter = paintRecentCoverGridCover;
|
||||||
|
props.coverPainterUserData = &painterData;
|
||||||
|
freeink::ui::coverGrid(
|
||||||
|
ui.frame, freeink::ui::makeRect(rects.list.x, rects.list.y, rects.list.width, rects.list.height), props);
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex,
|
renderer, rects.list, recentBooks.size(), selectorIndex, [this](int index) { return recentBooks[index].title; },
|
||||||
[this](int index) { return recentBooks[index].title; }, [this](int index) { return recentBooks[index].author; },
|
[this](int index) { return recentBooks[index].author; },
|
||||||
[this](int index) { return UITheme::getFileIcon(recentBooks[index].path); });
|
[this](int index) { return UITheme::getFileIcon(recentBooks[index].path); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Help text
|
// Help text
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
if (rects.buttons.width > 0 && rects.buttons.height > 0) {
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class RecentBooksActivity final : public Activity {
|
|||||||
|
|
||||||
// Data loading
|
// Data loading
|
||||||
void loadRecentBooks();
|
void loadRecentBooks();
|
||||||
|
int getPageItems();
|
||||||
|
|
||||||
// Show an OK/Cancel prompt to remove the given book from the Recent Books list.
|
// Show an OK/Cancel prompt to remove the given book from the Recent Books list.
|
||||||
void promptRemoveBook(const std::string& path, const std::string& title);
|
void promptRemoveBook(const std::string& path, const std::string& title);
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
#include "ThemeHomeRenderer.h"
|
||||||
|
|
||||||
|
#include <FreeInkUIGfxRenderer.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalClock.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "RecentBookCoverPainter.h"
|
||||||
|
#include "RecentBooksStore.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "components/icons/book.h"
|
||||||
|
#include "components/icons/folder.h"
|
||||||
|
#include "components/icons/library.h"
|
||||||
|
#include "components/icons/recent.h"
|
||||||
|
#include "components/icons/settings2.h"
|
||||||
|
#include "components/icons/transfer.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kCoverCacheBleed = 12;
|
||||||
|
|
||||||
|
const char* defaultLauncherLabel(ThemeHomeAction action) {
|
||||||
|
switch (action) {
|
||||||
|
case ThemeHomeAction::RecentBooks:
|
||||||
|
return tr(STR_MENU_RECENT_BOOKS);
|
||||||
|
case ThemeHomeAction::OpdsBrowser:
|
||||||
|
return tr(STR_OPDS_BROWSER);
|
||||||
|
case ThemeHomeAction::FileTransfer:
|
||||||
|
return tr(STR_FILE_TRANSFER);
|
||||||
|
case ThemeHomeAction::Settings:
|
||||||
|
return tr(STR_SETTINGS_TITLE);
|
||||||
|
case ThemeHomeAction::RecentBook:
|
||||||
|
return tr(STR_CONTINUE_READING);
|
||||||
|
case ThemeHomeAction::FileBrowser:
|
||||||
|
default:
|
||||||
|
return tr(STR_BROWSE_FILES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* buttonHintLabel(ThemeButtonHintLabel label, const char* fallback) {
|
||||||
|
switch (label) {
|
||||||
|
case ThemeButtonHintLabel::Empty:
|
||||||
|
return "";
|
||||||
|
case ThemeButtonHintLabel::Back:
|
||||||
|
return tr(STR_BACK);
|
||||||
|
case ThemeButtonHintLabel::Home:
|
||||||
|
return tr(STR_HOME);
|
||||||
|
case ThemeButtonHintLabel::Select:
|
||||||
|
return tr(STR_SELECT);
|
||||||
|
case ThemeButtonHintLabel::Confirm:
|
||||||
|
return tr(STR_CONFIRM);
|
||||||
|
case ThemeButtonHintLabel::Open:
|
||||||
|
return tr(STR_OPEN);
|
||||||
|
case ThemeButtonHintLabel::Toggle:
|
||||||
|
return tr(STR_TOGGLE);
|
||||||
|
case ThemeButtonHintLabel::Up:
|
||||||
|
return tr(STR_DIR_UP);
|
||||||
|
case ThemeButtonHintLabel::Down:
|
||||||
|
return tr(STR_DIR_DOWN);
|
||||||
|
case ThemeButtonHintLabel::Left:
|
||||||
|
return tr(STR_DIR_LEFT);
|
||||||
|
case ThemeButtonHintLabel::Right:
|
||||||
|
return tr(STR_DIR_RIGHT);
|
||||||
|
case ThemeButtonHintLabel::Default:
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UIIcon defaultLauncherIcon(ThemeHomeAction action) {
|
||||||
|
switch (action) {
|
||||||
|
case ThemeHomeAction::RecentBooks:
|
||||||
|
return UIIcon::Recent;
|
||||||
|
case ThemeHomeAction::OpdsBrowser:
|
||||||
|
return UIIcon::Library;
|
||||||
|
case ThemeHomeAction::FileTransfer:
|
||||||
|
return UIIcon::Transfer;
|
||||||
|
case ThemeHomeAction::Settings:
|
||||||
|
return UIIcon::Settings;
|
||||||
|
case ThemeHomeAction::RecentBook:
|
||||||
|
return UIIcon::Book;
|
||||||
|
case ThemeHomeAction::FileBrowser:
|
||||||
|
default:
|
||||||
|
return UIIcon::Folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string homeHeaderTitle(const ThemeMetrics& metrics, const std::vector<RecentBook>& recentBooks,
|
||||||
|
const int coverSelectorIndex) {
|
||||||
|
if (metrics.homeContinueReadingInMenu && metrics.homeShowContinueReadingHeader && !recentBooks.empty()) {
|
||||||
|
return recentBooks[std::min(coverSelectorIndex, static_cast<int>(recentBooks.size()) - 1)].title;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect placedWidgetRect(Rect slot, const ThemeHomeWidgetSpec& widget) {
|
||||||
|
slot.x += widget.offsetX - widget.bleed.left;
|
||||||
|
slot.y += widget.offsetY - widget.bleed.top;
|
||||||
|
slot.width += widget.bleed.left + widget.bleed.right;
|
||||||
|
slot.height += widget.bleed.top + widget.bleed.bottom;
|
||||||
|
slot.x += widget.inset.left;
|
||||||
|
slot.y += widget.inset.top;
|
||||||
|
slot.width -= widget.inset.left + widget.inset.right;
|
||||||
|
slot.height -= widget.inset.top + widget.inset.bottom;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::ui::Insets toFreeInkInsets(const ThemeEdgeInsets& insets) {
|
||||||
|
return freeink::ui::makeInsets(insets.top, insets.right, insets.bottom, insets.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::ui::StyleSet widgetSelectionStyles(ThemeWidgetSelectionStyle selectionStyle, int selectedRadius) {
|
||||||
|
if (selectionStyle == ThemeWidgetSelectionStyle::Outline) {
|
||||||
|
return freeink::ui::selectedOutlineListRowStyles(selectedRadius);
|
||||||
|
}
|
||||||
|
if (selectionStyle == ThemeWidgetSelectionStyle::None) return freeink::ui::selectedPlainListRowStyles();
|
||||||
|
return freeink::ui::defaultListRowStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::ui::CoverGridSelectionIndicator coverGridSelectionIndicator(ThemeWidgetSelectionStyle selectionStyle) {
|
||||||
|
return selectionStyle == ThemeWidgetSelectionStyle::CoverFrame ? freeink::ui::CoverGridSelectionIndicator::CoverFrame
|
||||||
|
: freeink::ui::CoverGridSelectionIndicator::Cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* homeTabIcon(UIIcon icon) {
|
||||||
|
switch (icon) {
|
||||||
|
case UIIcon::Folder:
|
||||||
|
return FolderIcon;
|
||||||
|
case UIIcon::Book:
|
||||||
|
return BookIcon;
|
||||||
|
case UIIcon::Recent:
|
||||||
|
return RecentIcon;
|
||||||
|
case UIIcon::Library:
|
||||||
|
return LibraryIcon;
|
||||||
|
case UIIcon::Transfer:
|
||||||
|
return TransferIcon;
|
||||||
|
case UIIcon::Settings:
|
||||||
|
return Settings2Icon;
|
||||||
|
default:
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeIconTabPainterData {
|
||||||
|
const GfxRenderer* renderer = nullptr;
|
||||||
|
const ThemeHomeLauncherSpec* const* launchers = nullptr;
|
||||||
|
size_t launcherCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HomeCoverGridItemProviderData {
|
||||||
|
const std::vector<RecentBook>* recentBooks = nullptr;
|
||||||
|
int startIndex = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
freeink::ui::CoverGridItem provideHomeCoverGridItem(uint16_t index, void* userData) {
|
||||||
|
auto* data = static_cast<HomeCoverGridItemProviderData*>(userData);
|
||||||
|
if (data == nullptr || data->recentBooks == nullptr) return {};
|
||||||
|
const int bookIndex = data->startIndex + static_cast<int>(index);
|
||||||
|
if (bookIndex < 0 || bookIndex >= static_cast<int>(data->recentBooks->size())) return {};
|
||||||
|
return freeink::ui::coverGridItem((*data->recentBooks)[bookIndex].title.c_str(), bookIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paintHomeIconTab(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::TabItem& tab, uint8_t,
|
||||||
|
void* userData) {
|
||||||
|
auto* data = static_cast<HomeIconTabPainterData*>(userData);
|
||||||
|
if (data == nullptr || data->renderer == nullptr || data->launchers == nullptr) return false;
|
||||||
|
const int index = tab.value;
|
||||||
|
if (index < 0 || index >= static_cast<int>(data->launcherCount)) return false;
|
||||||
|
const auto& launcher = *data->launchers[index];
|
||||||
|
const uint8_t* icon =
|
||||||
|
homeTabIcon(launcher.icon == UIIcon::None ? defaultLauncherIcon(launcher.action) : launcher.icon);
|
||||||
|
if (icon == nullptr) return false;
|
||||||
|
data->renderer->drawIcon(icon, rect.x, rect.y, rect.width, rect.height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WidgetRenderEntry {
|
||||||
|
const ThemeHomeWidgetSpec* widget;
|
||||||
|
int actionOffset;
|
||||||
|
size_t order;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WidgetRenderEntries {
|
||||||
|
std::array<WidgetRenderEntry, kMaxThemeWidgets> items;
|
||||||
|
size_t count = 0;
|
||||||
|
|
||||||
|
void push(const WidgetRenderEntry& entry) {
|
||||||
|
if (count >= items.size()) return;
|
||||||
|
items[count++] = entry;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool themeHomeActionVisible(ThemeHomeAction action, bool hasOpdsServers, bool hasRecentBooks) {
|
||||||
|
if (action == ThemeHomeAction::OpdsBrowser) return hasOpdsServers;
|
||||||
|
if (action == ThemeHomeAction::RecentBook) return hasRecentBooks;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetRenderEntries buildRenderEntries(const ThemeHomeScreenSpec& spec, const std::vector<RecentBook>& recentBooks,
|
||||||
|
bool hasOpdsServers) {
|
||||||
|
WidgetRenderEntries entries;
|
||||||
|
int nextActionOffset = 0;
|
||||||
|
for (size_t i = 0; i < spec.widgets.size(); ++i) {
|
||||||
|
const auto& widget = spec.widgets[i];
|
||||||
|
const int widgetActionOffset = nextActionOffset;
|
||||||
|
if (widget.type == ThemeHomeWidgetType::Recents) {
|
||||||
|
nextActionOffset += static_cast<int>(recentBooks.size());
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
|
||||||
|
if (std::max(0, widget.featured.startIndex) < static_cast<int>(recentBooks.size())) ++nextActionOffset;
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
|
||||||
|
const int maxItems = widget.coverGrid.rows > 0 ? widget.coverGrid.rows * std::max(1, widget.coverGrid.columns)
|
||||||
|
: static_cast<int>(recentBooks.size());
|
||||||
|
const int startIndex = std::max(0, widget.coverGrid.startIndex);
|
||||||
|
nextActionOffset += std::min({std::max(0, static_cast<int>(recentBooks.size()) - startIndex), maxItems,
|
||||||
|
static_cast<int>(kMaxThemeCoverGridItems)});
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
|
||||||
|
nextActionOffset += static_cast<int>(
|
||||||
|
std::count_if(widget.launcher.items.begin(), widget.launcher.items.end(), [&](const auto& launcher) {
|
||||||
|
return themeHomeActionVisible(launcher.action, hasOpdsServers, !recentBooks.empty());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
entries.push(WidgetRenderEntry{&widget, widgetActionOffset, i});
|
||||||
|
}
|
||||||
|
std::stable_sort(entries.items.begin(), entries.items.begin() + entries.count, [](const auto& a, const auto& b) {
|
||||||
|
if (a.widget->layer != b.widget->layer) return a.widget->layer < b.widget->layer;
|
||||||
|
return a.order < b.order;
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void buildThemeHomeActions(const ThemeHomeScreenSpec* spec, const std::vector<RecentBook>& recentBooks,
|
||||||
|
bool hasOpdsServers, std::vector<ThemeHomeActionEntry>& actions) {
|
||||||
|
actions.clear();
|
||||||
|
if (spec != nullptr) {
|
||||||
|
for (const auto& widget : spec->widgets) {
|
||||||
|
if (widget.type == ThemeHomeWidgetType::Recents) {
|
||||||
|
for (int i = 0; i < static_cast<int>(recentBooks.size()); ++i) {
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, i});
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
|
||||||
|
const int index = std::max(0, widget.featured.startIndex);
|
||||||
|
if (index < static_cast<int>(recentBooks.size())) {
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, index});
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
|
||||||
|
const int maxItems = widget.coverGrid.rows > 0 ? widget.coverGrid.rows * std::max(1, widget.coverGrid.columns)
|
||||||
|
: static_cast<int>(recentBooks.size());
|
||||||
|
const int startIndex = std::max(0, widget.coverGrid.startIndex);
|
||||||
|
for (int i = 0; startIndex + i < static_cast<int>(recentBooks.size()) && i < maxItems &&
|
||||||
|
i < static_cast<int>(kMaxThemeCoverGridItems);
|
||||||
|
++i) {
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, startIndex + i});
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
|
||||||
|
for (const auto& launcher : widget.launcher.items) {
|
||||||
|
if (themeHomeActionVisible(launcher.action, hasOpdsServers, !recentBooks.empty())) {
|
||||||
|
actions.push_back(ThemeHomeActionEntry{launcher.action, 0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!actions.empty()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < static_cast<int>(recentBooks.size()); ++i) {
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, i});
|
||||||
|
}
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::FileBrowser, 0});
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBooks, 0});
|
||||||
|
if (hasOpdsServers) actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::OpdsBrowser, 0});
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::FileTransfer, 0});
|
||||||
|
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::Settings, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool renderThemeHome(ThemeHomeRenderContext& ctx) {
|
||||||
|
const auto pageWidth = ctx.renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = ctx.renderer.getScreenHeight();
|
||||||
|
|
||||||
|
ThemeLayoutSlots slots;
|
||||||
|
layoutThemeSlots(ctx.spec.layout, Rect{0, 0, pageWidth, pageHeight}, ctx.metrics, slots);
|
||||||
|
if (slots.empty()) {
|
||||||
|
const auto& layout = ctx.spec.layout;
|
||||||
|
const auto& first = layout.children.empty() ? layout : layout.children.front();
|
||||||
|
LOG_ERR("HOME",
|
||||||
|
"SD home layout emitted no slots: page=%dx%d children=%d firstId=%s firstType=%d firstSize=%d firstFlex=%d",
|
||||||
|
pageWidth, pageHeight, static_cast<int>(layout.children.size()), first.id.c_str(),
|
||||||
|
static_cast<int>(first.sizeType), first.size, first.flex);
|
||||||
|
}
|
||||||
|
const bool sdHomeUsable = !slots.empty() && !ctx.actions.empty();
|
||||||
|
if (!sdHomeUsable) {
|
||||||
|
LOG_ERR("HOME", "Invalid SD home layout: widgets=%d slots=%d actions=%d; using built-in layout",
|
||||||
|
static_cast<int>(ctx.spec.widgets.size()), static_cast<int>(slots.size()),
|
||||||
|
static_cast<int>(ctx.actions.size()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.renderer.clearScreen();
|
||||||
|
ctx.coverRectX = 0;
|
||||||
|
ctx.coverRectY = 0;
|
||||||
|
ctx.coverRectW = 0;
|
||||||
|
ctx.coverRectH = 0;
|
||||||
|
|
||||||
|
const auto renderWidgets = buildRenderEntries(ctx.spec, ctx.recentBooks, ctx.hasOpdsServers);
|
||||||
|
for (size_t renderIndex = 0; renderIndex < renderWidgets.count; ++renderIndex) {
|
||||||
|
const auto& entry = renderWidgets.items[renderIndex];
|
||||||
|
const auto& widget = *entry.widget;
|
||||||
|
Rect slot = placedWidgetRect(findThemeSlot(slots, widget.slot), widget);
|
||||||
|
if (slot.width <= 0 || slot.height <= 0) continue;
|
||||||
|
|
||||||
|
if (widget.type == ThemeHomeWidgetType::Header) {
|
||||||
|
const auto title = homeHeaderTitle(ctx.metrics, ctx.recentBooks, ctx.coverSelectorIndex);
|
||||||
|
GUI.drawHeader(ctx.renderer, slot, title.empty() ? nullptr : title.c_str());
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::HeaderTitle) {
|
||||||
|
const auto title = homeHeaderTitle(ctx.metrics, ctx.recentBooks, ctx.coverSelectorIndex);
|
||||||
|
if (!title.empty()) {
|
||||||
|
const auto truncated = ctx.renderer.truncatedText(UI_10_FONT_ID, title.c_str(), slot.width);
|
||||||
|
const int textWidth = ctx.renderer.getTextWidth(UI_10_FONT_ID, truncated.c_str());
|
||||||
|
ctx.renderer.drawText(UI_10_FONT_ID, slot.x + std::max(0, (slot.width - textWidth) / 2),
|
||||||
|
slot.y + std::max(0, (slot.height - ctx.renderer.getLineHeight(UI_10_FONT_ID)) / 2),
|
||||||
|
truncated.c_str());
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::Battery) {
|
||||||
|
const bool showBatteryPercentage =
|
||||||
|
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
||||||
|
const int batteryX = slot.x + std::max(0, slot.width - ctx.metrics.batteryWidth);
|
||||||
|
GUI.drawBatteryRight(ctx.renderer, Rect{batteryX, slot.y, ctx.metrics.batteryWidth, ctx.metrics.batteryHeight},
|
||||||
|
showBatteryPercentage);
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::Clock) {
|
||||||
|
if (halClock.isAvailable()) {
|
||||||
|
char timeBuf[9];
|
||||||
|
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
|
||||||
|
auto clockText = ctx.renderer.truncatedText(SMALL_FONT_ID, timeBuf, slot.width);
|
||||||
|
const int textWidth = ctx.renderer.getTextWidth(SMALL_FONT_ID, clockText.c_str());
|
||||||
|
ctx.renderer.drawText(SMALL_FONT_ID, slot.x + std::max(0, (slot.width - textWidth) / 2), slot.y,
|
||||||
|
clockText.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::Recents) {
|
||||||
|
const bool hasCoverArea = slot.height > 0 && ctx.metrics.homeCoverHeight > 0;
|
||||||
|
ctx.coverRectX = 0;
|
||||||
|
ctx.coverRectY = std::max(0, slot.y - kCoverCacheBleed);
|
||||||
|
ctx.coverRectW = pageWidth;
|
||||||
|
ctx.coverRectH =
|
||||||
|
std::min(pageHeight - ctx.coverRectY, slot.height + (slot.y - ctx.coverRectY) + kCoverCacheBleed);
|
||||||
|
|
||||||
|
const bool selectorSensitiveCoverCache = GUI.homeCoverCacheDependsOnSelector();
|
||||||
|
const bool coverStripSelected = ctx.selectorIndex >= entry.actionOffset &&
|
||||||
|
ctx.selectorIndex < entry.actionOffset + static_cast<int>(ctx.recentBooks.size());
|
||||||
|
if (coverStripSelected) {
|
||||||
|
ctx.coverSelectorIndex = ctx.actions[ctx.selectorIndex].value;
|
||||||
|
}
|
||||||
|
const bool coverCacheMatches =
|
||||||
|
!selectorSensitiveCoverCache || (ctx.coverBufferSelectorIndex == ctx.coverSelectorIndex &&
|
||||||
|
ctx.coverBufferStripSelected == coverStripSelected);
|
||||||
|
if (hasCoverArea && ctx.coverBufferStored && !coverCacheMatches) {
|
||||||
|
ctx.coverBufferStored = false;
|
||||||
|
ctx.coverRendered = false;
|
||||||
|
}
|
||||||
|
bool bufferRestored = hasCoverArea && ctx.coverBufferStored && coverCacheMatches &&
|
||||||
|
ctx.restoreCoverBuffer != nullptr && ctx.restoreCoverBuffer(ctx.coverBufferUserData);
|
||||||
|
|
||||||
|
if (hasCoverArea) {
|
||||||
|
GUI.drawRecentBookCover(
|
||||||
|
ctx.renderer, slot, ctx.recentBooks, ctx.coverSelectorIndex, ctx.coverRendered, ctx.coverBufferStored,
|
||||||
|
bufferRestored,
|
||||||
|
[store = ctx.storeCoverBuffer, userData = ctx.coverBufferUserData]() {
|
||||||
|
return store != nullptr && store(userData);
|
||||||
|
},
|
||||||
|
coverStripSelected);
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
|
||||||
|
const int bookIndex = std::max(0, widget.featured.startIndex);
|
||||||
|
if (bookIndex < static_cast<int>(ctx.recentBooks.size())) {
|
||||||
|
const bool selected = ctx.selectorIndex >= entry.actionOffset && ctx.selectorIndex < entry.actionOffset + 1 &&
|
||||||
|
ctx.actions[ctx.selectorIndex].value == bookIndex;
|
||||||
|
ctx.renderer.drawText(UI_10_FONT_ID, slot.x, slot.y, tr(STR_CONTINUE_READING), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
#if FREEINK_HAVE_GFX_RENDERER
|
||||||
|
const int labelH = ctx.renderer.getLineHeight(UI_10_FONT_ID) + std::max(0, widget.featured.titleGap);
|
||||||
|
const int coverHeight =
|
||||||
|
widget.featured.coverHeight > 0 ? widget.featured.coverHeight : std::max(1, slot.height - labelH - 8);
|
||||||
|
const int coverWidth =
|
||||||
|
widget.featured.coverWidth > 0 ? widget.featured.coverWidth : std::max(1, coverHeight * 62 / 100);
|
||||||
|
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
|
||||||
|
RecentBookCoverPainterData painterData{&ctx.renderer, &ctx.recentBooks,
|
||||||
|
UITheme::getInstance().getHomeCoverThumbHeight(),
|
||||||
|
widget.featured.placeholderIconSize};
|
||||||
|
freeink::ui::BookCardProps props;
|
||||||
|
props.title = ctx.recentBooks[bookIndex].title.c_str();
|
||||||
|
props.author = ctx.recentBooks[bookIndex].author.c_str();
|
||||||
|
props.progressMax = 0;
|
||||||
|
props.value = static_cast<int16_t>(bookIndex);
|
||||||
|
props.state = selected ? freeink::ui::StateSelected : freeink::ui::StateNormal;
|
||||||
|
props.coverSize = freeink::ui::makeSize(coverWidth, coverHeight);
|
||||||
|
props.padding = freeink::ui::makeInsets(0);
|
||||||
|
props.gap = freeink::ui::clampI16(widget.featured.coverGap);
|
||||||
|
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_TITLE;
|
||||||
|
props.titleText.maxLines = 2;
|
||||||
|
props.authorText.font = freeink::ui::GfxRendererTarget::FONT_BODY;
|
||||||
|
props.centerTextVertically = true;
|
||||||
|
props.selectionIndicator = freeink::ui::BookCardSelectionIndicator::CoverFrame;
|
||||||
|
props.selectedCoverFrameRadius = freeink::ui::clampRadius(widget.featured.selectedRadius);
|
||||||
|
props.coverPainter = paintBookCardCover;
|
||||||
|
props.coverPainterUserData = &painterData;
|
||||||
|
freeink::ui::StyleSet styles =
|
||||||
|
widgetSelectionStyles(ThemeWidgetSelectionStyle::Outline, widget.featured.selectedRadius);
|
||||||
|
styles.normal.background = freeink::ui::Paint::solid(freeink::ui::Color::White);
|
||||||
|
props.styles = styles;
|
||||||
|
const int cardH = std::min(std::max(1, slot.height - labelH), coverHeight);
|
||||||
|
freeink::ui::bookCard(ui.frame, freeink::ui::makeRect(slot.x, slot.y + labelH, slot.width, cardH), props);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
|
||||||
|
const int columns = std::max(1, widget.coverGrid.columns);
|
||||||
|
const int rows = widget.coverGrid.rows > 0
|
||||||
|
? widget.coverGrid.rows
|
||||||
|
: std::max(1, (static_cast<int>(ctx.recentBooks.size()) + columns - 1) / columns);
|
||||||
|
const int startIndex = std::max(0, widget.coverGrid.startIndex);
|
||||||
|
const int maxItems = std::min({std::max(0, static_cast<int>(ctx.recentBooks.size()) - startIndex), rows * columns,
|
||||||
|
static_cast<int>(kMaxThemeCoverGridItems)});
|
||||||
|
if (maxItems > 0) {
|
||||||
|
const int selectedLocal =
|
||||||
|
ctx.selectorIndex >= entry.actionOffset && ctx.selectorIndex < entry.actionOffset + maxItems
|
||||||
|
? ctx.actions[ctx.selectorIndex].value - startIndex
|
||||||
|
: -1;
|
||||||
|
const int coverHeight =
|
||||||
|
widget.coverGrid.coverHeight > 0
|
||||||
|
? widget.coverGrid.coverHeight
|
||||||
|
: std::max(1, (slot.height - std::max(0, widget.coverGrid.gap) * (rows - 1)) / rows -
|
||||||
|
widget.coverGrid.labelHeight);
|
||||||
|
const int coverWidth =
|
||||||
|
widget.coverGrid.coverWidth > 0 ? widget.coverGrid.coverWidth : std::max(1, coverHeight * 62 / 100);
|
||||||
|
const int rowHeight = widget.coverGrid.rowHeight > 0
|
||||||
|
? widget.coverGrid.rowHeight
|
||||||
|
: coverHeight + std::max(0, widget.coverGrid.labelHeight) + 6;
|
||||||
|
|
||||||
|
#if FREEINK_HAVE_GFX_RENDERER
|
||||||
|
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
|
||||||
|
RecentBookCoverPainterData painterData{&ctx.renderer, &ctx.recentBooks,
|
||||||
|
UITheme::getInstance().getHomeCoverThumbHeight(),
|
||||||
|
widget.coverGrid.placeholderIconSize};
|
||||||
|
HomeCoverGridItemProviderData itemProviderData{&ctx.recentBooks, startIndex};
|
||||||
|
freeink::ui::CoverGridProps props;
|
||||||
|
props.itemProvider = provideHomeCoverGridItem;
|
||||||
|
props.itemProviderUserData = &itemProviderData;
|
||||||
|
props.count = static_cast<uint16_t>(maxItems);
|
||||||
|
props.selectedIndex = static_cast<int16_t>(selectedLocal);
|
||||||
|
props.columns = static_cast<uint8_t>(std::min(columns, 12));
|
||||||
|
props.gap = freeink::ui::clampI16(widget.coverGrid.gap);
|
||||||
|
props.rowGap =
|
||||||
|
freeink::ui::clampI16(widget.coverGrid.rowGap >= 0 ? widget.coverGrid.rowGap : widget.coverGrid.gap);
|
||||||
|
props.cellInset = toFreeInkInsets(widget.coverGrid.cellInset);
|
||||||
|
props.labelInset = toFreeInkInsets(widget.coverGrid.labelInset);
|
||||||
|
props.coverSize = freeink::ui::makeSize(coverWidth, coverHeight);
|
||||||
|
props.rowHeight = freeink::ui::clampI16(rowHeight, 1);
|
||||||
|
props.labelHeight = freeink::ui::clampI16(widget.coverGrid.labelHeight);
|
||||||
|
props.labelGap = freeink::ui::clampI16(widget.coverGrid.labelGap);
|
||||||
|
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_SMALL;
|
||||||
|
props.titleText.maxLines = static_cast<uint8_t>(std::max(1, std::min(3, widget.coverGrid.labelLines)));
|
||||||
|
props.cellStyles = widgetSelectionStyles(widget.coverGrid.selectionStyle, widget.coverGrid.selectedRadius);
|
||||||
|
props.selectionIndicator = coverGridSelectionIndicator(widget.coverGrid.selectionStyle);
|
||||||
|
props.selectedCoverFrameRadius = freeink::ui::clampRadius(widget.coverGrid.selectedRadius);
|
||||||
|
props.coverPainter = paintRecentCoverGridCover;
|
||||||
|
props.coverPainterUserData = &painterData;
|
||||||
|
freeink::ui::coverGrid(ui.frame, freeink::ui::makeRect(slot.x, slot.y, slot.width, slot.height), props);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
|
||||||
|
std::array<const ThemeHomeLauncherSpec*, kMaxThemeLauncherItems> launchers;
|
||||||
|
size_t launcherCount = 0;
|
||||||
|
for (const auto& launcher : widget.launcher.items) {
|
||||||
|
if (themeHomeActionVisible(launcher.action, ctx.hasOpdsServers, !ctx.recentBooks.empty())) {
|
||||||
|
if (launcherCount < launchers.size()) launchers[launcherCount++] = &launcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int selectedLocal = ctx.selectorIndex >= entry.actionOffset &&
|
||||||
|
ctx.selectorIndex < entry.actionOffset + static_cast<int>(launcherCount)
|
||||||
|
? ctx.selectorIndex - entry.actionOffset
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (widget.launcher.presentation == ThemeLauncherPresentation::IconTabs) {
|
||||||
|
#if FREEINK_HAVE_GFX_RENDERER
|
||||||
|
std::array<freeink::ui::TabItem, kMaxThemeLauncherItems> items;
|
||||||
|
size_t itemCount = 0;
|
||||||
|
for (int i = 0; i < static_cast<int>(launcherCount); ++i) {
|
||||||
|
items[itemCount++] = freeink::ui::tabItem(i, selectedLocal == i);
|
||||||
|
}
|
||||||
|
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
|
||||||
|
freeink::ui::StyleSet styles = freeink::ui::outlinedButtonStyles(widget.launcher.selectedRadius);
|
||||||
|
HomeIconTabPainterData painterData{&ctx.renderer, launchers.data(), launcherCount};
|
||||||
|
freeink::ui::TabBarProps props;
|
||||||
|
props.tabs = items.data();
|
||||||
|
props.count = static_cast<uint8_t>(std::min<size_t>(itemCount, 255));
|
||||||
|
props.tabStyles = styles;
|
||||||
|
props.gap = freeink::ui::clampI16(widget.launcher.gap);
|
||||||
|
props.iconSize = freeink::ui::clampI16(widget.launcher.iconSize, 1);
|
||||||
|
props.tabInset = freeink::ui::makeInsets(4);
|
||||||
|
props.iconPainter = paintHomeIconTab;
|
||||||
|
props.iconPainterUserData = &painterData;
|
||||||
|
freeink::ui::tabBar(ui.frame, freeink::ui::makeRect(slot.x, slot.y, slot.width, slot.height), props);
|
||||||
|
#endif
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::LauncherGrid) {
|
||||||
|
const int columns = std::max(1, widget.launcher.columns);
|
||||||
|
const int rows = widget.launcher.rows > 0
|
||||||
|
? widget.launcher.rows
|
||||||
|
: std::max(1, (static_cast<int>(launcherCount) + columns - 1) / columns);
|
||||||
|
const int gap = std::max(0, widget.launcher.gap);
|
||||||
|
const int cellW = std::max(1, (slot.width - gap * (columns - 1)) / columns);
|
||||||
|
const int cellH = std::max(1, (slot.height - gap * (rows - 1)) / rows);
|
||||||
|
for (int i = 0; i < static_cast<int>(launcherCount); ++i) {
|
||||||
|
const int col = i % columns;
|
||||||
|
const int row = i / columns;
|
||||||
|
if (row >= rows) break;
|
||||||
|
Rect cell{slot.x + col * (cellW + gap), slot.y + row * (cellH + gap),
|
||||||
|
col == columns - 1 ? slot.x + slot.width - (slot.x + col * (cellW + gap)) : cellW, cellH};
|
||||||
|
GUI.drawButtonMenu(
|
||||||
|
ctx.renderer, cell, 1, selectedLocal == i ? 0 : -1,
|
||||||
|
[&launchers, i](int) {
|
||||||
|
return launchers[i]->text.empty() ? std::string(defaultLauncherLabel(launchers[i]->action))
|
||||||
|
: launchers[i]->text;
|
||||||
|
},
|
||||||
|
[&launchers, i](int) {
|
||||||
|
return launchers[i]->icon == UIIcon::None ? defaultLauncherIcon(launchers[i]->action)
|
||||||
|
: launchers[i]->icon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GUI.drawButtonMenu(
|
||||||
|
ctx.renderer, slot, static_cast<int>(launcherCount), selectedLocal,
|
||||||
|
[&launchers](int index) {
|
||||||
|
return launchers[index]->text.empty() ? std::string(defaultLauncherLabel(launchers[index]->action))
|
||||||
|
: launchers[index]->text;
|
||||||
|
},
|
||||||
|
[&launchers](int index) {
|
||||||
|
return launchers[index]->icon == UIIcon::None ? defaultLauncherIcon(launchers[index]->action)
|
||||||
|
: launchers[index]->icon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (widget.type == ThemeHomeWidgetType::ButtonHints) {
|
||||||
|
const bool horizontalBottomHints = ctx.spec.navigation == ThemeHomeNavigationMode::SplitAxis ||
|
||||||
|
ctx.spec.navigation == ThemeHomeNavigationMode::CarouselAxis;
|
||||||
|
const auto labels = ctx.mappedInput.mapLabels(
|
||||||
|
buttonHintLabel(widget.buttonHints.back, ""), buttonHintLabel(widget.buttonHints.confirm, tr(STR_SELECT)),
|
||||||
|
buttonHintLabel(widget.buttonHints.previous, horizontalBottomHints ? tr(STR_DIR_LEFT) : tr(STR_DIR_UP)),
|
||||||
|
buttonHintLabel(widget.buttonHints.next, horizontalBottomHints ? tr(STR_DIR_RIGHT) : tr(STR_DIR_DOWN)));
|
||||||
|
GUI.drawButtonHints(ctx.renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.renderer.displayBuffer();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "components/themes/ThemeLayout.h"
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
class MappedInputManager;
|
||||||
|
struct RecentBook;
|
||||||
|
|
||||||
|
struct ThemeHomeActionEntry {
|
||||||
|
ThemeHomeAction action = ThemeHomeAction::FileBrowser;
|
||||||
|
int value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
using ThemeHomeBufferCallback = bool (*)(void*);
|
||||||
|
|
||||||
|
void buildThemeHomeActions(const ThemeHomeScreenSpec* spec, const std::vector<RecentBook>& recentBooks,
|
||||||
|
bool hasOpdsServers, std::vector<ThemeHomeActionEntry>& actions);
|
||||||
|
|
||||||
|
struct ThemeHomeRenderContext {
|
||||||
|
GfxRenderer& renderer;
|
||||||
|
MappedInputManager& mappedInput;
|
||||||
|
const ThemeMetrics& metrics;
|
||||||
|
const ThemeHomeScreenSpec& spec;
|
||||||
|
const std::vector<RecentBook>& recentBooks;
|
||||||
|
const std::vector<ThemeHomeActionEntry>& actions;
|
||||||
|
bool hasOpdsServers = false;
|
||||||
|
int selectorIndex = 0;
|
||||||
|
int& coverSelectorIndex;
|
||||||
|
bool& coverRendered;
|
||||||
|
bool& coverBufferStored;
|
||||||
|
int coverBufferSelectorIndex = -1;
|
||||||
|
bool coverBufferStripSelected = false;
|
||||||
|
int& coverRectX;
|
||||||
|
int& coverRectY;
|
||||||
|
int& coverRectW;
|
||||||
|
int& coverRectH;
|
||||||
|
void* coverBufferUserData = nullptr;
|
||||||
|
ThemeHomeBufferCallback storeCoverBuffer = nullptr;
|
||||||
|
ThemeHomeBufferCallback restoreCoverBuffer = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool renderThemeHome(ThemeHomeRenderContext& ctx);
|
||||||
@@ -41,6 +41,8 @@ namespace {
|
|||||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
// pages per minute, first item is 1 to prevent division by zero if accessed
|
// 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 int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
|
||||||
|
constexpr size_t initialBookmarkCacheCapacity = 16;
|
||||||
|
constexpr float bookmarkProgressEpsilon = 0.0001f;
|
||||||
|
|
||||||
int clampPercent(int percent) {
|
int clampPercent(int percent) {
|
||||||
if (percent < 0) {
|
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] == '/';
|
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.
|
// Pick a non-colliding destination path inside /Read/ for a finished book.
|
||||||
// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc.
|
// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc.
|
||||||
std::string buildReadFolderDestination(const std::string& srcPath) {
|
std::string buildReadFolderDestination(const std::string& srcPath) {
|
||||||
@@ -165,6 +197,8 @@ void EpubReaderActivity::onEnter() {
|
|||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
|
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
|
||||||
|
|
||||||
|
loadCachedBookmarks();
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -177,6 +211,14 @@ void EpubReaderActivity::onExit() {
|
|||||||
|
|
||||||
APP_STATE.readerActivityLoadCount = 0;
|
APP_STATE.readerActivityLoadCount = 0;
|
||||||
APP_STATE.saveToFile();
|
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();
|
section.reset();
|
||||||
if (pendingReadFolderMove && epub) {
|
if (pendingReadFolderMove && epub) {
|
||||||
const std::string srcPath = epub->getPath();
|
const std::string srcPath = epub->getPath();
|
||||||
@@ -256,7 +298,9 @@ void EpubReaderActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter reader menu activity.
|
// 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 (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
if (ignoreNextConfirmRelease) {
|
if (ignoreNextConfirmRelease) {
|
||||||
ignoreNextConfirmRelease = false;
|
ignoreNextConfirmRelease = false;
|
||||||
@@ -271,7 +315,7 @@ void EpubReaderActivity::loop() {
|
|||||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
SETTINGS.orientation, !currentPageFootnotes.empty()),
|
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
||||||
[this](const ActivityResult& result) {
|
[this](const ActivityResult& result) {
|
||||||
// Always apply orientation change even if the menu was cancelled
|
// Always apply orientation change even if the menu was cancelled
|
||||||
const auto& menu = std::get<MenuResult>(result.data);
|
const auto& menu = std::get<MenuResult>(result.data);
|
||||||
@@ -284,14 +328,32 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
// Long-press Confirm runs the user-selected function (SETTINGS.longPressMenuFunction).
|
||||||
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||||
if (!showBookmarkMessage) {
|
switch (SETTINGS.longPressMenuFunction) {
|
||||||
addBookmark();
|
case CrossPointSettings::LP_MENU_BOOKMARK:
|
||||||
showBookmarkMessage = true;
|
// Hold ~0.4s drops a bookmark at the current page.
|
||||||
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
|
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showBookmarkMessage) {
|
||||||
bookmarkMessageTime = millis();
|
addBookmark();
|
||||||
requestUpdate();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +534,7 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
|||||||
|
|
||||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||||
auto progressChangeResultHandler = [this](const ActivityResult& result) {
|
auto progressChangeResultHandler = [this](const ActivityResult& result) {
|
||||||
|
loadCachedBookmarks();
|
||||||
if (!result.isCancelled) {
|
if (!result.isCancelled) {
|
||||||
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||||
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||||
@@ -578,50 +641,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
||||||
if (KOREADER_STORE.hasCredentials()) {
|
launchKOReaderSync();
|
||||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
|
||||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
|
||||||
std::optional<uint16_t> paragraphIndex;
|
|
||||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
|
||||||
const uint16_t paragraphPage =
|
|
||||||
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
|
|
||||||
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
|
|
||||||
paragraphIndex = *pIdx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-compute local KO position and chapter name while Epub is still in RAM.
|
|
||||||
CrossPointPosition localPos = getCurrentPosition();
|
|
||||||
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
|
|
||||||
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
|
||||||
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
|
|
||||||
const std::string savedEpubPath = epub->getPath();
|
|
||||||
|
|
||||||
// Persist current position so the reader resumes at the right page on return.
|
|
||||||
// goToReader() depends on this file, so abort the sync if the write fails.
|
|
||||||
if (!saveProgress(currentSpineIndex, currentPage, totalPages)) {
|
|
||||||
LOG_ERR("KOSync", "Aborting sync because current progress could not be saved");
|
|
||||||
pendingSyncSaveError = true;
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release Epub and Section to free ~65KB RAM for the TLS handshake.
|
|
||||||
LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap());
|
|
||||||
{
|
|
||||||
RenderLock lock(*this);
|
|
||||||
if (section) {
|
|
||||||
nextPageNumber = section->currentPage;
|
|
||||||
}
|
|
||||||
section.reset();
|
|
||||||
epub.reset();
|
|
||||||
}
|
|
||||||
LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap());
|
|
||||||
|
|
||||||
activityManager.replaceActivity(std::make_unique<KOReaderSyncActivity>(
|
|
||||||
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
|
|
||||||
std::move(localChapterName), paragraphIndex));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
|
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
|
||||||
@@ -630,9 +650,61 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
progressChangeResultHandler);
|
progressChangeResultHandler);
|
||||||
break;
|
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;
|
||||||
|
std::optional<uint16_t> paragraphIndex;
|
||||||
|
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||||
|
const uint16_t paragraphPage =
|
||||||
|
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
|
||||||
|
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
|
||||||
|
paragraphIndex = *pIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute local KO position and chapter name while Epub is still in RAM.
|
||||||
|
CrossPointPosition localPos = getCurrentPosition();
|
||||||
|
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
|
||||||
|
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
||||||
|
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
|
||||||
|
const std::string savedEpubPath = epub->getPath();
|
||||||
|
|
||||||
|
// Persist current position so the reader resumes at the right page on return.
|
||||||
|
// goToReader() depends on this file, so abort the sync if the write fails.
|
||||||
|
if (!saveProgress(currentSpineIndex, currentPage, totalPages)) {
|
||||||
|
LOG_ERR("KOSync", "Aborting sync because current progress could not be saved");
|
||||||
|
pendingSyncSaveError = true;
|
||||||
|
requestUpdate();
|
||||||
|
return true; // acted: surfaced a save error to the user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release Epub and Section to free ~65KB RAM for the TLS handshake.
|
||||||
|
LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap());
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
if (section) {
|
||||||
|
nextPageNumber = section->currentPage;
|
||||||
|
}
|
||||||
|
section.reset();
|
||||||
|
epub.reset();
|
||||||
|
}
|
||||||
|
LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap());
|
||||||
|
|
||||||
|
activityManager.replaceActivity(std::make_unique<KOReaderSyncActivity>(
|
||||||
|
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
|
||||||
|
std::move(localChapterName), paragraphIndex));
|
||||||
|
return true; // acted: launched the sync activity
|
||||||
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
||||||
// No-op if the selected orientation matches current settings.
|
// No-op if the selected orientation matches current settings.
|
||||||
if (SETTINGS.orientation == orientation) {
|
if (SETTINGS.orientation == orientation) {
|
||||||
@@ -869,6 +941,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBookmarkFlag();
|
||||||
|
|
||||||
{
|
{
|
||||||
auto p = section->loadPageFromSectionFile();
|
auto p = section->loadPageFromSectionFile();
|
||||||
if (!p) {
|
if (!p) {
|
||||||
@@ -900,7 +974,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showBookmarkMessage) {
|
if (showBookmarkMessage) {
|
||||||
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
|
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,22 +1017,31 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
const auto t0 = millis();
|
const auto t0 = millis();
|
||||||
|
const int fontId = SETTINGS.getReaderFontId();
|
||||||
|
|
||||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||||
auto* fcm = renderer.getFontCacheManager();
|
auto* fcm = renderer.getFontCacheManager();
|
||||||
auto scope = fcm->createPrewarmScope();
|
auto scope = fcm->createPrewarmScope();
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
|
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); // scan pass
|
||||||
scope.endScanAndPrewarm();
|
scope.endScanAndPrewarm();
|
||||||
const auto tPrewarm = millis();
|
const auto tPrewarm = millis();
|
||||||
|
|
||||||
// Force special handling for pages with images when anti-aliasing is on
|
const bool pageHasImages = page->hasImages();
|
||||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
|
||||||
|
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);
|
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
const auto tBwRender = millis();
|
const auto tBwRender = millis();
|
||||||
|
|
||||||
if (imagePageWithAA) {
|
if (pageHasImages) {
|
||||||
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
||||||
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
||||||
// Instead, blank only the image area and do two fast refreshes.
|
// Instead, blank only the image area and do two fast refreshes.
|
||||||
@@ -971,7 +1054,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
|
|
||||||
// Re-render page content to restore images into the blanked area
|
// 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 %)
|
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
@@ -995,7 +1078,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
|
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
|
||||||
// cost stays close to one render. Both text (drawPixel) and images
|
// cost stays close to one render. Both text (drawPixel) and images
|
||||||
// (DirectPixelWriter) honor the active strip target.
|
// (DirectPixelWriter) honor the active strip target.
|
||||||
if (SETTINGS.textAntiAliasing && renderer.supportsStripGrayscale()) {
|
if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
|
||||||
constexpr int STRIP_ROWS = 80;
|
constexpr int STRIP_ROWS = 80;
|
||||||
const int gh = renderer.getDisplayHeight();
|
const int gh = renderer.getDisplayHeight();
|
||||||
const int gwBytes = renderer.getDisplayWidthBytes();
|
const int gwBytes = renderer.getDisplayWidthBytes();
|
||||||
@@ -1004,6 +1087,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
if (!scratch) {
|
if (!scratch) {
|
||||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||||
} else {
|
} else {
|
||||||
|
// [#2190] Headroom probe: tiled scratch is ~8 KB here; the full-frame
|
||||||
|
// alternative would need ~52 KB total (chunked at 8 KB). Compare free vs
|
||||||
|
// ~52 KB and largest_block vs 8 KB to see if X3 could afford full-frame.
|
||||||
|
LOG_INF("ERS", "Grayscale heap @render: free=%u largest_block=%u scratch=%d", (unsigned)ESP.getFreeHeap(),
|
||||||
|
(unsigned)ESP.getMaxAllocHeap(), gwBytes * STRIP_ROWS);
|
||||||
// Bands may be streamed in any order: X4 windows each via setRamArea, X3
|
// Bands may be streamed in any order: X4 windows each via setRamArea, X3
|
||||||
// via PTL.
|
// via PTL.
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||||
@@ -1011,7 +1099,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
renderGrayscalePass();
|
||||||
renderer.endStripTarget();
|
renderer.endStripTarget();
|
||||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1111,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
renderGrayscalePass();
|
||||||
renderer.endStripTarget();
|
renderer.endStripTarget();
|
||||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||||
}
|
}
|
||||||
@@ -1048,22 +1136,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
} else {
|
} else {
|
||||||
// Fallback path for a controller without strip support. grayscale rendering
|
// Fallback path for a controller without strip support. grayscale rendering
|
||||||
// TODO: Only do this if font supports it
|
// TODO: Only do this if font supports it
|
||||||
if (SETTINGS.textAntiAliasing) {
|
if (needsAnyGrayscale) {
|
||||||
// Save the BW frame before the grayscale passes overwrite it, restore
|
// Save the BW frame before the grayscale passes overwrite it, restore
|
||||||
// after. Only needed when grayscale actually renders.
|
// 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();
|
const auto tBwStore = millis();
|
||||||
|
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
renderGrayscalePass();
|
||||||
renderer.copyGrayscaleLsbBuffers();
|
renderer.copyGrayscaleLsbBuffers();
|
||||||
const auto tGrayLsb = millis();
|
const auto tGrayLsb = millis();
|
||||||
|
|
||||||
// Render and copy to MSB buffer
|
// Render and copy to MSB buffer
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
renderGrayscalePass();
|
||||||
renderer.copyGrayscaleMsbBuffers();
|
renderer.copyGrayscaleMsbBuffers();
|
||||||
const auto tGrayMsb = millis();
|
const auto tGrayMsb = millis();
|
||||||
|
|
||||||
@@ -1081,8 +1175,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
|
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
|
||||||
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
|
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
|
||||||
} else {
|
} else {
|
||||||
// No anti-aliasing: BW frame already displayed above, no grayscale to
|
// No text AA and no images: BW frame already displayed above, no grayscale
|
||||||
// render, so no save/restore.
|
// to render, so no save/restore.
|
||||||
const auto tEnd = millis();
|
const auto tEnd = millis();
|
||||||
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
|
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
|
||||||
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
|
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
|
||||||
@@ -1124,7 +1218,7 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
title = epub->getTitle();
|
title = epub->getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset);
|
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||||
@@ -1186,11 +1280,31 @@ void EpubReaderActivity::restoreSavedPosition() {
|
|||||||
requestUpdate();
|
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() {
|
void EpubReaderActivity::addBookmark() {
|
||||||
if (!section || !epub) {
|
if (!section || !epub) {
|
||||||
return;
|
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 currentPage;
|
||||||
int pageCount;
|
int pageCount;
|
||||||
{
|
{
|
||||||
@@ -1199,45 +1313,58 @@ void EpubReaderActivity::addBookmark() {
|
|||||||
currentPage = section->currentPage;
|
currentPage = section->currentPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string pageText;
|
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||||
if (currentPage >= 0 && currentPage < pageCount) {
|
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
|
||||||
pageText = section->getTextFromSectionFile();
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
|
||||||
|
|
||||||
BookmarkEntry entry;
|
|
||||||
entry.percentage = progress.percentage;
|
|
||||||
entry.xpath = progress.xpath;
|
|
||||||
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
|
|
||||||
|
|
||||||
// Add bookmark
|
|
||||||
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
|
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
|
||||||
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
|
|
||||||
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
|
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
|
||||||
Storage.mkdir(bookmarksDir.c_str());
|
Storage.mkdir(bookmarksDir.c_str());
|
||||||
std::vector<BookmarkEntry> bookmarks;
|
const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str());
|
||||||
if (Storage.exists(path.c_str())) {
|
if (!ok) {
|
||||||
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
|
LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str());
|
||||||
String json = Storage.readFile(path.c_str());
|
|
||||||
if (!json.isEmpty()) {
|
|
||||||
JsonSettingsIO::loadBookmarks(bookmarks, json.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();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EpubReaderActivity::updateBookmarkFlag() {
|
||||||
|
if (!section || !epub || cachedBookmarks.empty()) {
|
||||||
|
currentPageBookmarked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ProgressRange pageRange =
|
||||||
|
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
|
||||||
|
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
|
||||||
|
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||||
ScreenshotInfo info;
|
ScreenshotInfo info;
|
||||||
info.readerType = ScreenshotInfo::ReaderType::Epub;
|
info.readerType = ScreenshotInfo::ReaderType::Epub;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
|
#include "BookmarkEntry.h"
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
#include "ProgressMapper.h"
|
#include "ProgressMapper.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
@@ -34,6 +35,9 @@ class EpubReaderActivity final : public Activity {
|
|||||||
bool automaticPageTurnActive = false;
|
bool automaticPageTurnActive = false;
|
||||||
bool showBookmarkMessage = false;
|
bool showBookmarkMessage = false;
|
||||||
bool ignoreNextConfirmRelease = 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
|
// Tracks whether this book is currently removed from Recent Books by the
|
||||||
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
|
||||||
bool recentsEntryRemoved = false;
|
bool recentsEntryRemoved = false;
|
||||||
@@ -60,10 +64,15 @@ class EpubReaderActivity final : public Activity {
|
|||||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||||
void jumpToPercent(int percent);
|
void jumpToPercent(int percent);
|
||||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||||
|
// 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 applyOrientation(uint8_t orientation);
|
||||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||||
void pageTurn(bool isForwardTurn);
|
void pageTurn(bool isForwardTurn);
|
||||||
|
void loadCachedBookmarks();
|
||||||
void addBookmark();
|
void addBookmark();
|
||||||
|
void updateBookmarkFlag();
|
||||||
|
|
||||||
// Footnote navigation
|
// Footnote navigation
|
||||||
void navigateToHref(const std::string& href, bool savePosition = false);
|
void navigateToHref(const std::string& href, bool savePosition = false);
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ void EpubReaderBookmarksActivity::onEnter() {
|
|||||||
bookmarks.shrink_to_fit();
|
bookmarks.shrink_to_fit();
|
||||||
} else {
|
} else {
|
||||||
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
|
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 {
|
} else {
|
||||||
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
|
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
|
||||||
@@ -93,6 +85,14 @@ void EpubReaderBookmarksActivity::loop() {
|
|||||||
selectorIndex--;
|
selectorIndex--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bookmarks.empty()) {
|
||||||
|
ActivityResult result;
|
||||||
|
result.isCancelled = true;
|
||||||
|
setResult(std::move(result));
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
confirmingDelete = DELETE_MODE_OFF;
|
confirmingDelete = DELETE_MODE_OFF;
|
||||||
return;
|
return;
|
||||||
@@ -186,9 +186,12 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
|||||||
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
||||||
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
||||||
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
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::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
||||||
std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
if (bookmark.computedChapterPageCount > 0) {
|
||||||
std::to_string(bookmark.computedChapterPageCount) + " - " + tocTitle;
|
subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
||||||
|
std::to_string(bookmark.computedChapterPageCount) + " - ";
|
||||||
|
}
|
||||||
|
return subtitle + tocTitle;
|
||||||
};
|
};
|
||||||
const auto getBookmarkIcon = [isPortrait](int index) {
|
const auto getBookmarkIcon = [isPortrait](int index) {
|
||||||
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
|
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
|
||||||
@@ -208,16 +211,13 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
|||||||
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
|
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
|
||||||
|
|
||||||
GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT},
|
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 backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||||
const auto confirmLabel =
|
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));
|
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);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,27 @@
|
|||||||
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::string& title, const int currentPage, const int totalPages,
|
const std::string& title, const int currentPage, const int totalPages,
|
||||||
const int bookProgressPercent, const uint8_t currentOrientation,
|
const int bookProgressPercent, const uint8_t currentOrientation,
|
||||||
const bool hasFootnotes)
|
const bool hasFootnotes, const bool hasBookmarks)
|
||||||
: Activity("EpubReaderMenu", renderer, mappedInput),
|
: Activity("EpubReaderMenu", renderer, mappedInput),
|
||||||
menuItems(buildMenuItems(hasFootnotes)),
|
menuItems(buildMenuItems(hasFootnotes, hasBookmarks)),
|
||||||
title(title),
|
title(title),
|
||||||
pendingOrientation(currentOrientation),
|
pendingOrientation(currentOrientation),
|
||||||
currentPage(currentPage),
|
currentPage(currentPage),
|
||||||
totalPages(totalPages),
|
totalPages(totalPages),
|
||||||
bookProgressPercent(bookProgressPercent) {}
|
bookProgressPercent(bookProgressPercent) {}
|
||||||
|
|
||||||
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
|
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
|
||||||
|
bool hasBookmarks) {
|
||||||
std::vector<MenuItem> items;
|
std::vector<MenuItem> items;
|
||||||
items.reserve(11);
|
items.reserve(12);
|
||||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||||
if (hasFootnotes) {
|
if (hasFootnotes) {
|
||||||
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
||||||
}
|
}
|
||||||
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
|
if (hasBookmarks) {
|
||||||
|
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
|
||||||
|
}
|
||||||
|
items.push_back({MenuAction::TOGGLE_BOOKMARK, StrId::STR_TOGGLE_BOOKMARK});
|
||||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||||
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
|
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
|
||||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
AUTO_PAGE_TURN,
|
AUTO_PAGE_TURN,
|
||||||
ROTATE_SCREEN,
|
ROTATE_SCREEN,
|
||||||
BOOKMARKS,
|
BOOKMARKS,
|
||||||
|
TOGGLE_BOOKMARK,
|
||||||
SCREENSHOT,
|
SCREENSHOT,
|
||||||
DISPLAY_QR,
|
DISPLAY_QR,
|
||||||
GO_HOME,
|
GO_HOME,
|
||||||
@@ -27,7 +28,7 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
|
|
||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||||
const uint8_t currentOrientation, const bool hasFootnotes);
|
const uint8_t currentOrientation, const bool hasFootnotes, bool hasBookmarks);
|
||||||
|
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -40,7 +41,7 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
StrId labelId;
|
StrId labelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes);
|
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes, bool hasBookmarks);
|
||||||
|
|
||||||
// Fixed menu layout
|
// Fixed menu layout
|
||||||
const std::vector<MenuItem> menuItems;
|
const std::vector<MenuItem> menuItems;
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include "ProgressFile.h"
|
||||||
|
|
||||||
namespace EpubReaderUtils {
|
namespace EpubReaderUtils {
|
||||||
|
|
||||||
// Persists reader progress for an EPUB to its cache directory. Returns true on success.
|
// Persists reader progress for an EPUB to its cache directory. Returns true on success.
|
||||||
inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCount) {
|
inline bool saveProgress(const Epub& epub, int spineIndex, int pageNumber, int pageCount) {
|
||||||
if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 ||
|
if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 ||
|
||||||
pageCount > 0xFFFF) {
|
pageCount > 0xFFFF) {
|
||||||
LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount);
|
LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
HalFile f;
|
|
||||||
if (!Storage.openFileForWrite("ERS", epub.getCachePath() + "/progress.bin", f)) {
|
|
||||||
LOG_ERR("ERS", "Could not open progress file for write!");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
uint8_t data[6];
|
uint8_t data[6];
|
||||||
data[0] = spineIndex & 0xFF;
|
data[0] = spineIndex & 0xFF;
|
||||||
data[1] = (spineIndex >> 8) & 0xFF;
|
data[1] = (spineIndex >> 8) & 0xFF;
|
||||||
@@ -25,9 +21,7 @@ inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCou
|
|||||||
data[3] = (pageNumber >> 8) & 0xFF;
|
data[3] = (pageNumber >> 8) & 0xFF;
|
||||||
data[4] = pageCount & 0xFF;
|
data[4] = pageCount & 0xFF;
|
||||||
data[5] = (pageCount >> 8) & 0xFF;
|
data[5] = (pageCount >> 8) & 0xFF;
|
||||||
const size_t written = f.write(data, sizeof(data));
|
if (!ProgressFile::writeAtomic(epub.getCachePath(), data, sizeof(data))) {
|
||||||
if (written != sizeof(data)) {
|
|
||||||
LOG_ERR("ERS", "Short write saving progress: %u/%u bytes", (unsigned)written, (unsigned)sizeof(data));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber);
|
LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace ProgressFile {
|
||||||
|
|
||||||
|
// Writes `len` bytes of reader progress to `<cachePath>/progress.bin` without
|
||||||
|
// ever leaving the canonical file half-written.
|
||||||
|
//
|
||||||
|
// The bytes go to a temporary `progress.bin.tmp` first; only once that is fully
|
||||||
|
// written and closed is it renamed over progress.bin. An interrupted write
|
||||||
|
// (power loss or a crash mid-SPI) therefore damages only the throwaway temp file.
|
||||||
|
// Previously a truncate-in-place write that was cut short left progress.bin with
|
||||||
|
// a broken FAT cluster chain that the firmware could neither rewrite nor clear,
|
||||||
|
// stranding the book on an old page (issue #2275).
|
||||||
|
//
|
||||||
|
// This is crash-safe, not metadata-atomic: on FAT the replace is remove + rename,
|
||||||
|
// two separate directory operations, so a crash between them can leave neither
|
||||||
|
// file -- which simply reads as "no saved progress" on next launch, never a
|
||||||
|
// corrupt or unclearable file. The point is that progress.bin is never torn.
|
||||||
|
//
|
||||||
|
// Note: this prevents corruption on a healthy card going forward. It cannot
|
||||||
|
// repair an already-corrupted progress.bin -- removing the stale file may itself
|
||||||
|
// fail at the FAT level, in which case recovery still requires fsck on a host.
|
||||||
|
//
|
||||||
|
// Returns true only if the new progress.bin is fully in place.
|
||||||
|
inline bool writeAtomic(const std::string& cachePath, const uint8_t* data, size_t len) {
|
||||||
|
const std::string finalPath = cachePath + "/progress.bin";
|
||||||
|
const std::string tmpPath = cachePath + "/progress.bin.tmp";
|
||||||
|
|
||||||
|
{
|
||||||
|
HalFile f;
|
||||||
|
if (!Storage.openFileForWrite("PRG", tmpPath, f)) {
|
||||||
|
LOG_ERR("PRG", "Could not open temp progress file for write: %s", tmpPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const size_t written = f.write(data, len);
|
||||||
|
if (written != len) {
|
||||||
|
LOG_ERR("PRG", "Short write saving progress to %s: %u/%u bytes", tmpPath.c_str(), (unsigned)written,
|
||||||
|
(unsigned)len);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
f.flush();
|
||||||
|
// f (the temp file) is closed at scope exit (DESTRUCTOR_CLOSES_FILE=1) before
|
||||||
|
// the rename below -- SdFat must not rename a path that still has an open FsFile.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SdFat's rename does not overwrite an existing destination, so drop the old
|
||||||
|
// canonical file first. The brief window where neither file exists reads as
|
||||||
|
// "no saved progress" on next launch -- never a corrupt, unclearable file.
|
||||||
|
Storage.remove(finalPath.c_str());
|
||||||
|
if (!Storage.rename(tmpPath.c_str(), finalPath.c_str())) {
|
||||||
|
LOG_ERR("PRG", "Failed to rename temp progress into place: %s", finalPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ProgressFile
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
#include <Memory.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
@@ -29,7 +30,11 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
|
auto epub = makeUniqueNoThrow<Epub>(path, "/.crosspoint");
|
||||||
|
if (!epub) {
|
||||||
|
LOG_ERR("READER", "Failed to allocate EPUB object");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||||
return epub;
|
return epub;
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,11 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
|
auto xtc = makeUniqueNoThrow<Xtc>(path, "/.crosspoint");
|
||||||
|
if (!xtc) {
|
||||||
|
LOG_ERR("READER", "Failed to allocate XTC object");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
if (xtc->load()) {
|
if (xtc->load()) {
|
||||||
return xtc;
|
return xtc;
|
||||||
}
|
}
|
||||||
@@ -59,7 +68,11 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto txt = std::unique_ptr<Txt>(new Txt(path, "/.crosspoint"));
|
auto txt = makeUniqueNoThrow<Txt>(path, "/.crosspoint");
|
||||||
|
if (!txt) {
|
||||||
|
LOG_ERR("READER", "Failed to allocate TXT object");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
if (txt->load()) {
|
if (txt->load()) {
|
||||||
return txt;
|
return txt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
|
|||||||
const bool usePress = SETTINGS.longPressButtonBehavior == SETTINGS.OFF;
|
const bool usePress = SETTINGS.longPressButtonBehavior == SETTINGS.OFF;
|
||||||
const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward();
|
const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward();
|
||||||
const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack();
|
const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack();
|
||||||
const bool swapFront =
|
const bool swapFront = input.isNavDirectionSwapped();
|
||||||
SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED ||
|
|
||||||
SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW);
|
|
||||||
const auto prevButton = swapFront ? MappedInputManager::Button::Right : MappedInputManager::Button::Left;
|
const auto prevButton = swapFront ? MappedInputManager::Button::Right : MappedInputManager::Button::Left;
|
||||||
const auto nextButton = swapFront ? MappedInputManager::Button::Left : MappedInputManager::Button::Right;
|
const auto nextButton = swapFront ? MappedInputManager::Button::Left : MappedInputManager::Button::Right;
|
||||||
const bool prev =
|
const bool prev =
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "ProgressFile.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
@@ -421,14 +422,13 @@ void TxtReaderActivity::renderStatusBar() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TxtReaderActivity::saveProgress() const {
|
void TxtReaderActivity::saveProgress() const {
|
||||||
HalFile f;
|
uint8_t data[4];
|
||||||
if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
data[0] = currentPage & 0xFF;
|
||||||
uint8_t data[4];
|
data[1] = (currentPage >> 8) & 0xFF;
|
||||||
data[0] = currentPage & 0xFF;
|
data[2] = 0;
|
||||||
data[1] = (currentPage >> 8) & 0xFF;
|
data[3] = 0;
|
||||||
data[2] = 0;
|
if (!ProgressFile::writeAtomic(txt->getCachePath(), data, sizeof(data))) {
|
||||||
data[3] = 0;
|
LOG_ERR("TRS", "Failed to save progress: page %d", currentPage);
|
||||||
f.write(data, 4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "ProgressFile.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "XtcReaderChapterSelectionActivity.h"
|
#include "XtcReaderChapterSelectionActivity.h"
|
||||||
@@ -293,7 +294,19 @@ void XtcReaderActivity::renderPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
|
// Periodic ghost cleanup: scrub via the normal path, then run the
|
||||||
|
// settle flavor of the grayscale base pass (DTM planes are equal after
|
||||||
|
// the display sync, so only the gentle reinforcement cells fire).
|
||||||
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
renderer.preconditionGrayscale();
|
||||||
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
|
} else {
|
||||||
|
// OEM grayscale pipeline base: differential "AA-pre-BW(mid)" update as
|
||||||
|
// the page turn on X3; plain FAST refresh on X4 (previous behavior).
|
||||||
|
renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH);
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
|
}
|
||||||
|
|
||||||
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
||||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||||
@@ -375,15 +388,13 @@ void XtcReaderActivity::renderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void XtcReaderActivity::saveProgress() const {
|
void XtcReaderActivity::saveProgress() const {
|
||||||
HalFile f;
|
uint8_t data[4];
|
||||||
if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
data[0] = currentPage & 0xFF;
|
||||||
uint8_t data[4];
|
data[1] = (currentPage >> 8) & 0xFF;
|
||||||
data[0] = currentPage & 0xFF;
|
data[2] = (currentPage >> 16) & 0xFF;
|
||||||
data[1] = (currentPage >> 8) & 0xFF;
|
data[3] = (currentPage >> 24) & 0xFF;
|
||||||
data[2] = (currentPage >> 16) & 0xFF;
|
if (!ProgressFile::writeAtomic(xtc->getCachePath(), data, sizeof(data))) {
|
||||||
data[3] = (currentPage >> 24) & 0xFF;
|
LOG_ERR("XTR", "Failed to save progress: page %lu", currentPage);
|
||||||
f.write(data, 4);
|
|
||||||
f.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "SilentRestart.h"
|
||||||
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@@ -17,14 +19,46 @@ void ClockSyncActivity::onEnter() {
|
|||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
state = SYNCING;
|
state = SYNCING;
|
||||||
syncedTime[0] = '\0';
|
syncedTime[0] = '\0';
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
requestUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldTearDownWifiOnExit = true;
|
||||||
|
launchWifiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClockSyncActivity::onExit() {
|
||||||
|
Activity::onExit();
|
||||||
|
|
||||||
|
if (shouldTearDownWifiOnExit && WiFi.getMode() != WIFI_MODE_NULL) {
|
||||||
|
WiFi.disconnect(false);
|
||||||
|
delay(30);
|
||||||
|
silentRestart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClockSyncActivity::launchWifiSelection() {
|
||||||
|
LOG_INF("CLK", "Manual sync requested without WiFi, launching WiFi selection");
|
||||||
|
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||||
|
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClockSyncActivity::onWifiSelectionComplete(const bool connected) {
|
||||||
|
if (!connected) {
|
||||||
|
LOG_INF("CLK", "WiFi selection cancelled before manual clock sync");
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = SYNCING;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClockSyncActivity::onExit() { Activity::onExit(); }
|
|
||||||
|
|
||||||
void ClockSyncActivity::runSync() {
|
void ClockSyncActivity::runSync() {
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
LOG_INF("CLK", "Manual sync requested but WiFi is not connected");
|
LOG_INF("CLK", "Manual sync requested but WiFi is not connected after selection");
|
||||||
state = NO_WIFI;
|
state = NO_WIFI;
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
@@ -59,8 +93,7 @@ void ClockSyncActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +133,7 @@ void ClockSyncActivity::render(RenderLock&&) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state != SYNCING) {
|
if (state != SYNCING) {
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OK_BUTTON), "", "");
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
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