Merge branch 'master' into feat-koysnc-xpath

This commit is contained in:
jpirnay
2026-03-19 16:39:50 +01:00
committed by GitHub
184 changed files with 191169 additions and 203260 deletions
+14 -14
View File
@@ -110,7 +110,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
- Only ONE framebuffer exists (not double-buffered)
- Grayscale rendering requires temporary buffer allocation (`renderer.storeBwBuffer()`)
- Must call `renderer.restoreBwBuffer()` to free temporary buffers
- See [lib/GfxRenderer/GfxRenderer.cpp:439-440](lib/GfxRenderer/GfxRenderer.cpp) for malloc usage
- See [lib/GfxRenderer/GfxRenderer.cpp:439-440](../lib/GfxRenderer/GfxRenderer.cpp) for malloc usage
### Directory Structure
* lib/: Internal libraries (Epub engine, GfxRenderer, UITheme, I18n)
@@ -130,7 +130,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
| `HalGPIO` | `InputManager` | Button input handling | *(none)* |
| `HalStorage` | `SDCardManager` | SD card file I/O | `Storage` |
**Location**: [lib/hal/](lib/hal/)
**Location**: [lib/hal/](../lib/hal/)
**Why HAL?**
- Provides consistent error logging per module
@@ -247,7 +247,7 @@ When a template is necessary, limit instantiations: use explicit template instan
### Error Handling Philosophy
**Source**: [src/main.cpp:132-143](src/main.cpp), [lib/GfxRenderer/GfxRenderer.cpp:10](lib/GfxRenderer/GfxRenderer.cpp)
**Source**: [src/main.cpp:132-143](../src/main.cpp), [lib/GfxRenderer/GfxRenderer.cpp:10](../lib/GfxRenderer/GfxRenderer.cpp)
**Pattern Hierarchy**:
1. **LOG_ERR + return false** (90%): `LOG_ERR("MOD", "Failed: %s", reason); return false;`
@@ -259,7 +259,7 @@ When a template is necessary, limit instantiations: use explicit template instan
### Acceptable malloc/free Patterns
**Source**: [src/activities/home/HomeActivity.cpp:166](src/activities/home/HomeActivity.cpp), [lib/GfxRenderer/GfxRenderer.cpp:439-440](lib/GfxRenderer/GfxRenderer.cpp)
**Source**: [src/activities/home/HomeActivity.cpp:166](../src/activities/home/HomeActivity.cpp), [lib/GfxRenderer/GfxRenderer.cpp:439-440](../lib/GfxRenderer/GfxRenderer.cpp)
Despite "prefer stack allocation," malloc is acceptable for:
1. **Large temporary buffers** (> 256 bytes, won't fit on stack)
@@ -290,10 +290,10 @@ buffer = nullptr;
- **Document size**: Comment why stack allocation was rejected
**Examples in codebase**:
- Cover image buffers: [HomeActivity.cpp:166](src/activities/home/HomeActivity.cpp#L166)
- Text chunk buffers: [TxtReaderActivity.cpp:259](src/activities/reader/TxtReaderActivity.cpp#L259)
- Bitmap rendering: [GfxRenderer.cpp:439-440](lib/GfxRenderer/GfxRenderer.cpp#L439-L440)
- OTA update buffer: [OtaUpdater.cpp:40](src/network/OtaUpdater.cpp#L40)
- Cover image buffers: [HomeActivity.cpp:166](../src/activities/home/HomeActivity.cpp)
- Text chunk buffers: [TxtReaderActivity.cpp:259](../src/activities/reader/TxtReaderActivity.cpp)
- Bitmap rendering: [GfxRenderer.cpp:439-440](../lib/GfxRenderer/GfxRenderer.cpp)
- OTA update buffer: [OtaUpdater.cpp:40](../src/network/OtaUpdater.cpp)
---
@@ -305,7 +305,7 @@ buffer = nullptr;
### Logical Button Mapping
**Source**: [src/MappedInputManager.cpp:20-55](src/MappedInputManager.cpp)
**Source**: [src/MappedInputManager.cpp:20-55](../src/MappedInputManager.cpp)
Constraint: Physical button positions are fixed on hardware, but their logical functions change based on user settings and screen orientation.
@@ -352,7 +352,7 @@ Constraint: Physical button positions are fixed on hardware, but their logical f
### Activity Lifecycle and Memory Management
**Source**: [src/main.cpp:132-143](src/main.cpp)
**Source**: [src/main.cpp:132-143](../src/main.cpp)
**CRITICAL**: Activities are **heap-allocated** and **deleted on exit**.
@@ -389,7 +389,7 @@ void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::on
### FreeRTOS Task Guidelines
**Source**: [src/activities/util/KeyboardEntryActivity.cpp:45-50](src/activities/util/KeyboardEntryActivity.cpp)
**Source**: [src/activities/util/KeyboardEntryActivity.cpp:45-50](../src/activities/util/KeyboardEntryActivity.cpp)
**Pattern**: See Activity Lifecycle above. `xTaskCreate(&taskTrampoline, "Name", stackSize, this, 1, &handle)`
@@ -402,7 +402,7 @@ void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::on
### Global Font Loading
**Source**: [src/main.cpp:40-115](src/main.cpp)
**Source**: [src/main.cpp:40-115](../src/main.cpp)
**All fonts are loaded as global static objects** at firmware startup:
- Bookerly: 12, 14, 16, 18pt (4 styles each: regular, bold, italic, bold-italic)
@@ -423,7 +423,7 @@ void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::on
- Fonts stored in **Flash** (marked as `static const` in `lib/EpdFont/builtinFonts/`)
- Font rendering data cached in **DRAM** when first used
- `OMIT_FONTS` can reduce binary size for minimal builds
- Font IDs defined in [src/fontIds.h](src/fontIds.h)
- Font IDs defined in [src/fontIds.h](../src/fontIds.h)
**Usage**:
```cpp
@@ -517,7 +517,7 @@ clang-format -i src/**/*.cpp src/**/*.h
4. **Corrupt Cache Files**:
- Delete `.crosspoint/` directory on SD card
- Forces clean re-parse of all EPUBs
- Check file format versions in [docs/file-formats.md](docs/file-formats.md)
- Check file format versions in [docs/file-formats.md](../docs/file-formats.md)
5. **Watchdog Timeout**:
- Loop/task blocked for >5 seconds
+3 -1
View File
@@ -36,6 +36,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [x] Cover sleep screen
- [x] Wifi book upload
- [x] Wifi OTA updates
- [x] KOReader Sync integration for cross-device reading progress
- [x] Configurable font, layout, and display options
- [ ] User provided fonts
- [ ] Full UTF support
@@ -43,7 +44,8 @@ This project is **not affiliated with Xteink**; it's built as a community projec
Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages).
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint, including the
[KOReader Sync quick setup](./USER_GUIDE.md#365-koreader-sync-quick-setup).
For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document.
+8
View File
@@ -39,6 +39,14 @@ usability over "swiss-army-knife" functionality.
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
capabilities and more powerful chips.
### In-scope — Technically Unsupported
*These features align with CrossPoint's goals but are impractical on the current hardware or produce poor UX.*
* **Clock Display:** The ESP32-C3's RTC drifts significantly during deep sleep; making the clock untrustworthy after any sleep cycle. NTP sync could help, but CrossPoint doesn't connect to the internet on every boot.
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text — resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
## 3. Idea Evaluation
While I appreciate the desire to add new and exciting features to CrossPoint Reader, CrossPoint Reader is designed to be
+131 -5
View File
@@ -20,6 +20,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.2 Reader](#362-reader)
- [3.6.3 Controls](#363-controls)
- [3.6.4 System](#364-system)
- [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup)
- [3.7 Sleep Screen](#37-sleep-screen)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
@@ -84,6 +85,7 @@ The Browse Files screen acts as a file and folder browser.
* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down.
* **Open Selection:** Press **Confirm** to open a folder or read a selected book.
* **Delete Files:** Hold and release **Confirm** to delete the selected file. You will be given an option to either confirm or cancel deletion. Folder deletion is not supported.
### 3.4 Recent Books Screen
@@ -197,15 +199,139 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Check for updates**: Check for Crosspoint firmware updates over WiFi.
- **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information).
### 3.7 Sleep Screen
#### 3.6.5 KOReader Sync Quick Setup
You can customize the sleep screen by placing custom images in specific locations on the SD card:
CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials.
- **Single Image:** Place a file named `sleep.bmp` in the root directory.
- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.bmp` file, and one will be randomly selected each time the device sleeps.
##### Option A: Free Public Server (`sync.koreader.rocks`)
1. Register a user once (only if needed):
```bash
USERNAME="user"
PASSWORD="pass"
PASSWORD_MD5="$(printf '%s' "$PASSWORD" | openssl md5 | awk '{print $2}')"
curl -i "https://sync.koreader.rocks/users/create" \
-H "Accept: application/vnd.koreader.v1+json" \
-H "Content-Type: application/json" \
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
```
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
2. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
- Run **Authenticate**.
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
- Choose **Apply Remote** to jump to remote progress.
- Choose **Upload Local** to push current progress.
##### Option B: Self-Hosted Server (Docker Compose)
1. Start a sync server:
```bash
mkdir -p kosync-quickstart
cd kosync-quickstart
cat > compose.yaml <<'YAML'
services:
kosync:
image: koreader/kosync:latest
ports:
- "7200:7200"
- "17200:17200"
volumes:
- ./data/redis:/var/lib/redis
environment:
- ENABLE_USER_REGISTRATION=true
restart: unless-stopped
YAML
# Docker
docker compose up -d
# Podman (alternative)
podman compose up -d
```
> [!NOTE]
> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images.
> `ENABLE_USER_REGISTRATION=true` is convenient for first setup. After creating your users, set it to `false` (or remove it) to avoid unexpected registrations.
2. Verify the server:
```bash
curl -H "Accept: application/vnd.koreader.v1+json" "http://<server-ip>:17200/healthcheck"
# Expected: {"state":"OK"}
```
3. Register a user once.
CrossPoint authenticates against KOReader Sync (`koreader/kosync`) using an MD5 key, so register using the MD5 of your password:
> [!WARNING]
> Sending a reusable MD5-derived password over plain HTTP is insecure.
> Create unique sync-only credentials and do not reuse main account passwords.
> Prefer `https://<server-ip>:7200` whenever traffic leaves a fully trusted LAN or when using untrusted networks.
> Use `curl -k` only for self-signed certificate testing.
```bash
USERNAME="user"
PASSWORD="pass"
PASSWORD_MD5="$(printf '%s' "$PASSWORD" | openssl md5 | awk '{print $2}')"
curl -i "http://<server-ip>:17200/users/create" \
-H "Accept: application/vnd.koreader.v1+json" \
-H "Content-Type: application/json" \
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
```
If this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, the account already exists.
4. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Set **Sync Server URL** to `http://<server-ip>:17200`.
- Run **Authenticate**.
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
- Choose **Apply Remote** to jump to remote progress.
- Choose **Upload Local** to push current progress.
### 3.7 Sleep Screen
The **Sleep Screen** setting controls what is displayed when the device goes to sleep:
| Mode | Behavior |
|------|----------|
| **Dark** (default) | The CrossPoint logo on a dark background. |
| **Light** | The CrossPoint logo on a white background. |
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
| **Cover + Custom** | The cover of the currently open book. Falls back to **Custom** behavior if no book is open. |
| **None** | A blank screen. |
#### Cover settings
When using **Cover** or **Cover + Custom**, two additional settings apply:
- **Sleep Screen Cover Mode**: **Fit** (scale to fit, white borders) or **Crop** (scale and crop to fill the screen).
- **Sleep Screen Cover Filter**: **None** (grayscale), **Contrast** (black & white), or **Inverted** (inverted black & white).
#### Custom images
To use custom sleep images, set the sleep screen mode to **Custom** or **Cover + Custom**, then place images on the SD card:
- **Multiple Images (recommended):** Create a `.sleep` directory in the root of the SD card and place any number of `.bmp` images inside. One will be randomly selected each time the device sleeps. (A directory named `sleep` is also accepted as a fallback.)
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This is used as a fallback if no valid images are found in the `.sleep`/`sleep` directory.
> [!TIP]
> For best results:
+472
View File
@@ -0,0 +1,472 @@
# Activity & ActivityManager Migration Guide
This document explains the refactoring from the original per-activity render task model to the centralized `ActivityManager` introduced in [PR #1016](https://github.com/crosspoint-reader/crosspoint-reader/pull/1016). It covers the architectural differences, what changed for activity authors, and the FreeRTOS task and locking model that underpins the system.
## Overview of Changes
| Aspect | Old Model | New Model |
|--------|-----------|-----------|
| Render task | One per activity (8KB stack each) | Single shared task in `ActivityManager` |
| Render mutex | Per-activity `renderingMutex` | Single global mutex in `ActivityManager` |
| `RenderLock` | Inner class of `Activity` | Standalone class, acquires global mutex |
| Subactivities | `ActivityWithSubactivity` base class | Activity stack managed by `ActivityManager` |
| Navigation | Free functions in `main.cpp` | `activityManager.goHome()`, `goToReader()`, etc. |
| Subactivity results | Callback lambdas stored in parent | `startActivityForResult()` / `setResult()` / `finish()` |
| `requestUpdate()` | Notifies activity's own render task | Delegates to `ActivityManager` (immediate or deferred) |
## Architecture
### Old Model: Per-Activity Render Tasks
Each activity created its own FreeRTOS render task on entry and destroyed it on exit:
```text
┌─────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ currentActivity->loop() │ │
│ │ ├── handle input │ │
│ │ ├── update state (under RenderLock) │ │
│ │ └── requestUpdate() ──notify──► Render Task │ │
│ │ (per-activity)│ │
│ │ 8KB stack │ │
│ │ owns mutex │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ActivityWithSubactivity: │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Parent │────►│ SubActivity │ │
│ │ (has render │ │ (has own │ │
│ │ task) │ │ render task) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
Problems with this approach:
- **8KB per render task**: Each activity allocated an 8KB FreeRTOS stack for its render task, even though only one renders at a time
- **Dangerous deletion patterns**: `exitActivity()` + `enterNewActivity()` in callbacks led to `delete this` situations where the caller was destroyed while its code was still on the stack
- **Subactivity coupling**: Parents stored callbacks to child results, creating tight coupling and lifetime hazards
### New Model: Centralized ActivityManager
A single `ActivityManager` owns the render task and manages an activity stack:
```text
┌──────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ │
│ activityManager.loop() │
│ │ │
│ ├── currentActivity->loop() │
│ │ ├── handle input │
│ │ ├── update state (under RenderLock) │
│ │ └── requestUpdate() │
│ │ │
│ ├── process pending actions (Push / Pop / Replace) │
│ │ │
│ └── if requestedUpdate: ──notify──► Render Task │
│ (single, shared) │
│ 8KB stack │
│ global mutex │
│ │
│ Activity Stack: │
│ ┌──────────┬──────────┬──────────┐ ┌──────────┐ │
│ │ Home │ Settings │ Wifi │ │ Keyboard │ │
│ │ (stack) │ (stack) │ (stack) │ │ (current)│ │
│ └──────────┴──────────┴──────────┘ └──────────┘ │
│ stackActivities[] currentActivity │
└──────────────────────────────────────────────────────────┘
```
## Migration Checklist
### 1. Change Base Class
If your activity extended `ActivityWithSubactivity`, change it to extend `Activity`:
```cpp
// BEFORE
class MyActivity final : public ActivityWithSubactivity {
MyActivity(GfxRenderer& r, MappedInputManager& m, std::function<void()> goBack)
: ActivityWithSubactivity("MyActivity", r, m), goBack(goBack) {}
};
// AFTER
class MyActivity final : public Activity {
MyActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("MyActivity", r, m) {}
};
```
Note that navigation callbacks like `goBack` are no longer stored — use `finish()` or `activityManager.goHome()` instead.
### 2. Replace Navigation Functions
The free functions `exitActivity()` / `enterNewActivity()` in `main.cpp` are gone. Use `ActivityManager` methods:
```cpp
// BEFORE (in main.cpp or via stored callbacks)
exitActivity();
enterNewActivity(new SettingsActivity(renderer, mappedInput, onGoHome));
// AFTER (from any Activity method)
activityManager.goToSettings();
// or for arbitrary navigation:
activityManager.replaceActivity(std::make_unique<MyActivity>(renderer, mappedInput));
```
`replaceActivity()` destroys the current activity and clears the stack. Use it for top-level navigation (home, reader, settings, etc.).
### 3. Replace Subactivity Pattern
The `enterNewActivity()` / `exitActivity()` subactivity pattern is replaced by a stack with typed results:
```cpp
// BEFORE
void MyActivity::launchWifi() {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](bool connected) { onWifiDone(connected); }));
}
// Child calls: onComplete(true); // triggers callback, which may call exitActivity()
// AFTER
void MyActivity::launchWifi() {
startActivityForResult(
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) {
if (result.isCancelled) return;
auto& wifi = std::get<WifiResult>(result.data);
onWifiDone(wifi.connected);
});
}
// Child calls:
// setResult(WifiResult{.connected = true, .ssid = ssid});
// finish();
```
Key differences:
- **`startActivityForResult()`** pushes the current activity onto the stack and launches the child
- **`setResult()`** stores a typed result on the child activity
- **`finish()`** signals the manager to pop the child, call the result handler, and resume the parent
- The parent is never deleted during this process — it's safely stored on the stack
### 4. Update `render()` Signature
The `RenderLock` type changed from `Activity::RenderLock` (inner class) to standalone `RenderLock`:
```cpp
// BEFORE
void render(Activity::RenderLock&&) override;
// AFTER
void render(RenderLock&&) override;
```
Include `RenderLock.h` if not transitively included via `Activity.h`.
### 5. Update `onEnter()` / `onExit()`
Activities no longer create or destroy render tasks:
```cpp
// BEFORE
void MyActivity::onEnter() {
Activity::onEnter(); // created render task + logged
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // acquired RenderLock, deleted render task
}
// AFTER
void MyActivity::onEnter() {
Activity::onEnter(); // just logs
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // just logs
}
```
The render task lifecycle is handled entirely by `ActivityManager::begin()`.
### 6. Update `requestUpdate()` Calls
The signature changed to accept an `immediate` flag:
```cpp
// BEFORE
void requestUpdate(); // always immediate notification to per-activity render task
// AFTER
void requestUpdate(bool immediate = false);
// immediate=false (default): deferred until end of current loop iteration
// immediate=true: sends notification to render task right away
```
**When to use `immediate`**: Almost never. Deferred updates are batched — if `loop()` triggers multiple state changes that each call `requestUpdate()`, only one render happens. Use `immediate` only when you need the render to start before the current function returns (e.g., before a blocking network call).
**`requestUpdateAndWait()`**: Blocks the calling task until the render completes. Use sparingly — it's designed for cases where you need the screen to reflect new state before proceeding (e.g., showing "Checking for update..." before calling a network API).
### 7. Remove Stored Navigation Callbacks
Old activities often stored `std::function` callbacks for navigation:
```cpp
// BEFORE
class SettingsActivity : public ActivityWithSubactivity {
const std::function<void()> goBack; // stored callback
const std::function<void()> goHome; // stored callback
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m,
std::function<void()> goBack, std::function<void()> goHome)
: ActivityWithSubactivity("Settings", r, m), goBack(goBack), goHome(goHome) {}
};
// AFTER
class SettingsActivity : public Activity {
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("Settings", r, m) {}
// Use finish() to go back, activityManager.goHome() to go home
};
```
This removes `std::function` overhead (~2-4KB per unique signature) and eliminates lifetime risks from captured `this` pointers.
## Technical Details
### FreeRTOS Task Model
The firmware runs on an ESP32-C3, a single-core RISC-V microcontroller. FreeRTOS provides cooperative and preemptive multitasking on this single core — only one task executes at any moment, and the scheduler switches between tasks at yield points (blocking calls, `vTaskDelay`, `taskYIELD`) or when a tick interrupt promotes a higher-priority task.
There are two tasks relevant to the activity system:
```text
┌──────────────────────┐ ┌──────────────────────────┐
│ Main Task │ │ Render Task │
│ (Arduino loop) │ │ (ActivityManager-owned) │
│ Priority: 1 │ │ Priority: 1 │
│ │ │ │
│ Runs: │ │ Runs: │
│ - gpio.update() │ │ - ulTaskNotifyTake() │
│ - activity->loop() │ │ (blocks until notified)│
│ - pending actions │ │ - RenderLock (mutex) │
│ - sleep/power mgmt │ │ - activity->render() │
│ - requestUpdate →────┼─────┼─► xTaskNotify() │
│ (end of loop) │ │ │
└──────────────────────┘ └──────────────────────────┘
```
Both tasks run at priority 1. Since the ESP32-C3 is single-core, they alternate execution: the main task runs `loop()`, then at the end of the loop iteration, notifies the render task if an update was requested. The render task wakes, acquires the mutex, calls `render()`, releases the mutex, and blocks again.
Do not use `xTaskCreate` inside activities. If you have a use case that seems to require a background task, open a discussion to propose a lifecycle-aware `Worker` abstraction first.
### The Render Mutex and RenderLock
A single FreeRTOS mutex (`renderingMutex`) protects shared state between `loop()` and `render()`. Since these run on different tasks, any state read by `render()` and written by `loop()` must be guarded.
`RenderLock` is an RAII wrapper:
```cpp
// Standalone class (not tied to any specific activity)
class RenderLock {
bool isLocked = false;
public:
explicit RenderLock(); // acquires activityManager.renderingMutex
explicit RenderLock(Activity&); // same — Activity& param kept for compatibility
~RenderLock(); // releases mutex if still held
void unlock(); // early release
};
```
**Usage patterns:**
```cpp
// In loop(): protect state mutations that render() reads
void MyActivity::loop() {
if (somethingChanged) {
RenderLock lock;
state = newState; // safe — render() can't run while lock is held
}
requestUpdate(); // trigger render after lock is released
}
// In render(): lock is passed in, held for duration of render
void MyActivity::render(RenderLock&&) {
// Lock is held — safe to read shared state
renderer.clearScreen();
renderer.drawText(..., stateString, ...);
renderer.displayBuffer();
// Lock released when RenderLock destructor runs
}
```
**Critical rule**: Never call `requestUpdateAndWait()` while holding a `RenderLock`. The render task needs the mutex to call `render()`, so holding it while waiting for the render to complete is a deadlock:
```text
Main Task Render Task
────────── ───────────
RenderLock lock; (blocked on mutex)
requestUpdateAndWait();
→ notify render task
→ block waiting for
render to complete → wakes up
→ tries to acquire mutex
→ DEADLOCK: main holds mutex,
waits for render; render
waits for mutex
```
### requestUpdate() vs requestUpdateAndWait()
```text
requestUpdate(false) requestUpdate(true)
───────────────── ─────────────────
Sets flag only. Notifies render task
Render happens after immediately.
loop() returns and Render may start
ActivityManager checks before the calling
the flag. function returns.
(Does NOT wait for
render to complete.)
requestUpdateAndWait()
──────────────────────
Notifies render task AND
blocks calling task until
render is done. Uses
FreeRTOS direct-to-task
notification on the
caller's task handle.
```
`requestUpdateAndWait()` flow in detail:
```text
Calling Task Render Task
──────────── ───────────
requestUpdateAndWait()
├─ assert: not render task
├─ assert: not holding RenderLock
├─ store waitingTaskHandle
├─ xTaskNotify(renderTask) → wakes render task
└─ ulTaskNotifyTake() ─┐
(blocked) │ RenderLock lock;
│ activity->render();
│ // render complete
│ taskENTER_CRITICAL
│ waiter = waitingTaskHandle
│ waitingTaskHandle = nullptr
│ taskEXIT_CRITICAL
│ xTaskNotify(waiter) ───┐
│ │
┌──────────────────────┘ │
│ (woken by notification) ◄────────────────────────┘
└─ return
```
### Activity Lifecycle Under ActivityManager
```text
activityManager.replaceActivity(make_unique<MyActivity>(...))
╔═══════════════════════════════════════════════════╗
║ pendingAction = Replace ║
║ pendingActivity = MyActivity ║
╚═══════════════════════════════════════════════════╝
▼ (next loop iteration)
ActivityManager::loop()
├── currentActivity->loop() // old activity's last loop
├── process pending action:
│ ├── RenderLock lock;
│ ├── oldActivity->onExit() // cleanup under lock
│ ├── delete oldActivity
│ ├── clear stack
│ ├── currentActivity = MyActivity
│ ├── lock.unlock()
│ └── MyActivity->onEnter() // init new activity
└── if requestedUpdate:
└── notify render task
```
For push/pop (subactivity) navigation:
```text
Parent calls: startActivityForResult(make_unique<Child>(...), handler)
╔══════════════════════════════════════╗
║ pendingAction = Push ║
║ pendingActivity = Child ║
║ parent->resultHandler = handler ║
╚══════════════════════════════════════╝
▼ (next loop iteration)
├── Parent moved to stackActivities[]
├── currentActivity = Child
└── Child->onEnter()
... child runs ...
Child calls: setResult(MyResult{...}); finish();
╔══════════════════════════════════════╗
║ pendingAction = Pop ║
║ child->result = MyResult{...} ║
╚══════════════════════════════════════╝
▼ (next loop iteration)
├── result = child->result
├── Child->onExit(); delete Child
├── currentActivity = Parent (popped from stack)
├── Parent->resultHandler(result)
└── requestUpdate() // automatic re-render for parent
```
### Common Pitfalls
**Calling `finish()` and continuing to access `this`**: `finish()` sets `pendingAction = Pop` but does not immediately destroy the activity. The activity is destroyed on the next `ActivityManager::loop()` iteration. It's safe to access member variables after `finish()` within the same function, but don't rely on the activity surviving past the current `loop()` call.
**Modifying shared state without `RenderLock`**: If `render()` reads a variable and `loop()` writes it, the write must be under a `RenderLock`. Without it, `render()` could see a half-written value (e.g., a partially updated string or struct).
**Creating background tasks that outlive the activity**: Any FreeRTOS task created in `onEnter()` must be deleted in `onExit()` before the activity is destroyed. The `ActivityManager` does not track or clean up background tasks.
**Holding `RenderLock` across blocking calls**: The render task is blocked on the mutex while you hold the lock. Keep critical sections short — acquire, mutate state, release, then do blocking work.
```cpp
// WRONG — blocks render for the entire network call
void MyActivity::doNetworkStuff() {
RenderLock lock;
state = LOADING;
auto result = http.get(url); // blocks for seconds with lock held
state = DONE;
}
// CORRECT — release lock before blocking
void MyActivity::doNetworkStuff() {
{
RenderLock lock;
state = LOADING;
}
requestUpdate(true); // render "Loading..." immediately, before we block
auto result = http.get(url); // lock is not held
{
RenderLock lock;
state = DONE;
}
requestUpdate();
}
```
+1
View File
@@ -15,6 +15,7 @@ This guide explains the multi-language support system in CrossPoint Reader.
- Ukrainian
- Polish
- Danish
- Turkish
---
+5
View File
@@ -20,6 +20,9 @@ If you'd like to add your name to this list, please open a PR adding yourself an
## Portuguese (Brazil)
- [yagofarias](https://github.com/yagofarias)
## Portuguese (Portugal)
- [victordomingos](https://github.com/victordomingos)
## Italian
- [andreaturchet](https://github.com/andreaturchet)
- [fragolinux](https://github.com/fragolinux)
@@ -32,9 +35,11 @@ If you'd like to add your name to this list, please open a PR adding yourself an
- [yeyeto2788](https://github.com/yeyeto2788)
- [Skrzakk](https://github.com/Skrzakk)
- [pablohc](https://github.com/pablohc)
- [DaniPhii](https://github.com/DaniPhii)
## Swedish
- [dawiik](https://github.com/dawiik)
- [steka](https://github.com/steka)
## Romanian
- [ariel-lindemann](https://github.com/ariel-lindemann)
+41 -52
View File
@@ -15,10 +15,9 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
return;
}
int cursorX = startX;
const int cursorY = startY;
int32_t cursorXFP = fp4::fromPixel(startX); // 12.4 fixed-point accumulator
int lastBaseX = startX;
int lastBaseAdvance = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseTop = 0;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
@@ -32,7 +31,6 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
const EpdGlyph* glyph = getGlyph(cp);
if (!glyph) {
// TODO: Better handle this?
prevCp = 0;
continue;
}
@@ -46,11 +44,12 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
}
if (!isCombining && prevCp != 0) {
cursorX += getKerning(prevCp, cp);
cursorXFP += getKerning(prevCp, cp); // 4.4 fixed-point kern
}
const int glyphBaseX = isCombining ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
const int cursorXPixels = fp4::toPixel(cursorXFP); // snap 12.4 fixed-point to nearest pixel
const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : cursorXPixels;
const int glyphBaseY = startY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
@@ -58,10 +57,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseX = cursorXPixels;
lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point
lastBaseTop = glyph->top;
cursorX += glyph->advanceX;
cursorXFP += glyph->advanceX; // 12.4 fixed-point advance
prevCp = cp;
}
}
@@ -80,21 +79,19 @@ static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t
if (!entries || count == 0 || cp > 0xFFFF) {
return 0;
}
const auto target = static_cast<uint16_t>(cp);
int left = 0;
int right = static_cast<int>(count) - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const uint16_t midCp = entries[mid].codepoint;
if (midCp == target) {
return entries[mid].classId;
}
if (midCp < target) {
left = mid + 1;
} else {
right = mid - 1;
}
const auto* end = entries + count;
// lower_bound: exact-key lookup. Finds the first entry with codepoint >= target,
// then the equality check confirms an exact match exists.
const auto it = std::lower_bound(
entries, end, target, [](const EpdKernClassEntry& entry, uint16_t value) { return entry.codepoint < value; });
if (it != end && it->codepoint == target) {
return it->classId;
}
return 0;
}
@@ -117,21 +114,17 @@ uint32_t EpdFont::getLigature(const uint32_t leftCp, const uint32_t rightCp) con
}
const uint32_t key = (leftCp << 16) | rightCp;
int left = 0;
int right = static_cast<int>(count) - 1;
const auto* end = pairs + count;
while (left <= right) {
const int mid = left + (right - left) / 2;
const uint32_t midKey = pairs[mid].pair;
if (midKey == key) {
return pairs[mid].ligatureCp;
}
if (midKey < key) {
left = mid + 1;
} else {
right = mid - 1;
}
// lower_bound: exact-key lookup. Finds the first entry with pair >= key,
// then the equality check confirms an exact match exists.
const auto it =
std::lower_bound(pairs, end, key, [](const EpdLigaturePair& pair, uint32_t value) { return pair.pair < value; });
if (it != end && it->pair == key) {
return it->ligatureCp;
}
return 0;
}
@@ -154,29 +147,25 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
}
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals;
const int count = data->intervalCount;
if (count == 0) return nullptr;
// Binary search for O(log n) lookup instead of O(n)
// Critical for Korean fonts with many unicode intervals
int left = 0;
int right = count - 1;
const EpdUnicodeInterval* intervals = data->intervals;
const auto* end = intervals + count;
while (left <= right) {
const int mid = left + (right - left) / 2;
const EpdUnicodeInterval* interval = &intervals[mid];
// upper_bound: range lookup. Finds the first interval with first > cp, so the
// interval just before it is the last one with first <= cp. That's the only
// candidate that could contain cp. Then we verify cp <= candidate.last.
const auto it = std::upper_bound(
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
if (cp < interval->first) {
right = mid - 1;
} else if (cp > interval->last) {
left = mid + 1;
} else {
// Found: cp >= interval->first && cp <= interval->last
return &data->glyph[interval->offset + (cp - interval->first)];
if (it != intervals) {
const auto& interval = *(it - 1);
if (cp <= interval.last) {
return &data->glyph[interval.offset + (cp - interval.first)];
}
}
if (cp != REPLACEMENT_GLYPH) {
return getGlyph(REPLACEMENT_GLYPH);
}
+1 -1
View File
@@ -12,7 +12,7 @@ class EpdFont {
const EpdGlyph* getGlyph(uint32_t cp) const;
/// Returns the kerning adjustment (in pixels) between two codepoints.
/// Returns the kerning adjustment (4.4 fixed-point in pixels) between two codepoints.
/// Returns 0 if no kerning data exists for the pair.
int8_t getKerning(uint32_t leftCp, uint32_t rightCp) const;
+33 -3
View File
@@ -4,11 +4,40 @@
#pragma once
#include <cstdint>
/// Font metrics use "fixed-point 4" (4 fractional bits, i.e. 1/16-pixel
/// resolution). Both the 12.4 glyph advances (uint16_t) and the 4.4 kern
/// values (int8_t) share the same 4 fractional bits, so they can be freely
/// added into a single int32_t accumulator during text layout. The
/// accumulator is snapped to the nearest whole pixel only at render time,
/// which avoids the per-character rounding errors that plagued integer-only
/// layout.
///
/// The helpers below eliminate the raw bit-shifts that would otherwise be
/// scattered across every layout / measurement call site.
namespace fp4 {
constexpr int FRAC_BITS = 4;
constexpr int32_t HALF = 1 << (FRAC_BITS - 1); // 8, added before shift for round-to-nearest
/// Convert an integer pixel value to 12.4 fixed-point.
constexpr int32_t fromPixel(int px) { return static_cast<int32_t>(px) << FRAC_BITS; }
/// Snap a fixed-point value to the nearest integer pixel.
constexpr int toPixel(int32_t fp) { return static_cast<int>((fp + HALF) >> FRAC_BITS); }
/// Convert a fixed-point value to float (mainly useful for debug logging).
constexpr float toFloat(int32_t fp) { return fp / static_cast<float>(1 << FRAC_BITS); }
} // namespace fp4
/// Fixed-point conventions used by EpdGlyph and EpdFontData:
/// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel)
/// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel)
/// Both share 4 fractional bits so they combine directly in an accumulator.
/// Font data stored PER GLYPH
typedef struct {
uint8_t width; ///< Bitmap dimensions in pixels
uint8_t height; ///< Bitmap dimensions in pixels
uint8_t advanceX; ///< Distance to advance cursor (x axis)
uint16_t advanceX; ///< Distance to advance cursor (x axis), 12.4 fixed-point in pixels
int16_t left; ///< X dist from cursor pos to UL corner
int16_t top; ///< Y dist from cursor pos to UL corner
uint16_t dataLength; ///< Size of the font data.
@@ -21,7 +50,7 @@ typedef struct {
uint32_t compressedSize; ///< Compressed DEFLATE stream size
uint32_t uncompressedSize; ///< Decompressed size
uint16_t glyphCount; ///< Number of glyphs in this group
uint16_t firstGlyphIndex; ///< First glyph index in the global glyph array
uint32_t firstGlyphIndex; ///< First glyph index in the global glyph array
} EpdFontGroup;
/// Glyph interval structure
@@ -57,9 +86,10 @@ typedef struct {
bool is2Bit;
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
uint16_t groupCount; ///< 0 for uncompressed fonts
const uint16_t* glyphToGroup; ///< Per-glyph group ID (nullptr for contiguous-group fonts)
const EpdKernClassEntry* kernLeftClasses; ///< Sorted left-side class map (nullptr if none)
const EpdKernClassEntry* kernRightClasses; ///< Sorted right-side class map (nullptr if none)
const int8_t* kernMatrix; ///< Flat leftClassCount x rightClassCount matrix
const int8_t* kernMatrix; ///< Flat leftClassCount x rightClassCount matrix, 4.4 fixed-point in pixels
uint16_t kernLeftEntryCount; ///< Entries in kernLeftClasses
uint16_t kernRightEntryCount; ///< Entries in kernRightClasses
uint8_t kernLeftClassCount; ///< Number of distinct left classes (matrix rows)
+417 -83
View File
@@ -1,34 +1,55 @@
#include "FontDecompressor.h"
#include <Arduino.h>
#include <Logging.h>
#include <Utf8.h>
#include <cstdlib>
FontDecompressor::~FontDecompressor() { deinit(); }
bool FontDecompressor::init() {
clearCache();
return true;
}
void FontDecompressor::freeAllEntries() {
for (auto& entry : cache) {
if (entry.data) {
free(entry.data);
entry.data = nullptr;
void FontDecompressor::deinit() {
freePageBuffer();
freeHotGroup();
}
entry.valid = false;
}
}
void FontDecompressor::deinit() { freeAllEntries(); }
void FontDecompressor::clearCache() {
freeAllEntries();
accessCounter = 0;
freePageBuffer();
freeHotGroup();
}
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex) {
void FontDecompressor::freePageBuffer() {
free(pageBuffer);
pageBuffer = nullptr;
free(pageGlyphs);
pageGlyphs = nullptr;
pageFont = nullptr;
pageGlyphCount = 0;
}
void FontDecompressor::freeHotGroup() {
hotGroup.clear();
hotGroup.shrink_to_fit();
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
hotGlyphBuf.clear();
hotGlyphBuf.shrink_to_fit();
}
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
// O(1) path for frequency-grouped fonts with glyphToGroup mapping
if (fontData->glyphToGroup != nullptr) {
return fontData->glyphToGroup[glyphIndex];
}
// Contiguous-group fonts: linear scan
for (uint16_t i = 0; i < fontData->groupCount; i++) {
uint16_t first = fontData->groups[i].firstGlyphIndex;
uint32_t first = fontData->groups[i].firstGlyphIndex;
if (glyphIndex >= first && glyphIndex < first + fontData->groups[i].glyphCount) {
return i;
}
@@ -36,99 +57,412 @@ uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint16_t g
return fontData->groupCount; // sentinel = not found
}
FontDecompressor::CacheEntry* FontDecompressor::findInCache(const EpdFontData* fontData, uint16_t groupIndex) {
for (auto& entry : cache) {
if (entry.valid && entry.font == fontData && entry.groupIndex == groupIndex) {
return &entry;
}
}
return nullptr;
}
FontDecompressor::CacheEntry* FontDecompressor::findEvictionCandidate() {
// Find an invalid slot first
for (auto& entry : cache) {
if (!entry.valid) {
return &entry;
}
}
// Otherwise evict LRU
CacheEntry* lru = &cache[0];
for (auto& entry : cache) {
if (entry.lastUsed < lru->lastUsed) {
lru = &entry;
}
}
return lru;
}
bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry) {
bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, uint8_t* outBuf,
uint32_t outSize) {
const EpdFontGroup& group = fontData->groups[groupIndex];
// Free old buffer if reusing a slot
if (entry->data) {
free(entry->data);
entry->data = nullptr;
}
entry->valid = false;
// Allocate output buffer
auto* outBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
if (!outBuf) {
LOG_ERR("FDC", "Failed to allocate %u bytes for group %u", group.uncompressedSize, groupIndex);
return false;
}
const uint32_t tDecomp = millis();
inflateReader.init(false);
inflateReader.setSource(&fontData->bitmap[group.compressedOffset], group.compressedSize);
if (!inflateReader.read(outBuf, group.uncompressedSize)) {
if (!inflateReader.read(outBuf, outSize)) {
stats.decompressTimeMs += millis() - tDecomp;
LOG_ERR("FDC", "Decompression failed for group %u", groupIndex);
free(outBuf);
return false;
}
entry->font = fontData;
entry->groupIndex = groupIndex;
entry->data = outBuf;
entry->dataSize = group.uncompressedSize;
entry->valid = true;
stats.decompressTimeMs += millis() - tDecomp;
return true;
}
const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex) {
// --- Byte-aligned helpers ---
uint32_t FontDecompressor::getAlignedOffset(const EpdFontData* fontData, uint16_t groupIndex, uint32_t glyphIndex) {
uint32_t offset = 0;
auto accumGlyph = [&](const EpdGlyph& g) {
if (g.width > 0 && g.height > 0) {
offset += ((g.width + 3) / 4) * g.height;
}
};
if (fontData->glyphToGroup) {
// Frequency-grouped: scan glyphs before glyphIndex that belong to this group
for (uint32_t i = 0; i < glyphIndex; i++) {
if (fontData->glyphToGroup[i] == groupIndex) {
accumGlyph(fontData->glyph[i]);
}
}
} else {
// Contiguous-group: sum aligned sizes of preceding glyphs in the group
const EpdFontGroup& group = fontData->groups[groupIndex];
for (uint32_t i = group.firstGlyphIndex; i < glyphIndex; i++) {
accumGlyph(fontData->glyph[i]);
}
}
return offset;
}
void FontDecompressor::compactSingleGlyph(const uint8_t* alignedSrc, uint8_t* packedDst, uint8_t width,
uint8_t height) {
if (width == 0 || height == 0) return;
const uint32_t rowStride = (width + 3) / 4;
if (width % 4 == 0) {
memcpy(packedDst, alignedSrc, rowStride * height);
return;
}
uint8_t outByte = 0, outBits = 0;
uint32_t writeIdx = 0;
for (uint8_t y = 0; y < height; y++) {
for (uint8_t x = 0; x < width; x++) {
outByte = (outByte << 2) | ((alignedSrc[y * rowStride + x / 4] >> ((3 - (x % 4)) * 2)) & 0x3);
outBits += 2;
if (outBits == 8) {
packedDst[writeIdx++] = outByte;
outByte = 0;
outBits = 0;
}
}
}
if (outBits > 0) packedDst[writeIdx] = outByte << (8 - outBits);
}
// --- getBitmap: page buffer → hot group → decompress ---
const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint32_t glyphIndex) {
const uint32_t tStart = micros();
stats.getBitmapCalls++;
if (!fontData->groups || fontData->groupCount == 0) {
stats.getBitmapTimeUs += micros() - tStart;
return &fontData->bitmap[glyph->dataOffset];
}
// Check page buffer first (populated by prewarmCache)
if (pageBuffer && pageFont == fontData && pageGlyphCount > 0) {
int left = 0, right = pageGlyphCount - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (pageGlyphs[mid].glyphIndex == glyphIndex) {
if (pageGlyphs[mid].bufferOffset != UINT32_MAX) {
stats.cacheHits++;
stats.getBitmapTimeUs += micros() - tStart;
return &pageBuffer[pageGlyphs[mid].bufferOffset];
}
break; // Not extracted during prewarm; fall through to hot-group path
}
if (pageGlyphs[mid].glyphIndex < glyphIndex)
left = mid + 1;
else
right = mid - 1;
}
}
// Fallback: hot group slot
uint16_t groupIndex = getGroupIndex(fontData, glyphIndex);
if (groupIndex >= fontData->groupCount) {
LOG_ERR("FDC", "Glyph %u not found in any group", glyphIndex);
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
// Check cache
CacheEntry* entry = findInCache(fontData, groupIndex);
if (entry) {
entry->lastUsed = ++accessCounter;
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
glyph->dataLength, groupIndex, entry->dataSize);
return nullptr;
}
return &entry->data[glyph->dataOffset];
}
// Check if hot group already has this group decompressed — if not, decompress it
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
stats.cacheMisses++;
const EpdFontGroup& group = fontData->groups[groupIndex];
// Cache miss - decompress
entry = findEvictionCandidate();
if (!decompressGroup(fontData, groupIndex, entry)) {
hotGroup.resize(group.uncompressedSize);
if (hotGroup.empty()) {
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
entry->lastUsed = ++accessCounter;
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
glyph->dataLength, groupIndex, entry->dataSize);
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
hotGroup.clear();
hotGroup.shrink_to_fit();
hotGroupFont = nullptr;
hotGroupIndex = UINT16_MAX;
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
return &entry->data[glyph->dataOffset];
hotGroupFont = fontData;
hotGroupIndex = groupIndex;
stats.hotGroupBytes = group.uncompressedSize;
} else {
stats.cacheHits++;
}
// Compact just the requested glyph from byte-aligned data into scratch buffer
if (glyph->dataLength > hotGlyphBuf.size()) {
hotGlyphBuf.resize(glyph->dataLength);
}
if (hotGlyphBuf.empty()) {
stats.getBitmapTimeUs += micros() - tStart;
return nullptr;
}
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
stats.getBitmapTimeUs += micros() - tStart;
return hotGlyphBuf.data();
}
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
int32_t FontDecompressor::findGlyphIndex(const EpdFontData* fontData, uint32_t codepoint) {
const EpdUnicodeInterval* intervals = fontData->intervals;
const int count = fontData->intervalCount;
if (count == 0) return -1;
// Binary search
int left = 0;
int right = count - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const EpdUnicodeInterval* interval = &intervals[mid];
if (codepoint < interval->first) {
right = mid - 1;
} else if (codepoint > interval->last) {
left = mid + 1;
} else {
return static_cast<int32_t>(interval->offset + (codepoint - interval->first));
}
}
return -1;
}
int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8Text) {
freePageBuffer();
if (!fontData || !fontData->groups || !utf8Text) return 0;
// Step 1: Collect unique glyph indices needed for this page
uint32_t neededGlyphs[MAX_PAGE_GLYPHS];
uint16_t glyphCount = 0;
bool glyphCapWarned = false;
const unsigned char* p = reinterpret_cast<const unsigned char*>(utf8Text);
while (*p) {
uint32_t cp = utf8NextCodepoint(&p);
if (cp == 0) break;
int32_t glyphIdx = findGlyphIndex(fontData, cp);
if (glyphIdx < 0) continue;
// Deduplicate
bool found = false;
for (uint16_t i = 0; i < glyphCount; i++) {
if (neededGlyphs[i] == static_cast<uint32_t>(glyphIdx)) {
found = true;
break;
}
}
if (!found) {
if (glyphCount < MAX_PAGE_GLYPHS) {
neededGlyphs[glyphCount++] = static_cast<uint32_t>(glyphIdx);
} else if (!glyphCapWarned) {
LOG_DBG("FDC", "Glyph cap (%u) reached during prewarm; excess glyphs will use hot-group fallback",
MAX_PAGE_GLYPHS);
glyphCapWarned = true;
}
}
}
if (glyphCount == 0) return 0;
// Step 2: Compute total buffer size and collect unique groups
uint32_t totalBytes = 0;
uint16_t neededGroups[128];
uint8_t groupCount = 0;
bool groupCapWarned = false;
for (uint16_t i = 0; i < glyphCount; i++) {
totalBytes += fontData->glyph[neededGlyphs[i]].dataLength;
uint16_t gi = getGroupIndex(fontData, neededGlyphs[i]);
bool found = false;
for (uint8_t j = 0; j < groupCount; j++) {
if (neededGroups[j] == gi) {
found = true;
break;
}
}
if (!found) {
if (groupCount < 128) {
neededGroups[groupCount++] = gi;
} else if (!groupCapWarned) {
LOG_DBG("FDC", "Group cap (128) reached during prewarm; some groups will use hot-group fallback");
groupCapWarned = true;
}
}
}
stats.uniqueGroupsAccessed = groupCount;
// Step 3: Allocate page buffer and lookup table
pageBuffer = static_cast<uint8_t*>(malloc(totalBytes));
pageGlyphs = static_cast<PageGlyphEntry*>(malloc(glyphCount * sizeof(PageGlyphEntry)));
if (!pageBuffer || !pageGlyphs) {
LOG_ERR("FDC", "Failed to allocate page buffer (%u bytes, %u glyphs)", totalBytes, glyphCount);
freePageBuffer();
return glyphCount;
}
stats.pageBufferBytes = totalBytes;
stats.pageGlyphsBytes = glyphCount * sizeof(PageGlyphEntry);
pageFont = fontData;
pageGlyphCount = glyphCount;
// Initialize lookup entries (bufferOffset = UINT32_MAX means not yet extracted)
for (uint16_t i = 0; i < glyphCount; i++) {
pageGlyphs[i] = {neededGlyphs[i], UINT32_MAX, 0};
}
// Sort by glyphIndex for binary search in getBitmap()
for (uint16_t i = 1; i < glyphCount; i++) {
PageGlyphEntry key = pageGlyphs[i];
int j = i - 1;
while (j >= 0 && pageGlyphs[j].glyphIndex > key.glyphIndex) {
pageGlyphs[j + 1] = pageGlyphs[j];
j--;
}
pageGlyphs[j + 1] = key;
}
// Step 3b: Pre-scan to compute each needed glyph's byte-aligned offset within its group.
// This avoids recomputing aligned offsets per group during extraction in step 4.
uint32_t groupAlignedTracker[128] = {}; // running byte-aligned offset for each needed group
if (fontData->glyphToGroup) {
// Frequency-grouped: single O(totalGlyphs) pass through glyphToGroup
const auto& lastInterval = fontData->intervals[fontData->intervalCount - 1];
const uint32_t totalGlyphs = lastInterval.offset + (lastInterval.last - lastInterval.first + 1);
for (uint32_t i = 0; i < totalGlyphs; i++) {
const uint16_t gi = fontData->glyphToGroup[i];
// Find this glyph's group position in neededGroups
uint8_t gpPos = groupCount;
for (uint8_t j = 0; j < groupCount; j++) {
if (neededGroups[j] == gi) {
gpPos = j;
break;
}
}
if (gpPos == groupCount) continue; // not a needed group
const EpdGlyph& glyph = fontData->glyph[i];
// Binary search in sorted pageGlyphs to find if glyph i is needed
int left = 0, right = (int)pageGlyphCount - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
if (pageGlyphs[mid].glyphIndex == i) {
pageGlyphs[mid].alignedOffset = groupAlignedTracker[gpPos];
break;
}
if (pageGlyphs[mid].glyphIndex < i)
left = mid + 1;
else
right = mid - 1;
}
if (glyph.width > 0 && glyph.height > 0) {
groupAlignedTracker[gpPos] += ((glyph.width + 3) / 4) * glyph.height;
}
}
} else {
// Contiguous-group: iterate each needed group's glyphs directly
for (uint8_t g = 0; g < groupCount; g++) {
const EpdFontGroup& group = fontData->groups[neededGroups[g]];
uint32_t alignedOff = 0;
for (uint16_t j = 0; j < group.glyphCount; j++) {
const uint32_t glyphI = group.firstGlyphIndex + j;
const EpdGlyph& glyph = fontData->glyph[glyphI];
int left = 0, right = (int)pageGlyphCount - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
if (pageGlyphs[mid].glyphIndex == glyphI) {
pageGlyphs[mid].alignedOffset = alignedOff;
break;
}
if (pageGlyphs[mid].glyphIndex < glyphI)
left = mid + 1;
else
right = mid - 1;
}
if (glyph.width > 0 && glyph.height > 0) {
alignedOff += ((glyph.width + 3) / 4) * glyph.height;
}
}
}
}
// Step 4: For each unique group, decompress to temp buffer and extract needed glyphs
uint32_t writeOffset = 0;
int missed = 0;
for (uint8_t g = 0; g < groupCount; g++) {
uint16_t groupIdx = neededGroups[g];
const EpdFontGroup& group = fontData->groups[groupIdx];
auto* tempBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
if (!tempBuf) {
LOG_ERR("FDC", "Failed to allocate temp buffer (%u bytes) for group %u", group.uncompressedSize, groupIdx);
missed++;
continue;
}
if (group.uncompressedSize > stats.peakTempBytes) {
stats.peakTempBytes = group.uncompressedSize;
}
if (!decompressGroup(fontData, groupIdx, tempBuf, group.uncompressedSize)) {
free(tempBuf);
missed++;
continue;
}
// Extract needed glyphs directly from the byte-aligned temp buffer, compacting on the fly.
// alignedOffset was pre-computed in step 3b — no full-group compact scan needed.
for (uint16_t i = 0; i < pageGlyphCount; i++) {
if (pageGlyphs[i].bufferOffset != UINT32_MAX) continue; // already extracted
if (getGroupIndex(fontData, pageGlyphs[i].glyphIndex) != groupIdx) continue;
const EpdGlyph& glyph = fontData->glyph[pageGlyphs[i].glyphIndex];
compactSingleGlyph(&tempBuf[pageGlyphs[i].alignedOffset], &pageBuffer[writeOffset], glyph.width, glyph.height);
pageGlyphs[i].bufferOffset = writeOffset;
writeOffset += glyph.dataLength;
}
free(tempBuf);
}
LOG_DBG("FDC", "Prewarm: %u glyphs in %u bytes from %u groups (%d missed)", glyphCount, writeOffset, groupCount,
missed);
return missed;
}
// --- Stats ---
void FontDecompressor::resetStats() { stats = Stats{}; }
void FontDecompressor::logStats(const char* label) {
const uint32_t total = stats.cacheHits + stats.cacheMisses;
LOG_DBG("FDC", "[%s] hits=%lu misses=%lu (%.1f%% hit rate)", label, stats.cacheHits, stats.cacheMisses,
total > 0 ? 100.0f * stats.cacheHits / total : 0.0f);
LOG_DBG("FDC", "[%s] decompress=%lums groups_accessed=%u", label, stats.decompressTimeMs, stats.uniqueGroupsAccessed);
LOG_DBG("FDC", "[%s] mem: pageBuf=%lu pageGlyphs=%lu hotGroup=%lu peakTemp=%lu", label, stats.pageBufferBytes,
stats.pageGlyphsBytes, stats.hotGroupBytes, stats.peakTempBytes);
if (stats.getBitmapCalls > 0) {
LOG_DBG("FDC", "[%s] getBitmap: %lu calls, %luus total, %luus/call avg", label, stats.getBitmapCalls,
stats.getBitmapTimeUs, stats.getBitmapTimeUs / stats.getBitmapCalls);
}
resetStats();
}
+60 -21
View File
@@ -2,39 +2,78 @@
#include <InflateReader.h>
#include <vector>
#include "EpdFontData.h"
class FontDecompressor {
public:
static constexpr uint16_t MAX_PAGE_GLYPHS = 512;
FontDecompressor() = default;
~FontDecompressor();
bool init();
void deinit();
// Returns pointer to decompressed bitmap data for the given glyph.
// Valid until LRU eviction (safe for the duration of one glyph render).
const uint8_t* getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex);
// Checks the page buffer (from prewarm) first, then falls back to the hot group slot.
const uint8_t* getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint32_t glyphIndex);
// Evict all cached decompressed groups (call between pages for within-page-only caching).
// Free all cached data (page buffer + hot group).
void clearCache();
// Pre-scan UTF-8 text and extract needed glyph bitmaps into a flat page buffer.
// Each group is decompressed once into a temp buffer; only needed glyphs are kept.
// Returns the number of glyphs that couldn't be loaded (0 on full success).
int prewarmCache(const EpdFontData* fontData, const char* utf8Text);
struct Stats {
uint32_t cacheHits = 0;
uint32_t cacheMisses = 0;
uint32_t decompressTimeMs = 0;
uint16_t uniqueGroupsAccessed = 0;
uint32_t pageBufferBytes = 0; // pageBuffer allocation
uint32_t pageGlyphsBytes = 0; // pageGlyphs lookup table allocation
uint32_t hotGroupBytes = 0; // current hot group allocation
uint32_t peakTempBytes = 0; // largest temp buffer in prewarm
uint32_t getBitmapTimeUs = 0; // cumulative getBitmap time (micros)
uint32_t getBitmapCalls = 0; // number of getBitmap calls
};
void logStats(const char* label = "FDC");
void resetStats();
const Stats& getStats() const { return stats; }
private:
static constexpr uint8_t CACHE_SLOTS = 4;
struct CacheEntry {
const EpdFontData* font = nullptr;
uint16_t groupIndex = 0;
uint8_t* data = nullptr;
uint32_t dataSize = 0;
uint32_t lastUsed = 0;
bool valid = false;
};
Stats stats;
InflateReader inflateReader;
CacheEntry cache[CACHE_SLOTS] = {};
uint32_t accessCounter = 0;
void freeAllEntries();
uint16_t getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex);
CacheEntry* findInCache(const EpdFontData* fontData, uint16_t groupIndex);
CacheEntry* findEvictionCandidate();
bool decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry);
// Page buffer: flat array of prewarmed glyph bitmaps with sorted lookup
struct PageGlyphEntry {
uint32_t glyphIndex;
uint32_t bufferOffset;
uint32_t alignedOffset; // byte-aligned offset within its decompressed group (set during prewarm pre-scan)
};
uint8_t* pageBuffer = nullptr;
const EpdFontData* pageFont = nullptr;
PageGlyphEntry* pageGlyphs = nullptr;
uint16_t pageGlyphCount = 0;
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
const EpdFontData* hotGroupFont = nullptr;
uint16_t hotGroupIndex = UINT16_MAX;
std::vector<uint8_t> hotGroup;
// Scratch buffer for compacting a single glyph from the hot group.
// Valid until the next getBitmap() call.
std::vector<uint8_t> hotGlyphBuf;
void freePageBuffer();
void freeHotGroup();
uint16_t getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex);
uint32_t getAlignedOffset(const EpdFontData* fontData, uint16_t groupIndex, uint32_t glyphIndex);
bool decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, uint8_t* outBuf, uint32_t outSize);
static void compactSingleGlyph(const uint8_t* alignedSrc, uint8_t* packedDst, uint8_t width, uint8_t height);
static int32_t findGlyphIndex(const EpdFontData* fontData, uint32_t codepoint);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit --compress --force-autohint > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
echo "Generated $output_path"
done
done
+77 -14
View File
@@ -137,6 +137,33 @@ def norm_floor(val):
def norm_ceil(val):
return int(math.ceil(val / (1 << 6)))
# Fixed-point (fp4) output conventions (must match EpdFontData.h / fp4 namespace):
#
# advanceX 12.4 unsigned fixed-point (uint16_t).
# 12 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Encoded from FreeType's 16.16 linearHoriAdvance.
#
# kernMatrix 4.4 signed fixed-point (int8_t).
# 4 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Range: -8.0 to +7.9375 pixels.
# Encoded from font design-unit kerning values.
#
# Both share 4 fractional bits so the renderer can add them directly into a
# single int32_t accumulator and defer rounding until pixel placement.
def fp4_from_ft16_16(val):
"""Convert FreeType 16.16 fixed-point to 12.4 fixed-point with rounding."""
return (val + (1 << 11)) >> 12
def fp4_from_design_units(du, scale):
"""Convert a font design-unit value to 4.4 fixed-point, clamped to int8_t.
Multiplies by scale (ppem / units_per_em) and shifts into 4 fractional
bits. The result is rounded to nearest and clamped to [-128, 127].
"""
raw = round(du * scale * 16)
return max(-128, min(127, raw))
def chunks(l, n):
for i in range(0, len(l), n):
yield l[i:i + n]
@@ -270,7 +297,9 @@ for i_start, i_end in intervals:
glyph = GlyphProps(
width = bitmap.width,
height = bitmap.rows,
advance_x = norm_floor(face.glyph.advance.x),
# We use linearHoriAdvance (16.16 fixed-point, unhinted) instead of
# advance.x (26.6 fixed-point, grid-fitted to whole pixels by hinter)
advance_x = fp4_from_ft16_16(face.glyph.linearHoriAdvance),
left = face.glyph.bitmap_left,
top = face.glyph.bitmap_top,
data_length = len(packed),
@@ -406,15 +435,14 @@ def extract_kerning_fonttools(font_path, codepoints, ppem):
font.close()
# Scale design-unit values to pixels
# Scale design-unit kerning values to 4.4 fixed-point pixels.
scale = ppem / units_per_em
result = {} # (leftCp, rightCp) -> adjust
result = {} # (leftCp, rightCp) -> 4.4 fixed-point adjust
for (lg, rg), du in raw_kern.items():
lcp = glyph_to_cp[lg]
rcp = glyph_to_cp[rg]
adjust = int(math.floor(du * scale))
adjust = fp4_from_design_units(du, scale)
if adjust != 0:
adjust = max(-128, min(127, adjust))
result[(lcp, rcp)] = adjust
return result
@@ -661,7 +689,38 @@ print(f"ligatures: {len(ligature_pairs)} pairs extracted", file=sys.stderr)
compress = args.compress
def to_byte_aligned(packed, width, height):
"""Convert packed 2-bit bitmap to byte-aligned format (rows padded to byte boundary).
In packed format, pixels flow continuously across row boundaries (4 pixels/byte).
In byte-aligned format, each row starts at a byte boundary, padding the last byte
of each row with zero bits if width % 4 != 0. This improves DEFLATE compression
because identical pixel rows produce identical byte patterns regardless of position.
"""
if width == 0 or height == 0:
return b''
row_stride = (width + 3) // 4 # bytes per byte-aligned row
aligned = bytearray(row_stride * height)
for y in range(height):
for x in range(width):
# Read pixel from packed format (continuous bit stream)
packed_pos = y * width + x
packed_byte_idx = packed_pos // 4
packed_shift = (3 - (packed_pos % 4)) * 2
pixel = (packed[packed_byte_idx] >> packed_shift) & 0x3
# Write pixel to byte-aligned format (row-aligned)
aligned_byte_idx = y * row_stride + x // 4
aligned_shift = (3 - (x % 4)) * 2
aligned[aligned_byte_idx] |= (pixel << aligned_shift)
return bytes(aligned)
# Build groups for compression
if compress and not is2Bit:
print("Error: --compress requires --2bit (byte-aligned compression only supports 2-bit format)", file=sys.stderr)
sys.exit(1)
if compress:
# Script-based grouping: glyphs that co-occur in typical text rendering
# are grouped together for efficient LRU caching on the embedded target.
@@ -719,11 +778,12 @@ if compress:
for first_idx, count in groups:
# Concatenate bitmap data for this group
group_data = b''
packed_len = 0
group_aligned = bytearray()
for gi in range(first_idx, first_idx + count):
props, packed = all_glyphs[gi]
# Update glyph's dataOffset to be within-group offset
within_group_offset = len(group_data)
# Update glyph's dataOffset to be within-group offset (packed offset)
within_group_offset = packed_len
old_props = modified_glyph_props[gi]
modified_glyph_props[gi] = GlyphProps(
width=old_props.width,
@@ -735,13 +795,14 @@ if compress:
data_offset=within_group_offset,
code_point=old_props.code_point,
)
group_data += packed
packed_len += len(packed)
group_aligned.extend(to_byte_aligned(packed, old_props.width, old_props.height))
# Compress with raw DEFLATE (no zlib/gzip header)
# Compress byte-aligned data with raw DEFLATE (no zlib/gzip header)
compressor = zlib.compressobj(level=9, wbits=-15)
compressed = compressor.compress(group_data) + compressor.flush()
compressed = compressor.compress(bytes(group_aligned)) + compressor.flush()
compressed_groups.append((compressed, len(group_data), count, first_idx))
compressed_groups.append((compressed, len(group_aligned), count, first_idx))
compressed_bitmap_data.extend(compressed)
compressed_offset += len(compressed)
@@ -834,8 +895,10 @@ if compress:
print(f" {font_name}Groups,")
print(f" {len(compressed_groups)},")
else:
print(f" nullptr,")
print(f" 0,")
print(" nullptr,")
print(" 0,")
# glyphToGroup (not used for script-grouped fonts)
print(" nullptr,")
if kern_map:
print(f" {font_name}KernLeftClasses,")
print(f" {font_name}KernRightClasses,")
+118 -14
View File
@@ -3,9 +3,13 @@
Round-trip verification for compressed font headers.
Parses each generated .h file in the given directory, identifies compressed fonts
(those with a Groups array), decompresses each group, and verifies that
decompression succeeds and all glyph offsets/lengths fall within bounds.
(those with a Groups array), decompresses each group (byte-aligned bitmap format),
compacts to packed format, and verifies the data matches expected glyph sizes.
Supports both contiguous-group fonts (Latin) and frequency-grouped fonts (CJK)
with glyphToGroup mapping arrays.
"""
import math
import os
import re
import sys
@@ -18,6 +22,11 @@ def parse_hex_array(text):
return bytes(int(h, 16) for h in hex_vals)
def parse_uint8_array(text):
"""Extract uint8/uint16 values from a C array string like '{ 0, 1, 0xFF, ... }'"""
return [int(v, 0) for v in re.findall(r'\b0x[0-9A-Fa-f]+\b|\b\d+\b', text)]
def parse_groups(text):
"""Parse EpdFontGroup array entries: { compressedOffset, compressedSize, uncompressedSize, glyphCount, firstGlyphIndex }"""
groups = []
@@ -48,6 +57,45 @@ def parse_glyphs(text):
return glyphs
def get_group_glyph_indices(group, group_index, glyphs, glyph_to_group):
"""Get the ordered list of glyph indices belonging to a group."""
if glyph_to_group is not None:
# Frequency-grouped: scan all glyphs
return [i for i in range(len(glyphs)) if glyph_to_group[i] == group_index]
else:
# Contiguous: sequential from firstGlyphIndex
first = group['firstGlyphIndex']
return list(range(first, first + group['glyphCount']))
def compact_aligned_to_packed(aligned_data, width, height):
"""Convert byte-aligned 2-bit bitmap to packed format (reverse of to_byte_aligned).
In byte-aligned format, each row starts at a byte boundary.
In packed format, pixels flow continuously across row boundaries (4 pixels/byte).
"""
if width == 0 or height == 0:
return b''
packed_size = math.ceil(width * height / 4)
packed = bytearray(packed_size)
row_stride = (width + 3) // 4 # bytes per byte-aligned row
for y in range(height):
for x in range(width):
# Read pixel from byte-aligned format (row-aligned)
aligned_byte_idx = y * row_stride + x // 4
aligned_shift = (3 - (x % 4)) * 2
pixel = (aligned_data[aligned_byte_idx] >> aligned_shift) & 0x3
# Write pixel to packed format (continuous bit stream)
packed_pos = y * width + x
packed_byte_idx = packed_pos // 4
packed_shift = (3 - (packed_pos % 4)) * 2
packed[packed_byte_idx] |= (pixel << packed_shift)
return bytes(packed)
def verify_font_file(filepath):
"""Verify a single font header file. Returns (font_name, success, message)."""
with open(filepath, 'r') as f:
@@ -92,6 +140,20 @@ def verify_font_file(filepath):
glyphs = parse_glyphs(glyphs_match.group(1))
# Check for glyphToGroup array (frequency-grouped fonts)
glyph_to_group = None
g2g_match = re.search(
r'static const uint16_t ' + re.escape(font_name) + r'GlyphToGroup\[\]\s*=\s*\{(.+?)\};',
content, re.DOTALL
)
if g2g_match:
glyph_to_group = parse_uint8_array(g2g_match.group(1))
if len(glyph_to_group) != len(glyphs):
return (font_name, False, f"glyphToGroup length ({len(glyph_to_group)}) != glyph count ({len(glyphs)})")
max_group_id = max(glyph_to_group)
if max_group_id >= len(groups):
return (font_name, False, f"glyphToGroup contains group ID {max_group_id} but only {len(groups)} groups exist")
# Verify each group
for gi, group in enumerate(groups):
# Extract compressed chunk
@@ -99,7 +161,7 @@ def verify_font_file(filepath):
if len(chunk) != group['compressedSize']:
return (font_name, False, f"group {gi}: compressed data truncated (expected {group['compressedSize']}, got {len(chunk)})")
# Decompress with raw DEFLATE
# Decompress with raw DEFLATE — result is byte-aligned data
try:
decompressed = zlib.decompress(chunk, -15)
except zlib.error as e:
@@ -108,22 +170,64 @@ def verify_font_file(filepath):
if len(decompressed) != group['uncompressedSize']:
return (font_name, False, f"group {gi}: size mismatch (expected {group['uncompressedSize']}, got {len(decompressed)})")
# Verify each glyph's data within the group
first = group['firstGlyphIndex']
for j in range(group['glyphCount']):
glyph_idx = first + j
# Get glyph indices for this group
group_glyph_indices = get_group_glyph_indices(group, gi, glyphs, glyph_to_group)
if glyph_to_group is not None and len(group_glyph_indices) != group['glyphCount']:
return (font_name, False,
f"group {gi}: glyphCount {group['glyphCount']} != mapping count {len(group_glyph_indices)}")
# Walk through byte-aligned data, compact each glyph, and verify against packed format
byte_aligned_offset = 0
packed_offset = 0
for glyph_idx in group_glyph_indices:
if glyph_idx >= len(glyphs):
return (font_name, False, f"group {gi}: glyph index {glyph_idx} out of range")
glyph = glyphs[glyph_idx]
offset = glyph['dataOffset']
length = glyph['dataLength']
width = glyph['width']
height = glyph['height']
if offset + length > len(decompressed):
return (font_name, False, f"group {gi}, glyph {glyph_idx}: data extends beyond decompressed buffer "
f"(offset={offset}, length={length}, decompressed_size={len(decompressed)})")
if width == 0 or height == 0:
# Zero-size glyphs should have dataOffset == current packed_offset and dataLength == 0
if glyph['dataOffset'] != packed_offset:
return (font_name, False, f"group {gi}, glyph {glyph_idx}: zero-size glyph dataOffset {glyph['dataOffset']} != expected packed offset {packed_offset}")
if glyph['dataLength'] != 0:
return (font_name, False, f"group {gi}, glyph {glyph_idx}: zero-size glyph dataLength {glyph['dataLength']} != expected 0")
continue
return (font_name, True, f"{len(groups)} groups, {len(glyphs)} glyphs OK")
aligned_size = ((width + 3) // 4) * height
packed_size = math.ceil(width * height / 4)
# Verify packed offset and size match glyph metadata
if glyph['dataOffset'] != packed_offset:
return (font_name, False, f"group {gi}, glyph {glyph_idx}: dataOffset {glyph['dataOffset']} != expected packed offset {packed_offset}")
if glyph['dataLength'] != packed_size:
return (font_name, False, f"group {gi}, glyph {glyph_idx}: dataLength {glyph['dataLength']} != expected packed length {packed_size} "
f"(width={width}, height={height})")
# Extract byte-aligned data for this glyph
if byte_aligned_offset + aligned_size > len(decompressed):
return (font_name, False, f"group {gi}, glyph {glyph_idx}: byte-aligned data extends beyond decompressed buffer "
f"(offset={byte_aligned_offset}, size={aligned_size}, buf_size={len(decompressed)})")
aligned_glyph = decompressed[byte_aligned_offset:byte_aligned_offset + aligned_size]
# Compact to packed and verify pixel values are valid (0-3 for 2-bit)
packed_glyph = compact_aligned_to_packed(aligned_glyph, width, height)
if len(packed_glyph) != packed_size:
return (font_name, False, f"group {gi}, glyph {glyph_idx}: compacted size {len(packed_glyph)} != expected {packed_size}")
byte_aligned_offset += aligned_size
packed_offset += packed_size
# Verify total byte-aligned size matches uncompressedSize
if byte_aligned_offset != group['uncompressedSize']:
return (font_name, False, f"group {gi}: total byte-aligned size {byte_aligned_offset} != uncompressedSize {group['uncompressedSize']}")
extra_info = ""
if glyph_to_group is not None:
extra_info = " (frequency-grouped)"
return (font_name, True, f"{len(groups)} groups, {len(glyphs)} glyphs OK{extra_info}")
def main():
+6 -11
View File
@@ -103,16 +103,13 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
pos += strlen(pattern);
const auto endPos = coverPageHtml.find('"', pos);
if (endPos != std::string::npos) {
const auto ref = coverPageHtml.substr(pos, endPos - pos);
const auto ref = std::string_view{coverPageHtml}.substr(pos, endPos - pos);
// Check if it's an image file
if (ref.length() >= 4) {
const auto ext = ref.substr(ref.length() - 4);
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
if (FsHelpers::hasPngExtension(ref) || FsHelpers::hasJpgExtension(ref) || FsHelpers::hasGifExtension(ref)) {
imageRef = ref;
break;
}
}
}
pos = coverPageHtml.find(pattern, pos);
}
if (!imageRef.empty()) break;
@@ -541,8 +538,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@@ -575,7 +571,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return success;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
@@ -629,8 +625,7 @@ bool Epub::generateThumbBmp(int height) const {
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
LOG_DBG("EBP", "No known cover image for thumbnail");
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@@ -666,7 +661,7 @@ bool Epub::generateThumbBmp(int height) const {
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
+6 -4
View File
@@ -274,11 +274,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
bool BookMetadataCache::cleanupTmpFiles() const {
if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
Storage.remove((cachePath + tmpSpineBinFile).c_str());
const auto spineBinFile = cachePath + tmpSpineBinFile;
if (Storage.exists(spineBinFile.c_str())) {
Storage.remove(spineBinFile.c_str());
}
if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
Storage.remove((cachePath + tmpTocBinFile).c_str());
const auto tocBinFile = cachePath + tmpTocBinFile;
if (Storage.exists(tocBinFile.c_str())) {
Storage.remove(tocBinFile.c_str());
}
return true;
}
+1
View File
@@ -2,6 +2,7 @@
#include <HalStorage.h>
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
+34 -32
View File
@@ -101,20 +101,19 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
applyParagraphIndent();
const int pageWidth = viewportWidth;
const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR);
auto wordWidths = calculateWordWidths(renderer, fontId);
std::vector<size_t> lineBreakIndices;
if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
}
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
}
// Remove consumed words so size() reflects only remaining words
@@ -138,15 +137,17 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
}
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
const int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) {
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
if (words.empty()) {
return {};
}
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -184,9 +185,8 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// Add space before word j, unless it's the first word on the line or a continuation
int gap = 0;
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
gap = spaceWidth;
gap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]),
wordStyles[j - 1]);
gap =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
@@ -272,12 +272,14 @@ void ParsedText::applyParagraphIndent() {
// Builds break indices while opportunistically splitting the word that would overflow the current line.
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
const int pageWidth, const int spaceWidth,
std::vector<uint16_t>& wordWidths,
const int pageWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) {
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const int firstLineIndent =
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -298,8 +300,7 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const bool isFirstWord = currentIndex == lineStart;
int spacing = 0;
if (!isFirstWord && !continuesVec[currentIndex]) {
spacing = spaceWidth;
spacing += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[currentIndex - 1]),
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
} else if (!isFirstWord && continuesVec[currentIndex]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
@@ -434,19 +435,21 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
return true;
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths, const std::vector<bool>& continuesVec,
const std::vector<size_t>& lineBreakIndices,
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::function<void(std::shared_ptr<TextBlock>)>& processLine,
const GfxRenderer& renderer, const int fontId) {
const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt;
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
// Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
// it is structural (positions the bullet/marker), not decorative.
const bool isFirstLine = breakIndex == 0;
const int firstLineIndent =
isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
isFirstLine && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
? blockStyle.textIndent
: 0;
@@ -462,11 +465,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Count gaps: each word after the first creates a gap, unless it's a continuation
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
actualGapCount++;
int naturalGap = spaceWidth;
naturalGap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]),
wordStyles[lastBreakAt + wordIdx - 1]);
totalNaturalGaps += naturalGap;
totalNaturalGaps +=
renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
} else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
totalNaturalGaps +=
@@ -485,8 +486,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
? spareSpace / static_cast<int>(actualGapCount)
: 0;
// Calculate initial x position (first line starts at indent for left/justified text)
auto xpos = static_cast<uint16_t>(firstLineIndent);
// Calculate initial x position (first line starts at indent for left/justified text;
// may be negative for hanging indents, e.g. margin-left:3em; text-indent:-1em).
auto xpos = static_cast<int16_t>(firstLineIndent);
if (blockStyle.alignment == CssTextAlign::Right) {
xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
} else if (blockStyle.alignment == CssTextAlign::Center) {
@@ -495,7 +497,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Pre-calculate X positions for words
// Continuation words attach to the previous word with no space before them
std::vector<uint16_t> lineXPos;
std::vector<int16_t> lineXPos;
lineXPos.reserve(lineWordCount);
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
@@ -510,9 +512,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]);
xpos += advance;
} else {
int gap = spaceWidth;
int gap = 0;
if (wordIdx + 1 < lineWordCount) {
gap += renderer.getSpaceKernAdjust(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
firstCodepoint(words[lastBreakAt + wordIdx + 1]),
wordStyles[lastBreakAt + wordIdx]);
}
+3 -4
View File
@@ -21,14 +21,13 @@ class ParsedText {
bool hyphenationEnabled;
void applyParagraphIndent();
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, int spaceWidth,
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, 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::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
int fontId);
+63 -14
View File
@@ -10,10 +10,10 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 14;
constexpr uint8_t SECTION_FILE_VERSION = 18;
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(uint32_t);
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t);
} // namespace
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
@@ -36,7 +36,7 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool embeddedStyle) {
const bool embeddedStyle, const uint8_t imageRendering) {
if (!file) {
LOG_DBG("SCT", "File not open for writing header");
return;
@@ -44,7 +44,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(uint32_t),
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -55,13 +55,16 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, viewportHeight);
serialization::writePod(file, hyphenationEnabled);
serialization::writePod(file, embeddedStyle);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
serialization::writePod(file, imageRendering);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
}
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) {
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -84,6 +87,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
uint8_t fileParagraphAlignment;
bool fileHyphenationEnabled;
bool fileEmbeddedStyle;
uint8_t fileImageRendering;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
@@ -92,11 +96,13 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, fileViewportHeight);
serialization::readPod(file, fileHyphenationEnabled);
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileImageRendering);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering) {
file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache();
@@ -129,7 +135,7 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const std::function<void()>& popupFn) {
const uint8_t imageRendering, const std::function<void()>& popupFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@@ -179,7 +185,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle);
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
std::vector<uint32_t> lut = {};
// Derive the content base directory and image cache path prefix for the parser
@@ -201,7 +207,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -234,10 +240,20 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
// Go back and write LUT offset
file.seek(HEADER_SIZE - sizeof(uint32_t) - sizeof(pageCount));
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
const uint32_t anchorMapOffset = file.position();
const auto& anchors = visitor.getAnchors();
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
for (const auto& [anchor, page] : anchors) {
serialization::writeString(file, anchor);
serialization::writePod(file, page);
}
// Patch header with final pageCount, lutOffset, and anchorMapOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount));
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
file.close();
if (cssParser) {
cssParser->clear();
@@ -250,7 +266,7 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return nullptr;
}
file.seek(HEADER_SIZE - sizeof(uint32_t));
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
@@ -262,3 +278,36 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
file.close();
return page;
}
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
FsFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
f.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
f.close();
return std::nullopt;
}
f.seek(anchorMapOffset);
uint16_t count;
serialization::readPod(f, count);
for (uint16_t i = 0; i < count; i++) {
std::string key;
uint16_t page;
serialization::readString(f, key);
serialization::readPod(f, page);
if (key == anchor) {
f.close();
return page;
}
}
f.close();
return std::nullopt;
}
+9 -3
View File
@@ -1,6 +1,8 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include "Epub.h"
@@ -16,7 +18,7 @@ class Section {
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
bool embeddedStyle);
bool embeddedStyle, uint8_t imageRendering);
uint32_t onPageComplete(std::unique_ptr<Page> page);
public:
@@ -30,10 +32,14 @@ class Section {
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default;
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle);
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering);
bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
const std::function<void()>& popupFn = nullptr);
uint8_t imageRendering, const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
};
+1 -1
View File
@@ -74,7 +74,7 @@ bool TextBlock::serialize(FsFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint16_t wc;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
+2 -2
View File
@@ -13,12 +13,12 @@
class TextBlock final : public Block {
private:
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
public:
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
@@ -1,45 +1,360 @@
#include "JpegToFramebufferConverter.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <JPEGDEC.h>
#include <Logging.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <new>
#include "DitherUtils.h"
#include "PixelCache.h"
namespace {
// Context struct passed through JPEGDEC callbacks to avoid global mutable state.
// The draw callback receives this via pDraw->pUser (set by setUserPointer()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()).
struct JpegContext {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
// Source dimensions after JPEGDEC's built-in scaling
int scaledSrcWidth;
int scaledSrcHeight;
// Final output dimensions
int dstWidth;
int dstHeight;
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU)
int32_t fineScaleFP; // src -> dst mapping
int32_t invScaleFP; // dst -> src mapping
PixelCache cache;
bool caching;
JpegContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scaledSrcWidth(0),
scaledSrcHeight(0),
dstWidth(0),
dstHeight(0),
fineScaleFP(1 << 16),
invScaleFP(1 << 16),
caching(false) {}
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
void* jpegOpen(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
if (!Storage.openFileForRead("JPG", std::string(filename), *f)) {
delete f;
return nullptr;
}
*size = f->size();
return f;
}
void jpegClose(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
if (f) {
f->close();
delete f;
}
}
// JPEGDEC tracks file position via pFile->iPos internally (e.g. JPEGGetMoreData
// checks iPos < iSize to decide whether more data is available). The callbacks
// MUST maintain iPos to match the actual file position, otherwise progressive
// JPEGs with large headers fail during parsing.
int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
int32_t bytesRead = f->read(pBuf, len);
if (bytesRead < 0) return 0;
pFile->iPos += bytesRead;
return bytesRead;
}
int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return -1;
if (!f->seek(pos)) return -1;
pFile->iPos = pos;
return pos;
}
// JPEGDEC object is ~17 KB due to internal decode buffers.
// Heap-allocate on demand so memory is only used during active decode.
constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024;
constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024;
// Choose JPEGDEC's built-in scale factor for coarse downscaling.
// Returns the scale denominator (1, 2, 4, or 8) and sets jpegScaleOption.
int chooseJpegScale(float targetScale, int& jpegScaleOption) {
if (targetScale <= 0.125f) {
jpegScaleOption = JPEG_SCALE_EIGHTH;
return 8;
}
if (targetScale <= 0.25f) {
jpegScaleOption = JPEG_SCALE_QUARTER;
return 4;
}
if (targetScale <= 0.5f) {
jpegScaleOption = JPEG_SCALE_HALF;
return 2;
}
jpegScaleOption = 0;
return 1;
}
// Fixed-point 16.16 arithmetic avoids software float emulation on ESP32-C3 (no FPU).
constexpr int FP_SHIFT = 16;
constexpr int32_t FP_ONE = 1 << FP_SHIFT;
constexpr int32_t FP_MASK = FP_ONE - 1;
int jpegDrawCallback(JPEGDRAW* pDraw) {
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0;
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
const int stride = pDraw->iWidth;
const int validW = pDraw->iWidthUsed;
const int blockH = pDraw->iHeight;
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
const int32_t fineScaleFP = ctx->fineScaleFP;
const int32_t invScaleFP = ctx->invScaleFP;
GfxRenderer& renderer = *ctx->renderer;
const int cfgX = ctx->config->x;
const int cfgY = ctx->config->y;
const int blockX = pDraw->x;
const int blockY = pDraw->y;
// Determine destination pixel range covered by this source block
const int srcYEnd = blockY + blockH;
const int srcXEnd = blockX + validW;
int dstYStart = (int)((int64_t)blockY * fineScaleFP >> FP_SHIFT);
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFP >> FP_SHIFT);
int dstXStart = (int)((int64_t)blockX * fineScaleFP >> FP_SHIFT);
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFP >> FP_SHIFT);
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
int clampYMax = ctx->dstHeight;
if (ctx->screenHeight - cfgY < clampYMax) clampYMax = ctx->screenHeight - cfgY;
if (dstYStart < -cfgY) dstYStart = -cfgY;
if (dstYEnd > clampYMax) dstYEnd = clampYMax;
int clampXMax = ctx->dstWidth;
if (ctx->screenWidth - cfgX < clampXMax) clampXMax = ctx->screenWidth - cfgX;
if (dstXStart < -cfgX) dstXStart = -cfgX;
if (dstXEnd > clampXMax) dstXEnd = clampXMax;
if (dstYStart >= dstYEnd || dstXStart >= dstXEnd) return 1;
// === 1:1 fast path: no scaling math ===
if (fineScaleFP == FP_ONE) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const uint8_t* row = &pixels[(dstY - blockY) * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
uint8_t gray = row[dstX - blockX];
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
// Smooths block boundaries that would otherwise create visible banding
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
if (fineScaleFP > FP_ONE) {
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
int safeXStart = (int)(((int64_t)blockX * fineScaleFP + FP_MASK) >> FP_SHIFT);
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFP >> FP_SHIFT);
if (safeXStart < dstXStart) safeXStart = dstXStart;
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const int32_t srcFyFP = dstY * invScaleFP;
const int32_t fy = srcFyFP & FP_MASK;
const int32_t fyInv = FP_ONE - fy;
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
int ly1 = ly0 + 1;
if (ly0 < 0) ly0 = 0;
if (ly0 >= blockH) ly0 = blockH - 1;
if (ly1 >= blockH) ly1 = blockH - 1;
const uint8_t* row0 = &pixels[ly0 * stride];
const uint8_t* row1 = &pixels[ly1 * stride];
// Left edge (with X boundary clamping)
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int lx1 = lx0 + 1;
if (lx0 < 0) lx0 = 0;
if (lx1 < 0) lx1 = 0;
if (lx0 >= validW) lx0 = validW - 1;
if (lx1 >= validW) lx1 = validW - 1;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx0 + 1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
// Right edge (with X boundary clamping)
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
const int32_t fx = srcFxFP & FP_MASK;
const int32_t fxInv = FP_ONE - fx;
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
int lx1 = lx0 + 1;
if (lx0 >= validW) lx0 = validW - 1;
if (lx1 >= validW) lx1 = validW - 1;
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
const int32_t srcFyFP = dstY * invScaleFP;
int ly = (srcFyFP >> FP_SHIFT) - blockY;
if (ly < 0) ly = 0;
if (ly >= blockH) ly = blockH - 1;
const uint8_t* row = &pixels[ly * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
const int32_t srcFxFP = dstX * invScaleFP;
int lx = (srcFxFP >> FP_SHIFT) - blockX;
if (lx < 0) lx = 0;
if (lx >= validW) lx = validW - 1;
uint8_t gray = row[lx];
uint8_t dithered;
if (useDithering) {
dithered = applyBayerDither4Level(gray, outX, outY);
} else {
dithered = gray / 85;
if (dithered > 3) dithered = 3;
}
drawPixelWithRenderMode(renderer, outX, outY, dithered);
if (caching) ctx->cache.setPixel(outX, outY, dithered);
}
}
return 1;
}
} // namespace
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
file.close();
if (status != 0) {
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
if (!jpeg) {
LOG_ERR("JPG", "Failed to allocate JPEG decoder for dimensions");
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, nullptr);
if (rc != 1) {
LOG_ERR("JPG", "Failed to open JPEG for dimensions (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
delete jpeg;
return false;
}
out.width = jpeg->getWidth();
out.height = jpeg->getHeight();
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
jpeg->close();
delete jpeg;
return true;
}
@@ -47,250 +362,130 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
const RenderConfig& config) {
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
LOG_ERR("JPG", "picojpeg init failed: %d", status);
file.close();
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
if (!jpeg) {
LOG_ERR("JPG", "Failed to allocate JPEG decoder");
return false;
}
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
file.close();
JpegContext ctx;
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback);
if (rc != 1) {
LOG_ERR("JPG", "Failed to open JPEG (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
delete jpeg;
return false;
}
// Calculate output dimensions
int srcWidth = jpeg->getWidth();
int srcHeight = jpeg->getHeight();
if (srcWidth <= 0 || srcHeight <= 0) {
LOG_ERR("JPG", "Invalid JPEG dimensions: %dx%d", srcWidth, srcHeight);
jpeg->close();
delete jpeg;
return false;
}
if (!validateImageDimensions(srcWidth, srcHeight, "JPEG")) {
jpeg->close();
delete jpeg;
return false;
}
bool isProgressive = jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE;
if (isProgressive) {
LOG_INF("JPG", "Progressive JPEG detected - decoding DC coefficients only (lower quality)");
}
// Calculate overall target scale
float targetScale;
int destWidth, destHeight;
float scale;
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
destWidth = config.maxWidth;
destHeight = config.maxHeight;
scale = (float)destWidth / imageInfo.m_width;
targetScale = (float)destWidth / srcWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
? (float)config.maxWidth / imageInfo.m_width
: 1.0f;
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
? (float)config.maxHeight / imageInfo.m_height
: 1.0f;
scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
float scaleX = (config.maxWidth > 0 && srcWidth > config.maxWidth) ? (float)config.maxWidth / srcWidth : 1.0f;
float scaleY = (config.maxHeight > 0 && srcHeight > config.maxHeight) ? (float)config.maxHeight / srcHeight : 1.0f;
targetScale = (scaleX < scaleY) ? scaleX : scaleY;
if (targetScale > 1.0f) targetScale = 1.0f;
destWidth = (int)(imageInfo.m_width * scale);
destHeight = (int)(imageInfo.m_height * scale);
destWidth = (int)(srcWidth * targetScale);
destHeight = (int)(srcHeight * targetScale);
}
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
file.close();
return false;
// Choose JPEGDEC built-in scaling for coarse downscaling.
// Progressive JPEGs: JPEGDEC forces JPEG_SCALE_EIGHTH internally (DC-only
// decode produces 1/8 resolution). We must match this to avoid the if/else
// priority chain in DecodeJPEG selecting a different scale.
int jpegScaleOption;
int jpegScaleDenom;
if (isProgressive) {
jpegScaleOption = JPEG_SCALE_EIGHTH;
jpegScaleDenom = 8;
} else {
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
}
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
ctx.dstWidth = destWidth;
ctx.dstHeight = destHeight;
ctx.fineScaleFP = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
ctx.invScaleFP = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
// Allocate pixel cache if cachePath is provided
PixelCache cache;
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
isProgressive ? " [progressive]" : "");
// Set pixel type to 8-bit grayscale (must be after open())
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
jpeg->setUserPointer(&ctx);
// Allocate cache buffer using final output dimensions
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
caching = false;
ctx.caching = false;
}
}
int mcuX = 0;
int mcuY = 0;
unsigned long decodeStart = millis();
rc = jpeg->decode(0, 0, jpegScaleOption);
unsigned long decodeTime = millis() - decodeStart;
while (mcuY < imageInfo.m_MCUSPerCol) {
status = pjpeg_decode_mcu();
if (status == PJPG_NO_MORE_BLOCKS) {
break;
}
if (status != 0) {
LOG_ERR("JPG", "MCU decode failed: %d", status);
file.close();
if (rc != 1) {
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
jpeg->close();
delete jpeg;
return false;
}
// Source position in image coordinates
int srcStartX = mcuX * imageInfo.m_MCUWidth;
int srcStartY = mcuY * imageInfo.m_MCUHeight;
switch (imageInfo.m_scanType) {
case PJPG_GRAYSCALE:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (col < 8) ? 0 : 1;
int pixelIndex = row * 8 + (col % 8);
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (row < 8) ? 0 : 1;
int pixelIndex = (row % 8) * 8 + col;
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockX = (col < 8) ? 0 : 1;
int blockY = (row < 8) ? 0 : 1;
int blockIndex = blockY * 2 + blockX;
int pixelIndex = (row % 8) * 8 + (col % 8);
int blockOffset = blockIndex * 64;
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
}
mcuX++;
if (mcuX >= imageInfo.m_MCUSPerRow) {
mcuX = 0;
mcuY++;
}
}
LOG_DBG("JPG", "Decoding complete");
file.close();
jpeg->close();
delete jpeg;
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled
if (caching) {
cache.writeToFile(config.cachePath);
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
}
return true;
}
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
if (context->bufferPos >= context->bufferFilled) {
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
if (readCount <= 0) {
*pBytes_actually_read = 0;
return 0;
}
context->bufferFilled = readCount;
context->bufferPos = 0;
}
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
context->bufferPos += bytesToCopy;
*pBytes_actually_read = bytesToCopy;
return 0;
}
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".jpg" || ext == ".jpeg");
return FsHelpers::hasJpgExtension(extension);
}
@@ -1,4 +1,5 @@
#pragma once
#include <stdint.h>
#include <string>
@@ -17,8 +18,4 @@ class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "JPEG"; }
private:
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
};
@@ -1,5 +1,6 @@
#include "PngToFramebufferConverter.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
@@ -391,9 +392,5 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".png");
return FsHelpers::hasPngExtension(extension);
}
@@ -107,6 +107,17 @@ bool isPunctuation(const uint32_t cp) {
bool isAsciiDigit(const uint32_t cp) { return cp >= '0' && cp <= '9'; }
bool isApostrophe(const uint32_t cp) {
switch (cp) {
case '\'':
case 0x2018: // left single quotation mark
case 0x2019: // right single quotation mark
return true;
default:
return false;
}
}
bool isExplicitHyphen(const uint32_t cp) {
switch (cp) {
case '-':
@@ -19,6 +19,7 @@ bool isCyrillicLetter(uint32_t cp);
bool isAlphabetic(uint32_t cp);
bool isPunctuation(uint32_t cp);
bool isAsciiDigit(uint32_t cp);
bool isApostrophe(uint32_t cp);
bool isExplicitHyphen(uint32_t cp);
bool isSoftHyphen(uint32_t cp);
void trimSurroundingPunctuationAndFootnote(std::vector<CodepointInfo>& cps);
+119 -20
View File
@@ -1,6 +1,7 @@
#include "Hyphenator.h"
#include <algorithm>
#include <cassert>
#include <vector>
#include "HyphenationCommon.h"
@@ -59,6 +60,94 @@ std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<Cod
return breaks;
}
bool isSegmentSeparator(const uint32_t cp) { return isExplicitHyphen(cp) || isApostrophe(cp); }
void appendSegmentPatternBreaks(const std::vector<CodepointInfo>& cps, const LanguageHyphenator& hyphenator,
const bool includeFallback, std::vector<Hyphenator::BreakInfo>& outBreaks) {
size_t segStart = 0;
for (size_t i = 0; i <= cps.size(); ++i) {
const bool atEnd = i == cps.size();
const bool atSeparator = !atEnd && isSegmentSeparator(cps[i].value);
if (!atEnd && !atSeparator) {
continue;
}
if (i > segStart) {
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
auto segIndexes = hyphenator.breakIndexes(segment);
if (includeFallback && segIndexes.empty()) {
const size_t minPrefix = hyphenator.minPrefix();
const size_t minSuffix = hyphenator.minSuffix();
for (size_t idx = minPrefix; idx + minSuffix <= segment.size(); ++idx) {
segIndexes.push_back(idx);
}
}
for (const size_t idx : segIndexes) {
assert(idx > 0 && idx < segment.size());
if (idx == 0 || idx >= segment.size()) continue;
const size_t cpIdx = segStart + idx;
if (cpIdx < cps.size()) {
outBreaks.push_back({cps[cpIdx].byteOffset, true});
}
}
}
segStart = i + 1;
}
}
void appendApostropheContractionBreaks(const std::vector<CodepointInfo>& cps,
std::vector<Hyphenator::BreakInfo>& outBreaks) {
constexpr size_t kMinLeftSegmentLen = 3;
constexpr size_t kMinRightSegmentLen = 3;
size_t segmentStart = 0;
for (size_t i = 0; i < cps.size(); ++i) {
if (isSegmentSeparator(cps[i].value)) {
if (isApostrophe(cps[i].value) && i > 0 && i + 1 < cps.size() && isAlphabetic(cps[i - 1].value) &&
isAlphabetic(cps[i + 1].value)) {
size_t leftPrefixLen = 0;
for (size_t j = segmentStart; j < i; ++j) {
if (isAlphabetic(cps[j].value)) {
++leftPrefixLen;
}
}
size_t rightSuffixLen = 0;
for (size_t j = i + 1; j < cps.size() && !isSegmentSeparator(cps[j].value); ++j) {
if (isAlphabetic(cps[j].value)) {
++rightSuffixLen;
}
}
// Avoid stranding short clitics like "l'"/"d'" or contraction tails like "'ve"/"'re"/"'ll".
if (leftPrefixLen >= kMinLeftSegmentLen && rightSuffixLen >= kMinRightSegmentLen) {
outBreaks.push_back({cps[i + 1].byteOffset, false});
}
}
segmentStart = i + 1;
}
}
}
void sortAndDedupeBreakInfos(std::vector<Hyphenator::BreakInfo>& infos) {
std::sort(infos.begin(), infos.end(), [](const Hyphenator::BreakInfo& a, const Hyphenator::BreakInfo& b) {
if (a.byteOffset != b.byteOffset) {
return a.byteOffset < b.byteOffset;
}
return a.requiresInsertedHyphen < b.requiresInsertedHyphen;
});
infos.erase(std::unique(infos.begin(), infos.end(),
[](const Hyphenator::BreakInfo& a, const Hyphenator::BreakInfo& b) {
return a.byteOffset == b.byteOffset;
}),
infos.end());
}
} // namespace
std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& word, const bool includeFallback) {
@@ -71,6 +160,15 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
trimSurroundingPunctuationAndFootnote(cps);
const auto* hyphenator = cachedHyphenator_;
// Detect apostrophe-like separators early; used by both branches below.
bool hasApostropheLikeSeparator = false;
for (const auto& cp : cps) {
if (isApostrophe(cp.value)) {
hasApostropheLikeSeparator = true;
break;
}
}
// Explicit hyphen markers (soft or hard) take precedence over language breaks.
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
if (!explicitBreakInfos.empty()) {
@@ -89,31 +187,32 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
// @16 Satellitensys|tems (+hyphen)
// Result: 6 sorted break points; the line-breaker picks the widest prefix that fits.
if (hyphenator) {
size_t segStart = 0;
for (size_t i = 0; i <= cps.size(); ++i) {
const bool atEnd = (i == cps.size());
const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value);
if (atEnd || atHyphen) {
if (i > segStart) {
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
auto segIndexes = hyphenator->breakIndexes(segment);
for (const size_t idx : segIndexes) {
const size_t cpIdx = segStart + idx;
if (cpIdx < cps.size()) {
explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true});
appendSegmentPatternBreaks(cps, *hyphenator, /*includeFallback=*/false, explicitBreakInfos);
}
// Also add apostrophe contraction breaks when present (e.g. "l'état-major"
// has both an explicit hyphen and an apostrophe that can independently break).
if (hasApostropheLikeSeparator) {
appendApostropheContractionBreaks(cps, explicitBreakInfos);
}
}
segStart = i + 1;
}
}
// Merge explicit and pattern breaks into ascending byte-offset order.
std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(),
[](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; });
}
// Merge all break points into ascending byte-offset order.
sortAndDedupeBreakInfos(explicitBreakInfos);
return explicitBreakInfos;
}
// Apostrophe-like separators split compounds into alphabetic segments; run Liang on each segment.
// This allows words like "all'improvviso" to hyphenate within "improvviso" instead of becoming
// completely unsplittable due to the apostrophe punctuation. Apostrophe contraction breaks are
// applied regardless of whether a language hyphenator is available.
if (hasApostropheLikeSeparator) {
std::vector<BreakInfo> segmentedBreaks;
if (hyphenator) {
appendSegmentPatternBreaks(cps, *hyphenator, includeFallback, segmentedBreaks);
}
appendApostropheContractionBreaks(cps, segmentedBreaks);
sortAndDedupeBreakInfos(segmentedBreaks);
return segmentedBreaks;
}
// Ask language hyphenator for legal break points.
std::vector<size_t> indexes;
if (hyphenator) {
+10 -4
View File
@@ -11,7 +11,8 @@ class Hyphenator {
struct BreakInfo {
size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur.
bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks).
// false = the word already contains a hyphen at this position (explicit '-').
// false = break occurs at an existing visible separator boundary
// (explicit '-' or eligible apostrophe contraction boundary).
};
// Returns byte offsets where the word may be hyphenated.
@@ -19,12 +20,17 @@ class Hyphenator {
// Break sources (in priority order):
// 1. Explicit hyphens already present in the word (e.g. '-' or soft-hyphen U+00AD).
// When found, language patterns are additionally run on each alphabetic segment
// between hyphens so compound words can break within their parts.
// between separators so compound words can break within their parts.
// Example: "US-Satellitensystems" yields breaks after "US-" (no inserted hyphen)
// plus pattern breaks inside "Satellitensystems" (Sa|tel|li|ten|sys|tems).
// 2. Language-specific Liang patterns (e.g. German de_patterns).
// 2. Apostrophe contractions between letters (e.g. all'improvviso).
// Liang patterns are run per alphabetic segment around apostrophes.
// A direct break at the apostrophe boundary is allowed only when the left
// segment has at least 3 letters and the right segment has at least 3 letters,
// avoiding short clitics (e.g. l', d') and contraction tails (e.g. 've, 're, 'll).
// 3. Language-specific Liang patterns (e.g. German de_patterns).
// Example: "Quadratkilometer" -> Qua|drat|ki|lo|me|ter.
// 3. Fallback every-N-chars splitting (only when includeFallback is true AND no
// 4. Fallback every-N-chars splitting (only when includeFallback is true AND no
// pattern breaks were found). Used as a last resort to prevent a single oversized
// word from overflowing the page width.
static std::vector<BreakInfo> breakOffsets(const std::string& word, bool includeFallback);
@@ -4,6 +4,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
#include <expat.h>
#include "../../Epub.h"
@@ -133,11 +134,21 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
// div's margin should be preserved, even though it has no direct text content.
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
return;
}
makePages();
}
// Record deferred anchor after previous block is flushed
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
@@ -151,7 +162,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Extract class and style attributes for CSS processing
// Extract class, style, and id attributes
std::string classAttr;
std::string styleAttr;
if (atts != nullptr) {
@@ -160,6 +171,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
classAttr = atts[i + 1];
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer recording until startNewTextBlock, after previous block is flushed to pages
self->pendingAnchorId = atts[i + 1];
}
}
}
@@ -243,7 +257,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
if (!src.empty()) {
// imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely
if (self->imageRendering == 2) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
if (!src.empty() && self->imageRendering != 1) {
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
{
@@ -278,8 +299,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
int displayWidth = 0;
int displayHeight = 0;
const float emSize =
static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
// Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules
if (!styleAttr.empty()) {
@@ -368,6 +388,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
if (!self->currentPage) {
LOG_ERR("EHP", "Failed to create new page");
@@ -505,7 +526,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle(
cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment), self->viewportWidth);
@@ -738,9 +759,30 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
}
// If we're about to run out of space, then cut the word off and start a new one
// If we're about to run out of space, then cut the word off and start a new one.
// For CJK text (no spaces), this is the primary word-breaking mechanism.
// We must avoid splitting multi-byte UTF-8 sequences across word boundaries,
// otherwise the trailing bytes become orphaned continuation bytes that the
// decoder can't interpret.
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
int safeLen = utf8SafeTruncateBuffer(self->partWordBuffer, self->partWordBufferIndex);
if (safeLen < self->partWordBufferIndex && safeLen > 0) {
// Incomplete UTF-8 sequence at the end — save it before flushing
int overflow = self->partWordBufferIndex - safeLen;
char saved[4];
for (int j = 0; j < overflow; j++) {
saved[j] = self->partWordBuffer[safeLen + j];
}
self->partWordBufferIndex = safeLen;
self->flushPartWordBuffer();
for (int j = 0; j < overflow; j++) {
self->partWordBuffer[j] = saved[j];
}
self->partWordBufferIndex = overflow;
} else {
self->flushPartWordBuffer();
}
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
@@ -752,8 +794,12 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
: self->viewportWidth;
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth,
self->renderer, self->fontId, effectiveWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
}
}
@@ -984,7 +1030,12 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
// Process last page if there is still text
if (currentTextBlock) {
makePages();
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
currentTextBlock.reset();
}
@@ -995,8 +1046,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
+11 -2
View File
@@ -5,6 +5,7 @@
#include <climits>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "../FootnoteEntry.h"
@@ -48,6 +49,7 @@ class ChapterHtmlSlimParser {
bool hyphenationEnabled;
const CssParser* cssParser;
bool embeddedStyle;
uint8_t imageRendering;
std::string contentBase;
std::string imageBasePath;
int imageCounter = 0;
@@ -68,6 +70,11 @@ class ChapterHtmlSlimParser {
int tableRowIndex = 0;
int tableColIndex = 0;
// Anchor-to-page mapping: tracks which page each HTML id attribute lands on
int completedPageCount = 0;
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
// Footnote link tracking
bool insideFootnoteLink = false;
int footnoteLinkDepth = -1;
@@ -94,8 +101,8 @@ class ChapterHtmlSlimParser {
const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr,
const CssParser* cssParser = nullptr)
const std::string& imageBasePath, const uint8_t imageRendering = 0,
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
: epub(epub),
filepath(filepath),
@@ -111,10 +118,12 @@ class ChapterHtmlSlimParser {
popupFn(popupFn),
cssParser(cssParser),
embeddedStyle(embeddedStyle),
imageRendering(imageRendering),
contentBase(contentBase),
imageBasePath(imageBasePath) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
};
+3 -5
View File
@@ -36,12 +36,10 @@ ContentOpfParser::~ContentOpfParser() {
if (tempItemStore) {
tempItemStore.close();
}
if (Storage.exists((cachePath + itemCacheFile).c_str())) {
Storage.remove((cachePath + itemCacheFile).c_str());
const auto itemCachePath = cachePath + itemCacheFile;
if (Storage.exists(itemCachePath.c_str())) {
Storage.remove(itemCachePath.c_str());
}
itemIndex.clear();
itemIndex.shrink_to_fit();
useItemIndex = false;
}
size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); }
+43 -1
View File
@@ -1,8 +1,12 @@
#include "FsHelpers.h"
#include <cctype>
#include <cstring>
#include <vector>
std::string FsHelpers::normalisePath(const std::string& path) {
namespace FsHelpers {
std::string normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
@@ -37,3 +41,41 @@ std::string FsHelpers::normalisePath(const std::string& path) {
return result;
}
bool checkFileExtension(std::string_view fileName, const char* extension) {
const size_t extLen = strlen(extension);
if (fileName.length() < extLen) {
return false;
}
const size_t offset = fileName.length() - extLen;
for (size_t i = 0; i < extLen; i++) {
if (tolower(static_cast<unsigned char>(fileName[offset + i])) !=
tolower(static_cast<unsigned char>(extension[i]))) {
return false;
}
}
return true;
}
bool hasJpgExtension(std::string_view fileName) {
return checkFileExtension(fileName, ".jpg") || checkFileExtension(fileName, ".jpeg");
}
bool hasPngExtension(std::string_view fileName) { return checkFileExtension(fileName, ".png"); }
bool hasBmpExtension(std::string_view fileName) { return checkFileExtension(fileName, ".bmp"); }
bool hasGifExtension(std::string_view fileName) { return checkFileExtension(fileName, ".gif"); }
bool hasEpubExtension(std::string_view fileName) { return checkFileExtension(fileName, ".epub"); }
bool hasXtcExtension(std::string_view fileName) {
return checkFileExtension(fileName, ".xtc") || checkFileExtension(fileName, ".xtch");
}
bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(fileName, ".txt"); }
bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); }
} // namespace FsHelpers
+56 -5
View File
@@ -1,7 +1,58 @@
#pragma once
#include <string>
#include <WString.h>
class FsHelpers {
public:
static std::string normalisePath(const std::string& path);
};
#include <string>
#include <string_view>
namespace FsHelpers {
std::string normalisePath(const std::string& path);
/**
* Check if the given filename ends with the specified extension (case-insensitive).
*/
bool checkFileExtension(std::string_view fileName, const char* extension);
inline bool checkFileExtension(const String& fileName, const char* extension) {
return checkFileExtension(std::string_view{fileName.c_str(), fileName.length()}, extension);
}
// Check for either .jpg or .jpeg extension (case-insensitive)
bool hasJpgExtension(std::string_view fileName);
inline bool hasJpgExtension(const String& fileName) {
return hasJpgExtension(std::string_view{fileName.c_str(), fileName.length()});
}
// Check for .png extension (case-insensitive)
bool hasPngExtension(std::string_view fileName);
inline bool hasPngExtension(const String& fileName) {
return hasPngExtension(std::string_view{fileName.c_str(), fileName.length()});
}
// Check for .bmp extension (case-insensitive)
bool hasBmpExtension(std::string_view fileName);
// Check for .gif extension (case-insensitive)
bool hasGifExtension(std::string_view fileName);
inline bool hasGifExtension(const String& fileName) {
return hasGifExtension(std::string_view{fileName.c_str(), fileName.length()});
}
// Check for .epub extension (case-insensitive)
bool hasEpubExtension(std::string_view fileName);
inline bool hasEpubExtension(const String& fileName) {
return hasEpubExtension(std::string_view{fileName.c_str(), fileName.length()});
}
// Check for either .xtc or .xtch extension (case-insensitive)
bool hasXtcExtension(std::string_view fileName);
// Check for .txt extension (case-insensitive)
bool hasTxtExtension(std::string_view fileName);
inline bool hasTxtExtension(const String& fileName) {
return hasTxtExtension(std::string_view{fileName.c_str(), fileName.length()});
}
// Check for .md extension (case-insensitive)
bool hasMarkdownExtension(std::string_view fileName);
} // namespace FsHelpers
+96
View File
@@ -0,0 +1,96 @@
#include "FontCacheManager.h"
#include <FontDecompressor.h>
#include <Logging.h>
#include <cstring>
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap) : fontMap_(fontMap) {}
void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; }
void FontCacheManager::clearCache() {
if (fontDecompressor_) fontDecompressor_->clearCache();
}
void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) {
if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return;
for (uint8_t i = 0; i < 4; i++) {
if (!(styleMask & (1 << i))) continue;
auto style = static_cast<EpdFontFamily::Style>(i);
const EpdFontData* data = fontMap_.at(fontId).getData(style);
if (!data || !data->groups) continue;
int missed = fontDecompressor_->prewarmCache(data, utf8Text);
if (missed > 0) {
LOG_DBG("FCM", "prewarmCache: %d glyph(s) not cached for style %d", missed, i);
}
}
}
void FontCacheManager::logStats(const char* label) {
if (fontDecompressor_) fontDecompressor_->logStats(label);
}
void FontCacheManager::resetStats() {
if (fontDecompressor_) fontDecompressor_->resetStats();
}
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
scanText_ += text;
if (scanFontId_ < 0) scanFontId_ = fontId;
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
uint32_t cpCount = 0;
while (*p) {
if ((*p & 0xC0) != 0x80) cpCount++;
p++;
}
scanStyleCounts_[baseStyle] += cpCount;
}
// --- PrewarmScope implementation ---
FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manager_(&manager) {
manager_->scanMode_ = ScanMode::Scanning;
manager_->clearCache();
manager_->resetStats();
manager_->scanText_.clear();
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
manager_->scanFontId_ = -1;
}
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
manager_->scanMode_ = ScanMode::None;
if (manager_->scanText_.empty()) return;
// Build style bitmask from all styles that appeared during the scan
uint8_t styleMask = 0;
for (uint8_t i = 0; i < 4; i++) {
if (manager_->scanStyleCounts_[i] > 0) styleMask |= (1 << i);
}
if (styleMask == 0) styleMask = 1; // default to regular
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
// Free scan string memory
manager_->scanText_.clear();
manager_->scanText_.shrink_to_fit();
}
FontCacheManager::PrewarmScope::~PrewarmScope() {
if (active_) {
endScanAndPrewarm(); // no-op if already called (scanText_ is empty)
manager_->clearCache();
}
}
FontCacheManager::PrewarmScope::PrewarmScope(PrewarmScope&& other) noexcept
: manager_(other.manager_), active_(other.active_) {
other.active_ = false;
}
FontCacheManager::PrewarmScope FontCacheManager::createPrewarmScope() { return PrewarmScope(*this); }
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <EpdFontFamily.h>
#include <cstdint>
#include <map>
#include <string>
class FontDecompressor;
class FontCacheManager {
public:
explicit FontCacheManager(const std::map<int, EpdFontFamily>& fontMap);
void setFontDecompressor(FontDecompressor* d);
void clearCache();
void prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F);
void logStats(const char* label = "render");
void resetStats();
// Scan-mode API: called by GfxRenderer::drawText() during scan pass
bool isScanning() const;
void recordText(const char* text, int fontId, EpdFontFamily::Style style);
// The FontDecompressor pointer, needed by GfxRenderer::getGlyphBitmap()
FontDecompressor* getDecompressor() const { return fontDecompressor_; }
// RAII scope for two-pass prewarm pattern
class PrewarmScope {
public:
explicit PrewarmScope(FontCacheManager& manager);
~PrewarmScope();
void endScanAndPrewarm();
PrewarmScope(PrewarmScope&& other) noexcept;
PrewarmScope& operator=(PrewarmScope&&) = delete;
PrewarmScope(const PrewarmScope&) = delete;
PrewarmScope& operator=(const PrewarmScope&) = delete;
private:
FontCacheManager* manager_;
bool active_ = true;
};
PrewarmScope createPrewarmScope();
private:
const std::map<int, EpdFontFamily>& fontMap_;
FontDecompressor* fontDecompressor_ = nullptr;
enum class ScanMode : uint8_t { None, Scanning };
ScanMode scanMode_ = ScanMode::None;
std::string scanText_;
uint32_t scanStyleCounts_[4] = {};
int scanFontId_ = -1;
};
+63 -53
View File
@@ -1,16 +1,23 @@
#include "GfxRenderer.h"
#include <FontDecompressor.h>
#include <Logging.h>
#include <Utf8.h>
#include "FontCacheManager.h"
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
if (!fontDecompressor) {
auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr;
if (!fd) {
LOG_ERR("GFX", "Compressed font but no FontDecompressor set");
return nullptr;
}
uint16_t glyphIndex = static_cast<uint16_t>(glyph - fontData->glyph);
return fontDecompressor->getBitmap(fontData, glyph, glyphIndex);
uint32_t glyphIndex = static_cast<uint32_t>(glyph - fontData->glyph);
// For page-buffer hits the pointer is stable for the page lifetime.
// For hot-group hits it is valid only until the next getBitmap() call — callers
// must consume it (draw the glyph) before requesting another bitmap.
return fd->getBitmap(fontData, glyph, glyphIndex);
}
return &fontData->bitmap[glyph->dataOffset];
}
@@ -65,7 +72,7 @@ enum class TextRotation { None, Rotated90CW };
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
template <TextRotation rotation>
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int* cursorX, int* cursorY,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) {
@@ -87,11 +94,11 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
// For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse)
int outerBase, innerBase;
if constexpr (rotation == TextRotation::Rotated90CW) {
outerBase = *cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
innerBase = *cursorY - left; // screenY = innerBase - glyphX
outerBase = cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
innerBase = cursorY - left; // screenY = innerBase - glyphX
} else {
outerBase = *cursorY - top; // screenY = outerBase + glyphY
innerBase = *cursorX + left; // screenX = innerBase + glyphX
outerBase = cursorY - top; // screenY = outerBase + glyphY
innerBase = cursorX + left; // screenX = innerBase + glyphX
}
if (is2Bit) {
@@ -152,12 +159,6 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
}
}
if constexpr (rotation == TextRotation::Rotated90CW) {
*cursorY -= glyph->advanceX;
} else {
*cursorX += glyph->advanceX;
}
}
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
@@ -206,11 +207,10 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
int yPos = y + getFontAscenderSize(fontId);
int xPos = x;
const int yPos = y + getFontAscenderSize(fontId);
int32_t xPosFP = fp4::fromPixel(x); // 12.4 fixed-point accumulator
int lastBaseX = x;
int lastBaseY = yPos;
int lastBaseAdvance = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseTop = 0;
// cannot draw a NULL / empty string
@@ -218,6 +218,11 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
return;
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
@@ -239,30 +244,32 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
}
}
int combiningX = lastBaseX + lastBaseAdvance / 2;
int combiningY = lastBaseY - raiseBy;
renderChar(font, cp, &combiningX, &combiningY, black, style);
const int combiningX = lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2);
const int combiningY = yPos - raiseBy;
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
xPos += font.getKerning(prevCp, cp, style);
}
const int kernFP = (prevCp != 0) ? font.getKerning(prevCp, cp, style) : 0; // 4.4 fixed-point kern
xPosFP += kernFP;
lastBaseX = fp4::toPixel(xPosFP); // snap 12.4 fixed-point to nearest pixel
const EpdGlyph* glyph = font.getGlyph(cp, style);
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
renderChar(font, cp, &xPos, &yPos, black, style);
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
if (glyph) {
xPosFP += glyph->advanceX; // 12.4 fixed-point advance
}
prevCp = cp;
}
}
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
if (x1 == x2) {
if (y2 < y1) {
std::swap(y1, y2);
@@ -575,6 +582,7 @@ void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, con
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
@@ -946,22 +954,29 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
}
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
return spaceGlyph ? spaceGlyph->advanceX : 0;
return spaceGlyph ? fp4::toPixel(spaceGlyph->advanceX) : 0; // snap 12.4 fixed-point to nearest pixel
}
int GfxRenderer::getSpaceKernAdjust(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
return font.getKerning(leftCp, ' ', style) + font.getKerning(' ', rightCp, style);
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
const int32_t spaceAdvanceFP = spaceGlyph ? static_cast<int32_t>(spaceGlyph->advanceX) : 0;
// Combine space advance + flanking kern into one fixed-point sum before snapping.
// Snapping the combined value avoids the +/-1 px error from snapping each component separately.
const int32_t kernFP = static_cast<int32_t>(font.getKerning(leftCp, ' ', style)) +
static_cast<int32_t>(font.getKerning(' ', rightCp, style));
return fp4::toPixel(spaceAdvanceFP + kernFP);
}
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
return fontIt->second.getKerning(leftCp, rightCp, style);
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
@@ -973,7 +988,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
uint32_t cp;
uint32_t prevCp = 0;
int width = 0;
int32_t widthFP = 0; // 12.4 fixed-point accumulator
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
@@ -981,13 +996,13 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
width += font.getKerning(prevCp, cp, style);
widthFP += font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (glyph) width += glyph->advanceX;
if (glyph) widthFP += glyph->advanceX; // 12.4 fixed-point advance
prevCp = cp;
}
return width;
return fp4::toPixel(widthFP); // snap 12.4 fixed-point to nearest pixel
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
@@ -1034,11 +1049,9 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
const auto& font = fontIt->second;
int xPos = x;
int yPos = y;
int lastBaseX = x;
int32_t yPosFP = fp4::fromPixel(y); // 12.4 fixed-point accumulator
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseTop = 0;
constexpr int MIN_COMBINING_GAP_PX = 1;
@@ -1055,25 +1068,27 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
int combiningX = lastBaseX - raiseBy;
int combiningY = lastBaseY - lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
const int combiningX = x - raiseBy;
const int combiningY = lastBaseY - fp4::toPixel(lastBaseAdvanceFP / 2);
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, combiningX, combiningY, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
if (prevCp != 0) {
yPos -= font.getKerning(prevCp, cp, style);
yPosFP -= font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern (subtract for rotated)
}
lastBaseY = fp4::toPixel(yPosFP); // snap 12.4 fixed-point to nearest pixel
const EpdGlyph* glyph = font.getGlyph(cp, style);
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point
lastBaseTop = glyph ? glyph->top : 0;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, x, lastBaseY, black, style);
if (glyph) {
yPosFP -= glyph->advanceX; // 12.4 fixed-point advance (subtract for rotated)
}
prevCp = cp;
}
}
@@ -1174,11 +1189,6 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
}
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const {
renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, style);
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
switch (orientation) {
case Portrait:
+16 -9
View File
@@ -1,9 +1,11 @@
#pragma once
#include <EpdFontFamily.h>
#include <FontDecompressor.h>
#include <HalDisplay.h>
class FontCacheManager;
#include <cstring>
#include <map>
#include <string>
#include <vector>
@@ -39,7 +41,12 @@ class GfxRenderer {
uint8_t* frameBuffer = nullptr;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap;
FontDecompressor* fontDecompressor = nullptr;
// Mutable because drawText() is const but needs to delegate scan-mode
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
// as before, concentrated in a single pointer instead of four fields.
mutable FontCacheManager* fontCacheManager_ = nullptr;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const;
void freeBwBufferChunks();
@@ -61,10 +68,9 @@ class GfxRenderer {
// Setup
void begin(); // must be called right after display.begin()
void insertFont(int fontId, EpdFontFamily font);
void setFontDecompressor(FontDecompressor* d) { fontDecompressor = d; }
void clearFontCache() {
if (fontDecompressor) fontDecompressor->clearCache();
}
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
// Orientation control (affects logical width/height and coordinate transforms)
void setOrientation(const Orientation o) { orientation = o; }
@@ -112,9 +118,10 @@ class GfxRenderer {
void drawText(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getSpaceWidth(int fontId, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
/// Returns the kerning adjustment for a space between two codepoints:
/// kern(leftCp, ' ') + kern(' ', rightCp). Returns 0 if kerning is unavailable.
int getSpaceKernAdjust(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style) const;
/// Returns the total inter-word advance: fp4::toPixel(spaceAdvance + kern(leftCp,' ') + kern(' ',rightCp)).
/// Using a single snap avoids the +/-1 px rounding error that arises when space advance and kern are
/// snapped separately and then added as integers.
int getSpaceAdvance(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style) const;
/// Returns the kerning adjustment between two adjacent codepoints.
int getKerning(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style) const;
int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style) const;
+1 -59
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Пераход у сон"
STR_BROWSE_FILES: "Прагляд файлаў"
STR_FILE_TRANSFER: "Перадача файлаў"
STR_SETTINGS_TITLE: "Налады"
STR_CALIBRE_LIBRARY: "Бібліятэка Calibre"
STR_CONTINUE_READING: "Працягнуць чытанне"
STR_NO_OPEN_BOOK: "Няма адкрытай кнігі"
STR_START_READING: "Пачніце чытанне ніжэй"
STR_BOOKS: "Кнігі"
STR_NO_BOOKS_FOUND: "Кнігі не знойдзены"
STR_NO_FILES_FOUND: "Файлы не знойдзены"
STR_SELECT_CHAPTER: "Абярыце раздзел"
STR_NO_CHAPTERS: "Раздзелаў няма"
STR_END_OF_BOOK: "Канец кнігі"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Пусты файл"
STR_OUT_OF_BOUNDS: "Выхад за межы"
STR_LOADING: "Загрузка..."
STR_LOADING_POPUP: "Загрузка"
STR_LOAD_XTC_FAILED: "Не ўдалося загрузіць XTC"
STR_LOAD_TXT_FAILED: "Не ўдалося загрузіць TXT"
STR_LOAD_EPUB_FAILED: "Не ўдалося загрузіць EPUB"
STR_SD_CARD_ERROR: "Памылка SD-карты"
STR_WIFI_NETWORKS: "Сеткі Wi-Fi"
STR_NO_NETWORKS: "Сеткі не знойдзены"
STR_NETWORKS_FOUND: "Знойдзена сетак: %zu"
@@ -37,14 +31,9 @@ STR_SCANNING: "Сканаванне..."
STR_CONNECTING: "Падключэнне..."
STR_CONNECTED: "Падключана!"
STR_CONNECTION_FAILED: "Памылка падключэння"
STR_CONNECTION_TIMEOUT: "Тайм-аўт падключэння"
STR_FORGET_NETWORK: "Забыць сетку?"
STR_SAVE_PASSWORD: "Захаваць пароль?"
STR_REMOVE_PASSWORD: "Выдаліць захаваны пароль?"
STR_PRESS_OK_SCAN: "Націсніце OK для паўторнага пошуку"
STR_PRESS_ANY_CONTINUE: "Націсніце любую кнопку"
STR_SELECT_HINT: "УЛЕВА/УПРАВА: выбар | OK: пацвердзіць"
STR_HOW_CONNECT: "Як вы хочаце падключыцца?"
STR_JOIN_NETWORK: "Падключыцца да сеткі"
STR_CREATE_HOTSPOT: "Стварыць кропку доступу"
STR_JOIN_DESC: "Падключэнне да існуючай сеткі Wi-Fi"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "або http://"
STR_SCAN_QR_HINT: "або адсканіруйце QR-код:"
STR_CALIBRE_WIRELESS: "Calibre па Wi-Fi"
STR_CALIBRE_WEB_URL: "Вэб-адрас Calibre"
STR_CONNECT_WIRELESS: "Падключыць як бесправадную прыладу"
STR_NETWORK_LEGEND: "* = Абаронена | + = Захавана"
STR_MAC_ADDRESS: "MAC-адрас:"
STR_CHECKING_WIFI: "Праверка Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Увядзіце пароль Wi-Fi"
STR_ENTER_TEXT: "Увядзіце тэкст"
STR_TO_PREFIX: "да "
STR_CALIBRE_DISCOVERING: "Пошук Calibre..."
STR_CALIBRE_CONNECTING_TO: "Падключэнне да "
STR_CALIBRE_CONNECTED_TO: "Падключана да "
STR_CALIBRE_WAITING_COMMANDS: "Чаканне каманд..."
STR_CONNECTION_FAILED_RETRYING: "(Памылка падключэння, паўторная спроба)"
STR_CALIBRE_DISCONNECTED: "Злучэнне з Calibre разарвана"
STR_CALIBRE_WAITING_TRANSFER: "Чаканне перадачы..."
STR_CALIBRE_TRANSFER_HINT: "Калі перадача не ўдаецца"
STR_CALIBRE_RECEIVING: "Атрыманне:"
STR_CALIBRE_RECEIVED: "Атрымана:"
STR_CALIBRE_WAITING_MORE: "Чаканне наступных файлаў..."
STR_CALIBRE_FAILED_CREATE_FILE: "Не ўдалося стварыць файл"
STR_CALIBRE_PASSWORD_REQUIRED: "Патрабуецца пароль"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Перадача перапынена"
STR_CALIBRE_INSTRUCTION_1: "1) Усталюйце плагін CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Падключыцеся да той жа сеткі Wi-Fi"
STR_CALIBRE_INSTRUCTION_3: "3) У Calibre абярыце: «Адправіць на прыладу»"
@@ -88,37 +63,25 @@ STR_CAT_CONTROLS: "Кіраванне"
STR_CAT_SYSTEM: "Сістэма"
STR_SLEEP_SCREEN: "Экран сну"
STR_SLEEP_COVER_MODE: "Рэжым вокладкі сну"
STR_STATUS_BAR: "Радок стану"
STR_HIDE_BATTERY: "Схаваць % батарэі"
STR_EXTRA_SPACING: "Дадат. інтэрвал абзаца"
STR_TEXT_AA: "Згладжванне тэксту"
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
STR_ORIENTATION: "Арыентацыя чытання"
STR_FRONT_BTN_LAYOUT: "Бакавыя кнопкі"
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
STR_FONT_FAMILY: "Шрыфт чытання"
STR_EXT_READER_FONT: "Знешні шрыфт чытання"
STR_EXT_CHINESE_FONT: "Шрыфт CJK"
STR_EXT_UI_FONT: "Шрыфт інтэрфейсу"
STR_FONT_SIZE: "Памер шрыфту інтэрфейсу"
STR_LINE_SPACING: "Міжрадковы інтэрвал"
STR_ASCII_LETTER_SPACING: "Інтэрвал літар ASCII"
STR_ASCII_DIGIT_SPACING: "Інтэрвал лічбаў ASCII"
STR_CJK_SPACING: "Інтэрвал CJK"
STR_COLOR_MODE: "Каляровы рэжым"
STR_SCREEN_MARGIN: "Палі экрана"
STR_PARA_ALIGNMENT: "Выраўноўванне абзаца"
STR_HYPHENATION: "Перанос слоў"
STR_TIME_TO_SLEEP: "Сон праз"
STR_REFRESH_FREQ: "Частата абнаўлення"
STR_CALIBRE_SETTINGS: "Налады Calibre"
STR_KOREADER_SYNC: "Сінхранізацыя KOReader"
STR_CHECK_UPDATES: "Праверыць абнаўленні"
STR_LANGUAGE: "Мова"
STR_SELECT_WALLPAPER: "Абраць шпалеры"
STR_CLEAR_READING_CACHE: "Ачысціць кэш чытання"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Імя карыстальніка"
STR_PASSWORD: "Пароль"
STR_SYNC_SERVER_URL: "URL сервера сінхранізацыі"
@@ -153,8 +116,6 @@ STR_COVER: "Вокладка"
STR_NONE_OPT: "Няма"
STR_FIT: "Упісаць"
STR_CROP: "Абрэзаць"
STR_NO_PROGRESS: "Без прагрэсу"
STR_FULL_OPT: "Поўная"
STR_NEVER: "Ніколі"
STR_IN_READER: "У рэжыме чытання"
STR_ALWAYS: "Заўсёды"
@@ -165,9 +126,6 @@ STR_PORTRAIT: "Партрэт"
STR_LANDSCAPE_CW: "Ландшафт (CW)"
STR_INVERTED: "Інверсія"
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
STR_FRONT_LAYOUT_BCLR: "Наз, Ок, Лев, Прав"
STR_FRONT_LAYOUT_LRBC: "Лев, Прав, Наз, Ок"
STR_FRONT_LAYOUT_LBCR: "Лев, Наз, Ок, Прав"
STR_PREV_NEXT: "Назад/Наперад"
STR_NEXT_PREV: "Наперад/Назад"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +162,6 @@ STR_NO_UPDATE: "Абнаўленняў няма"
STR_UPDATE_FAILED: "Памылка абнаўлення"
STR_UPDATE_COMPLETE: "Абнаўленне завершана"
STR_POWER_ON_HINT: "Утрымлівайце кнопку сілкавання для ўключэння"
STR_EXTERNAL_FONT: "Карыстальніцкі шрыфт"
STR_BUILTIN_DISABLED: "Убудаваны (адключаны)"
STR_NO_ENTRIES: "Запісы не знойдзены"
STR_DOWNLOADING: "Спампоўка..."
STR_DOWNLOAD_FAILED: "Памылка спампоўкі"
@@ -216,7 +172,6 @@ STR_FETCH_FEED_FAILED: "Не ўдалося атрымаць стужку"
STR_PARSE_FEED_FAILED: "Не ўдалося апрацаваць стужку"
STR_NETWORK_PREFIX: "Сетка:"
STR_IP_ADDRESS_PREFIX: "IP-адрас:"
STR_SCAN_QR_WIFI_HINT: "або адсканіруйце QR-код для падключэння да Wi-Fi."
STR_ERROR_GENERAL_FAILURE: "Памылка: Агульная памылка"
STR_ERROR_NETWORK_NOT_FOUND: "Памылка: Сетка не знойдзена"
STR_ERROR_CONNECTION_TIMEOUT: "Памылка: Тайм-аўт злучэння"
@@ -224,7 +179,6 @@ STR_SD_CARD: "SD-карта"
STR_BACK: "« Назад"
STR_EXIT: "« Выхад"
STR_HOME: "« Галоўная"
STR_SAVE: "« Захаваць"
STR_SELECT: "Абраць"
STR_TOGGLE: "Выбар"
STR_CONFIRM: "Пацв."
@@ -237,22 +191,14 @@ STR_YES: "Так"
STR_NO: "Не"
STR_STATE_ON: "УКЛ"
STR_STATE_OFF: "ВЫКЛ"
STR_SET: "Устаноўлена"
STR_NOT_SET: "Не ўстаноўлена"
STR_DIR_LEFT: "Улева"
STR_DIR_RIGHT: "Управа"
STR_DIR_UP: "Уверх"
STR_DIR_DOWN: "Уніз"
STR_CAPS_ON: "CAPS"
STR_CAPS_OFF: "caps"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[УКЛ]"
STR_SLEEP_COVER_FILTER: "Фільтр экрана сну"
STR_FILTER_CONTRAST: "Кантраст"
STR_STATUS_BAR_FULL_PERCENT: "Поўная + %"
STR_STATUS_BAR_FULL_BOOK: "Поўная + шкала кнігі"
STR_STATUS_BAR_BOOK_ONLY: "Толькі шкала кнігі"
STR_STATUS_BAR_FULL_CHAPTER: "Поўная + шкала раздзела"
STR_UI_THEME: "Тэма інтэрфейсу"
STR_THEME_CLASSIC: "Класічная"
STR_THEME_LYRA: "Lyra"
@@ -261,7 +207,6 @@ STR_SUNLIGHT_FADING_FIX: "Кампенсацыя выцвітання"
STR_REMAP_FRONT_BUTTONS: "Пераназначыць пярэднія кнопкі"
STR_OPDS_BROWSER: "OPDS браўзер"
STR_COVER_CUSTOM: "Вокладка + Свой"
STR_RECENTS: "Нядаўнія"
STR_MENU_RECENT_BOOKS: "Нядаўнія кнігі"
STR_NO_RECENT_BOOKS: "Няма нядаўніх кніг"
STR_CALIBRE_DESC: "Выкарыстоўваць бесправадную перадачу Calibre"
@@ -288,9 +233,6 @@ STR_DELETE_CACHE: "Выдаліць кэш кнігі"
STR_CHAPTER_PREFIX: "Раздзел:"
STR_PAGES_SEPARATOR: "стар. |"
STR_BOOK_PREFIX: "Кніга:"
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "LOCK"
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
STR_SYNCING_TIME: "Сінхранізацыя часу..."
+31 -55
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Entrant en repòs"
STR_BROWSE_FILES: "Explora fitxers"
STR_FILE_TRANSFER: "Transferència"
STR_SETTINGS_TITLE: "Configuració"
STR_CALIBRE_LIBRARY: "Biblioteca del Calibre"
STR_CONTINUE_READING: "Continua llegint"
STR_NO_OPEN_BOOK: "Cap llibre obert"
STR_START_READING: "Inicia la lectura a continuació"
STR_BOOKS: "Llibres"
STR_NO_BOOKS_FOUND: "No s'ha trobat cap llibre"
STR_NO_FILES_FOUND: "No s'han trobat fitxers"
STR_SELECT_CHAPTER: "Selecciona el capítol"
STR_NO_CHAPTERS: "Sense capítols"
STR_END_OF_BOOK: "Final del llibre"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Fitxer buit"
STR_OUT_OF_BOUNDS: "Fora de límits"
STR_LOADING: "S'està carregant..."
STR_LOADING_POPUP: "S'està carregant"
STR_LOAD_XTC_FAILED: "No s'ha pogut carregar l'XTC"
STR_LOAD_TXT_FAILED: "No s'ha pogut carregar el TXT"
STR_LOAD_EPUB_FAILED: "No s'ha pogut carregar l'EPUB"
STR_SD_CARD_ERROR: "Error de targeta SD"
STR_WIFI_NETWORKS: "Xarxes WiFi"
STR_NO_NETWORKS: "No s'han trobat xarxes"
STR_NETWORKS_FOUND: "%zu xarxes trobades"
@@ -37,14 +31,9 @@ STR_SCANNING: "S'està escanejant..."
STR_CONNECTING: "S'està connectant..."
STR_CONNECTED: "S'ha connectat!"
STR_CONNECTION_FAILED: "Error de connexió"
STR_CONNECTION_TIMEOUT: "S'ha esgotat el temps de connexió"
STR_FORGET_NETWORK: "Voleu oblidar aquesta xarxa?"
STR_SAVE_PASSWORD: "Voleu desar la contrasenya per a la propera vegada?"
STR_REMOVE_PASSWORD: "Voleu suprimir la contrasenya desada?"
STR_PRESS_OK_SCAN: "Premeu OK per tornar a escanejar"
STR_PRESS_ANY_CONTINUE: "Premeu qualsevol botó per continuar"
STR_SELECT_HINT: "ESQUERRA/DRETA: Selecciona | OK: Confirma"
STR_HOW_CONNECT: "Com voleu connectar-vos?"
STR_JOIN_NETWORK: "Uneix-te a una xarxa"
STR_CREATE_HOTSPOT: "Crea un punt d'accés"
STR_JOIN_DESC: "Connecta't a una xarxa WiFi existent"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "o http://"
STR_SCAN_QR_HINT: "o escanegeu el codi QR amb el telèfon:"
STR_CALIBRE_WIRELESS: "Calibre sense fils"
STR_CALIBRE_WEB_URL: "URL web del Calibre"
STR_CONNECT_WIRELESS: "Connecta com a dispositiu sense fils"
STR_NETWORK_LEGEND: "* = Encriptat | + = Desat"
STR_MAC_ADDRESS: "Adreça MAC:"
STR_CHECKING_WIFI: "S'està comprovant el WiFi..."
STR_ENTER_WIFI_PASSWORD: "Introduïu la contrasenya WiFi"
STR_ENTER_TEXT: "Introduïu el text"
STR_TO_PREFIX: "a "
STR_CALIBRE_DISCOVERING: "S'està descobrint el Calibre..."
STR_CALIBRE_CONNECTING_TO: "S'està connectant a "
STR_CALIBRE_CONNECTED_TO: "S'ha connectat a "
STR_CALIBRE_WAITING_COMMANDS: "S'estan esperant les ordres..."
STR_CONNECTION_FAILED_RETRYING: "(La connexió ha fallat, s'està tornant a intentar)"
STR_CALIBRE_DISCONNECTED: "Calibre desconnectat"
STR_CALIBRE_WAITING_TRANSFER: "S'està esperant la transferència..."
STR_CALIBRE_TRANSFER_HINT: "Si la transferència falla, activeu\\n'Ignora l'espai lliure' a la configuració del\\nconnector SmartDevice a Calibre."
STR_CALIBRE_RECEIVING: "S'està rebent: "
STR_CALIBRE_RECEIVED: "S'ha rebut: "
STR_CALIBRE_WAITING_MORE: "S'està esperant més..."
STR_CALIBRE_FAILED_CREATE_FILE: "No s'ha pogut crear el fitxer"
STR_CALIBRE_PASSWORD_REQUIRED: "Contrasenya requerida"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Transferència interrompuda"
STR_CALIBRE_INSTRUCTION_1: "1) Instal·leu el connector CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Estigueu a la mateixa xarxa WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) A Calibre: \"Envia a un dispositiu\""
@@ -88,37 +63,29 @@ STR_CAT_CONTROLS: "Controls"
STR_CAT_SYSTEM: "Sistema"
STR_SLEEP_SCREEN: "Pantalla de repòs"
STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
STR_STATUS_BAR: "Barra d'estat"
STR_HIDE_BATTERY: "Oculta el % de bateria"
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
STR_TEXT_AA: "Antialiàsing del text"
STR_IMAGES: "Imatges"
STR_IMAGES_DISPLAY: "Mostrar"
STR_IMAGES_PLACEHOLDER: "Text de mostra"
STR_IMAGES_SUPPRESS: "Suprimir"
STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada"
STR_ORIENTATION: "Orientació de lectura"
STR_FRONT_BTN_LAYOUT: "Disposició dels botons frontals"
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
STR_LONG_PRESS_SKIP: "Pressió llarga omet el capítol"
STR_FONT_FAMILY: "Tipus de lletra"
STR_EXT_READER_FONT: "Tipus de lletra extern"
STR_EXT_CHINESE_FONT: "Tipus de lletra"
STR_EXT_UI_FONT: "Tipus de lletra (UI)"
STR_FONT_SIZE: "Mida de la lletra (UI)"
STR_LINE_SPACING: "Interlineat del lector"
STR_ASCII_LETTER_SPACING: "Espaiat de la lletra ASCII"
STR_ASCII_DIGIT_SPACING: "Espaiat del dígit ASCII"
STR_CJK_SPACING: "Espaiat CJK"
STR_COLOR_MODE: "Mode de color"
STR_SCREEN_MARGIN: "Marge de pantalla del lector"
STR_PARA_ALIGNMENT: "Alineació de paràgrafs del lector"
STR_HYPHENATION: "Partició de mots"
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
STR_REFRESH_FREQ: "Freqüència de refresc"
STR_CALIBRE_SETTINGS: "Configuració del Calibre"
STR_KOREADER_SYNC: "Sincronització del KOReader"
STR_CHECK_UPDATES: "Comprova si hi ha actualitzacions"
STR_LANGUAGE: "Idioma"
STR_SELECT_WALLPAPER: "Selecciona un fons de pantalla"
STR_CLEAR_READING_CACHE: "Esborra la memòria cau de lectura"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Nom d'usuari"
STR_PASSWORD: "Contrasenya"
STR_SYNC_SERVER_URL: "URL del servidor de sincronització"
@@ -153,8 +120,6 @@ STR_COVER: "Portada"
STR_NONE_OPT: "Cap"
STR_FIT: "Ajustar"
STR_CROP: "Retallar"
STR_NO_PROGRESS: "Sense progrés"
STR_FULL_OPT: "Completa"
STR_NEVER: "Mai"
STR_IN_READER: "Al lector"
STR_ALWAYS: "Sempre"
@@ -165,9 +130,6 @@ STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horitzontal horari"
STR_INVERTED: "Invertit"
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
STR_FRONT_LAYOUT_BCLR: "Enr, Cnfrm, Esq, Dreta"
STR_FRONT_LAYOUT_LRBC: "Esq, Dreta, Enr, Cnfrm"
STR_FRONT_LAYOUT_LBCR: "Esq, Enr, Cnfrm, Dreta"
STR_PREV_NEXT: "Anterior/Següent"
STR_NEXT_PREV: "Següent/Anterior"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +166,6 @@ STR_NO_UPDATE: "No hi ha actualitzacions disponibles"
STR_UPDATE_FAILED: "Ha fallat l'actualització"
STR_UPDATE_COMPLETE: "Actualització completada"
STR_POWER_ON_HINT: "Premeu i manteniu premut el botó d'encesa per tornar a engegar"
STR_EXTERNAL_FONT: "Tipus de lletra extern"
STR_BUILTIN_DISABLED: "Integrat (desactivat)"
STR_NO_ENTRIES: "No s'ha trobat cap entrada"
STR_DOWNLOADING: "S'està baixant..."
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
@@ -216,7 +176,6 @@ STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
STR_NETWORK_PREFIX: "Xarxa: "
STR_IP_ADDRESS_PREFIX: "Adreça IP: "
STR_SCAN_QR_WIFI_HINT: "o escanegeu el codi QR amb el telèfon per connectar el WiFi."
STR_ERROR_GENERAL_FAILURE: "Error: Fallada general"
STR_ERROR_NETWORK_NOT_FOUND: "Error: No s'ha trobat la xarxa"
STR_ERROR_CONNECTION_TIMEOUT: "Error: temps de connexió esgotat"
@@ -224,8 +183,8 @@ STR_SD_CARD: "Targeta SD"
STR_BACK: "« Enrere"
STR_EXIT: "« Surt"
STR_HOME: "« Inici"
STR_SAVE: "« Desa"
STR_SELECT: "Selecciona"
STR_SELECTED: "Seleccionat"
STR_TOGGLE: "Canvia"
STR_CONFIRM: "Confirma"
STR_CANCEL: "Cancel·la"
@@ -235,20 +194,33 @@ STR_DOWNLOAD: "Descarrega"
STR_RETRY: "Nou intent"
STR_YES: "Sí"
STR_NO: "No"
STR_SHOW: "Mostrar"
STR_HIDE: "Amagar"
STR_STATE_ON: "ON"
STR_STATE_OFF: "OFF"
STR_SET: "Establert"
STR_NOT_SET: "No establert"
STR_DIR_LEFT: "Esquerra"
STR_DIR_RIGHT: "Dreta"
STR_DIR_UP: "Amunt"
STR_DIR_DOWN: "Avall"
STR_CAPS_ON: "MAJS"
STR_CAPS_OFF: "majs"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[ON]"
STR_SLEEP_COVER_FILTER: "Filtre de pantalla de repòs"
STR_FILTER_CONTRAST: "Contrast"
STR_CUSTOMISE_STATUS_BAR: "Personalitza la barra d'estat"
STR_CHAPTER_PAGE_COUNT: "Comptador de pàgines del capítol"
STR_BOOK_PROGRESS_PERCENTAGE: "Percentatge de progrés del llibre"
STR_PROGRESS_BAR: "Barra de progrés"
STR_PROGRESS_BAR_THICKNESS: "Gruix de la barra de progrés"
STR_PROGRESS_BAR_THIN: "Fina"
STR_PROGRESS_BAR_MEDIUM: "Mitjana"
STR_PROGRESS_BAR_THICK: "Gruixuda"
STR_BOOK: "Llibre"
STR_CHAPTER: "Capítol"
STR_EXAMPLE_CHAPTER: "Capítol 21"
STR_EXAMPLE_BOOK: "Títol del llibre"
STR_PREVIEW: "Vista prèvia"
STR_TITLE: "Títol"
STR_BATTERY: "Bateria"
STR_UI_THEME: "Tema de la interfície"
STR_THEME_CLASSIC: "Clàssic"
STR_THEME_LYRA: "Lyra"
@@ -257,7 +229,6 @@ STR_SUNLIGHT_FADING_FIX: "Correcció de l'esvaïment pel sol"
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_RECENTS: "Recents"
STR_MENU_RECENT_BOOKS: "Llibres recents"
STR_NO_RECENT_BOOKS: "No hi ha llibres recents"
STR_CALIBRE_DESC: "Usa les transferències sense fils de Calibre"
@@ -281,12 +252,11 @@ STR_GO_TO_PERCENT: "Ves al %"
STR_GO_HOME_BUTTON: "Ves a l'inici"
STR_SYNC_PROGRESS: "Sincronitza el progrés"
STR_DELETE_CACHE: "Esborra la memòria cau del llibre"
STR_DELETE: "Esborra"
STR_DISPLAY_QR: "Mostra la pàgina com a QR"
STR_CHAPTER_PREFIX: "Capítol: "
STR_PAGES_SEPARATOR: " pàgines | "
STR_BOOK_PREFIX: "Llibre: "
STR_KBD_SHIFT: "maj"
STR_KBD_SHIFT_CAPS: "MAJ"
STR_KBD_LOCK: "BLOCA"
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
@@ -313,3 +283,9 @@ STR_UPLOAD: "Puja"
STR_BOOK_S_STYLE: "Estil del llibre"
STR_EMBEDDED_STYLE: "Estil incrustat"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_FOOTNOTES: "Notes al peu"
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
STR_LINK: "[enllaç]"
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
+2 -55
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Vstup do režimu spánku"
STR_BROWSE_FILES: "Procházet soubory"
STR_FILE_TRANSFER: "Přenos souborů"
STR_SETTINGS_TITLE: "Nastavení"
STR_CALIBRE_LIBRARY: "Knihovna Calibre"
STR_CONTINUE_READING: "Pokračovat ve čtení"
STR_NO_OPEN_BOOK: "Žádná otevřená kniha"
STR_START_READING: "Začněte číst níže"
STR_BOOKS: "Knihy"
STR_NO_BOOKS_FOUND: "Žádné knihy nenalezeny"
STR_NO_FILES_FOUND: "Nebyly nalezeny žádné soubory"
STR_SELECT_CHAPTER: "Vybrat kapitolu"
STR_NO_CHAPTERS: "Žádné kapitoly"
STR_END_OF_BOOK: "Konec knihy"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Prázdný soubor"
STR_OUT_OF_BOUNDS: "Mimo hranice"
STR_LOADING: "Načítání..."
STR_LOADING_POPUP: "Načítání"
STR_LOAD_XTC_FAILED: "Nepodařilo se načíst XTC"
STR_LOAD_TXT_FAILED: "Nepodařilo se načíst TXT"
STR_LOAD_EPUB_FAILED: "Nepodařilo se načíst EPUB"
STR_SD_CARD_ERROR: "Chyba SD karty"
STR_WIFI_NETWORKS: "WiFi sítě"
STR_NO_NETWORKS: "Žádné sítě nenalezeny"
STR_NETWORKS_FOUND: "Nalezeno %zu sítí"
@@ -37,14 +31,9 @@ STR_SCANNING: "Skenování..."
STR_CONNECTING: "Připojování..."
STR_CONNECTED: "Připojeno!"
STR_CONNECTION_FAILED: "Připojení se nezdařilo"
STR_CONNECTION_TIMEOUT: "Časový limit připojení"
STR_FORGET_NETWORK: "Zapomenout síť?"
STR_SAVE_PASSWORD: "Uložit heslo pro příště?"
STR_REMOVE_PASSWORD: "Odstranit uložené heslo?"
STR_PRESS_OK_SCAN: "Stiskněte OK pro přeskenování"
STR_PRESS_ANY_CONTINUE: "Pokračujte stiskem libovolné klávesy"
STR_SELECT_HINT: "VLEVO/VPRAVO: Vybrat | OK: Potvrdit"
STR_HOW_CONNECT: "Jak se chcete připojit?"
STR_JOIN_NETWORK: "Připojit se k síti"
STR_CREATE_HOTSPOT: "Vytvořit hotspot"
STR_JOIN_DESC: "Připojit se k existující síti WiFi"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "nebo http://"
STR_SCAN_QR_HINT: "nebo naskenujte QR kód telefonem:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_CALIBRE_WEB_URL: "URL webu Calibre"
STR_CONNECT_WIRELESS: "Připojit jako bezdrátové zařízení"
STR_NETWORK_LEGEND: "* = Šifrováno | + = Uloženo"
STR_MAC_ADDRESS: "MAC adresa:"
STR_CHECKING_WIFI: "Kontrola WiFi..."
STR_ENTER_WIFI_PASSWORD: "Zadejte heslo WiFi"
STR_ENTER_TEXT: "Zadejte text"
STR_TO_PREFIX: "pro"
STR_CALIBRE_DISCOVERING: "Prozkoumávání Calibre..."
STR_CALIBRE_CONNECTING_TO: "Připojování k"
STR_CALIBRE_CONNECTED_TO: "Připojeno k"
STR_CALIBRE_WAITING_COMMANDS: "Čekám na příkazy…"
STR_CONNECTION_FAILED_RETRYING: "(Připojení se nezdařilo, opakování pokusu)"
STR_CALIBRE_DISCONNECTED: "Calibre odpojeno"
STR_CALIBRE_WAITING_TRANSFER: "Čekání na přenos..."
STR_CALIBRE_TRANSFER_HINT: "Nezdaří-li se přenos, povolte\\n„Ignorovat volné místo“ v Calibre\\nnastavení pluginu SmartDevice."
STR_CALIBRE_RECEIVING: "Příjem:"
STR_CALIBRE_RECEIVED: "Přijato:"
STR_CALIBRE_WAITING_MORE: "Čekání na další..."
STR_CALIBRE_FAILED_CREATE_FILE: "Nepodařilo se vytvořit soubor"
STR_CALIBRE_PASSWORD_REQUIRED: "Vyžadováno heslo"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Přenos přerušen"
STR_CALIBRE_INSTRUCTION_1: "1) Nainstalujte plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Buďte ve stejné síti WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
@@ -88,37 +63,25 @@ STR_CAT_CONTROLS: "Ovládací prvky"
STR_CAT_SYSTEM: "Systém"
STR_SLEEP_SCREEN: "Obrazovka spánku"
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
STR_STATUS_BAR: "Stavový řádek"
STR_HIDE_BATTERY: "Skrýt baterii %"
STR_EXTRA_SPACING: "Extra mezery mezi odstavci"
STR_TEXT_AA: "Vyhlazování textu"
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
STR_ORIENTATION: "Orientace čtení"
STR_FRONT_BTN_LAYOUT: "Rozvržení předních tlačítek"
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
STR_LONG_PRESS_SKIP: "Dlouhé stisknutí Přeskočit kapitolu"
STR_FONT_FAMILY: "Rodina písem čtečky"
STR_EXT_READER_FONT: "Písmo externí čtečky"
STR_EXT_CHINESE_FONT: "Písmo čtečky"
STR_EXT_UI_FONT: "Písmo rozhraní"
STR_FONT_SIZE: "Velikost písma rozhraní"
STR_LINE_SPACING: "Řádkování čtečky"
STR_ASCII_LETTER_SPACING: "Mezery písmen ASCII"
STR_ASCII_DIGIT_SPACING: "Mezery číslic ASCII"
STR_CJK_SPACING: "Mezery CJK"
STR_COLOR_MODE: "Režim barev"
STR_SCREEN_MARGIN: "Okraj obrazovky čtečky"
STR_PARA_ALIGNMENT: "Zarovnání odstavců čtečky"
STR_HYPHENATION: "Dělení slov"
STR_TIME_TO_SLEEP: "Čas do uspání"
STR_REFRESH_FREQ: "Frekvence obnovení"
STR_CALIBRE_SETTINGS: "Nastavení Calibre"
STR_KOREADER_SYNC: "KOReaderu Sync"
STR_CHECK_UPDATES: "Zkontrolovat aktualizace"
STR_LANGUAGE: "Jazyk"
STR_SELECT_WALLPAPER: "Vybrat tapetu"
STR_CLEAR_READING_CACHE: "Vymazat mezipaměť čtení"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Uživatelské jméno"
STR_PASSWORD: "Heslo"
STR_SYNC_SERVER_URL: "URL synch. serveru"
@@ -153,8 +116,6 @@ STR_COVER: "Obálka"
STR_NONE_OPT: "Žádný"
STR_FIT: "Přizpůsobit"
STR_CROP: "Oříznout"
STR_NO_PROGRESS: "Žádný postup"
STR_FULL_OPT: "Plná"
STR_NEVER: "Nikdy"
STR_IN_READER: "Ve čtečce"
STR_ALWAYS: "Vždy"
@@ -165,9 +126,6 @@ STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
STR_INVERTED: "Invertovaný"
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
STR_FRONT_LAYOUT_BCLR: "Zpět, Potvrdit, Vlevo, Vpravo"
STR_FRONT_LAYOUT_LRBC: "Vlevo, Vpravo, Zpět, Potvrdit"
STR_FRONT_LAYOUT_LBCR: "Vlevo, Zpět, Potvrdit, Vpravo"
STR_PREV_NEXT: "Předchozí/Další"
STR_NEXT_PREV: "Další/Předchozí"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +162,6 @@ STR_NO_UPDATE: "Žádná aktualizace k dispozici"
STR_UPDATE_FAILED: "Aktualizace selhala"
STR_UPDATE_COMPLETE: "Aktualizace dokončena"
STR_POWER_ON_HINT: "Stiskněte a podržte tlačítko napájení pro opětovné zapnutí"
STR_EXTERNAL_FONT: "Externí písmo"
STR_BUILTIN_DISABLED: "Vestavěné (Zakázáno)"
STR_NO_ENTRIES: "Žádné položky nenalezeny"
STR_DOWNLOADING: "Stahování..."
STR_DOWNLOAD_FAILED: "Stahování selhalo"
@@ -216,7 +172,6 @@ STR_FETCH_FEED_FAILED: "Načtení kanálu se nezdařilo"
STR_PARSE_FEED_FAILED: "Analyzování kanálu se nezdařilo"
STR_NETWORK_PREFIX: "Síť:"
STR_IP_ADDRESS_PREFIX: "IP adresa:"
STR_SCAN_QR_WIFI_HINT: "nebo naskenujte QR kód telefonem pro připojení k WiFi."
STR_ERROR_GENERAL_FAILURE: "Chyba: Obecná chyba"
STR_ERROR_NETWORK_NOT_FOUND: "Chyba: Síť nenalezena"
STR_ERROR_CONNECTION_TIMEOUT: "Chyba: Časový limit připojení"
@@ -224,7 +179,6 @@ STR_SD_CARD: "SD karta"
STR_BACK: "« Zpět"
STR_EXIT: "« Konec"
STR_HOME: "« Domů"
STR_SAVE: "« Uložit"
STR_SELECT: "Vybrat"
STR_TOGGLE: "Přepnout"
STR_CONFIRM: "Potvrdit"
@@ -237,16 +191,12 @@ STR_YES: "Ano"
STR_NO: "Ne"
STR_STATE_ON: "ZAP"
STR_STATE_OFF: "VYP"
STR_SET: "Nastavit"
STR_NOT_SET: "Nenastaveno"
STR_DIR_LEFT: "Vlevo"
STR_DIR_RIGHT: "Vpravo"
STR_DIR_UP: "Nahoru"
STR_DIR_DOWN: "Dolů"
STR_CAPS_ON: "PÍSMO"
STR_CAPS_OFF: "písmo"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[ZAP]"
STR_SLEEP_COVER_FILTER: "Filtr obrazovky spánku"
STR_FILTER_CONTRAST: "Kontrast"
STR_UI_THEME: "Šablona rozhraní"
@@ -257,7 +207,6 @@ STR_SUNLIGHT_FADING_FIX: "Oprava blednutí na slunci"
STR_REMAP_FRONT_BUTTONS: "Přemapovat přední tlačítka"
STR_OPDS_BROWSER: "Prohlížeč OPDS"
STR_COVER_CUSTOM: "Obálka + Vlastní"
STR_RECENTS: "Nedávné"
STR_MENU_RECENT_BOOKS: "Nedávné knihy"
STR_NO_RECENT_BOOKS: "Žádné nedávné knihy"
STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre"
@@ -281,12 +230,10 @@ STR_GO_TO_PERCENT: "Přejít na %"
STR_GO_HOME_BUTTON: "Přejít Domů"
STR_SYNC_PROGRESS: "Průběh synchronizace"
STR_DELETE_CACHE: "Smazat mezipaměť knihy"
STR_DELETE: "Smazat"
STR_CHAPTER_PREFIX: "Kapitola:"
STR_PAGES_SEPARATOR: "stránek |"
STR_BOOK_PREFIX: "Kniha:"
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "ZÁMEK"
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
STR_PERCENT_STEP_HINT: "Vlevo/Vpravo: 1 % Nahoru/Dolů: 10 %"
STR_SYNCING_TIME: "Čas synchronizace..."
+1 -59
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Går i hvile"
STR_BROWSE_FILES: "Gennemsøg filer"
STR_FILE_TRANSFER: "Filoverførelse"
STR_SETTINGS_TITLE: "Indstillinger"
STR_CALIBRE_LIBRARY: "Calibre bibliotek"
STR_CONTINUE_READING: "Fortsæt med at læse"
STR_NO_OPEN_BOOK: "Ingen åben bog"
STR_START_READING: "Start læsning nedenfor"
STR_BOOKS: "Bøger"
STR_NO_BOOKS_FOUND: "Ingen bøger fundet"
STR_NO_FILES_FOUND: "Ingen filer fundet"
STR_SELECT_CHAPTER: "Vælg kapitel"
STR_NO_CHAPTERS: "Ingen kapitler"
STR_END_OF_BOOK: "Bogen er færdig"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Tom fil"
STR_OUT_OF_BOUNDS: "Uden for grænsen"
STR_LOADING: "Indlæser..."
STR_LOADING_POPUP: "Indlæser"
STR_LOAD_XTC_FAILED: "Mislykkedes at indlæse XTC"
STR_LOAD_TXT_FAILED: "Mislykkedes at indlæse TXT"
STR_LOAD_EPUB_FAILED: "Mislykkedes at indlæse EPUB"
STR_SD_CARD_ERROR: "SD kort fejl"
STR_WIFI_NETWORKS: "Trådløse netværk"
STR_NO_NETWORKS: "Intet netværk fundet"
STR_NETWORKS_FOUND: "%zu netværk fundet"
@@ -37,14 +31,9 @@ STR_SCANNING: "Skanner..."
STR_CONNECTING: "Forbinder..."
STR_CONNECTED: "Forbundet!"
STR_CONNECTION_FAILED: "Forbindelsen mislykkedes"
STR_CONNECTION_TIMEOUT: "Forbindelses timeout"
STR_FORGET_NETWORK: "Glem netværk?"
STR_SAVE_PASSWORD: "Gem adgangskode til næste gang?"
STR_REMOVE_PASSWORD: "Fjern gemt adgangskode?"
STR_PRESS_OK_SCAN: "Tryk OK for at scanne igen"
STR_PRESS_ANY_CONTINUE: "Tryk på en knap for at fortsætte"
STR_SELECT_HINT: "VENSTRE/HØJRE: Vælg | OK: Bekræft"
STR_HOW_CONNECT: "Hvordan vil du oprette forbindelse?"
STR_JOIN_NETWORK: "Tilslut netværk"
STR_CREATE_HOTSPOT: "Opret Hotspot"
STR_JOIN_DESC: "Opret forbindelse til et eksisterende WiFi-netværk"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "eller http://"
STR_SCAN_QR_HINT: "eller scan QR-kode med din telefon:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_CALIBRE_WEB_URL: "Calibre Web URL"
STR_CONNECT_WIRELESS: "Opret forbindelse som trådløs enhed"
STR_NETWORK_LEGEND: "* = Krypteret | + = Gemt"
STR_MAC_ADDRESS: "MAC-adresse:"
STR_CHECKING_WIFI: "Tjekker WiFi..."
STR_ENTER_WIFI_PASSWORD: "Indtast WiFi-adgangskode"
STR_ENTER_TEXT: "Indtast tekst"
STR_TO_PREFIX: "til "
STR_CALIBRE_DISCOVERING: "Opdager Calibre..."
STR_CALIBRE_CONNECTING_TO: "Forbinder til "
STR_CALIBRE_CONNECTED_TO: "Forbundet til "
STR_CALIBRE_WAITING_COMMANDS: "Venter på kommandoer..."
STR_CONNECTION_FAILED_RETRYING: "(Forbindelsen mislykkedes, prøver igen)"
STR_CALIBRE_DISCONNECTED: "Calibre afbrudt"
STR_CALIBRE_WAITING_TRANSFER: "Venter på overførelse..."
STR_CALIBRE_TRANSFER_HINT: "Hvis overførslen mislykkes, aktiver\\n'Ignorer ledig plads' i Calibres\\nSmartDevice-plugin-indstillinger."
STR_CALIBRE_RECEIVING: "Modtager: "
STR_CALIBRE_RECEIVED: "Modtaget: "
STR_CALIBRE_WAITING_MORE: "Venter på mere..."
STR_CALIBRE_FAILED_CREATE_FILE: "Kunne ikke oprette fil"
STR_CALIBRE_PASSWORD_REQUIRED: "Adgangskode påkrævet"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Overførelse afbrudt"
STR_CALIBRE_INSTRUCTION_1: "1) Installer CrossPoint Reader-plugin"
STR_CALIBRE_INSTRUCTION_2: "2) Vær på det samme WiFi-netværk"
STR_CALIBRE_INSTRUCTION_3: "3) I Calibre: \"Send til enhed\""
@@ -88,37 +63,25 @@ STR_CAT_CONTROLS: "Brugerflade"
STR_CAT_SYSTEM: "System"
STR_SLEEP_SCREEN: "Hvile-skærm"
STR_SLEEP_COVER_MODE: "Hvile-skærm omslag-tilstand"
STR_STATUS_BAR: "Statuslinje"
STR_HIDE_BATTERY: "Skjul batteri %"
STR_EXTRA_SPACING: "Ekstra afsnitsafstand"
STR_TEXT_AA: "Tekst Anti-Aliasing"
STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap"
STR_ORIENTATION: "Læseretning"
STR_FRONT_BTN_LAYOUT: "Knaplayout foran"
STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)"
STR_LONG_PRESS_SKIP: "Langt tryk spring kapitel over"
STR_FONT_FAMILY: "Læser skrifttype"
STR_EXT_READER_FONT: "Ekstern læserskrifttype"
STR_EXT_CHINESE_FONT: "Læserskrifttype"
STR_EXT_UI_FONT: "Brugergrænseflade skrifttype"
STR_FONT_SIZE: "Brugergrænseflade skriftstørrelse"
STR_LINE_SPACING: "Linjeafstand"
STR_ASCII_LETTER_SPACING: "ASCII bogstavafstand"
STR_ASCII_DIGIT_SPACING: "ASCII cifreafstand"
STR_CJK_SPACING: "CJK afstand"
STR_COLOR_MODE: "Farvetilstand"
STR_SCREEN_MARGIN: "Skærmmargen"
STR_PARA_ALIGNMENT: "Afsnitsjustering"
STR_HYPHENATION: "Orddeling"
STR_TIME_TO_SLEEP: "Tid til hvile"
STR_REFRESH_FREQ: "Opdateringsfrekvens"
STR_CALIBRE_SETTINGS: "Calibre-indstillinger"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Søg efter opdateringer"
STR_LANGUAGE: "Sprog"
STR_SELECT_WALLPAPER: "Vælg baggrundsbillede"
STR_CLEAR_READING_CACHE: "Ryd læsecache"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Brugernavn"
STR_PASSWORD: "Adgangskode"
STR_SYNC_SERVER_URL: "Synkroniseringsserver-URL"
@@ -153,8 +116,6 @@ STR_COVER: "Omslag"
STR_NONE_OPT: "Ingen"
STR_FIT: "Tilpas"
STR_CROP: "Beskær"
STR_NO_PROGRESS: "Ingen fremskridt"
STR_FULL_OPT: "Fuld"
STR_NEVER: "Aldrig"
STR_IN_READER: "I læseren"
STR_ALWAYS: "Altid"
@@ -165,9 +126,6 @@ STR_PORTRAIT: "Portræt"
STR_LANDSCAPE_CW: "Liggende med uret"
STR_INVERTED: "Inverteret"
STR_LANDSCAPE_CCW: "Liggende mod uret"
STR_FRONT_LAYOUT_BCLR: "Bck, Cnfrm, Lft, Rght"
STR_FRONT_LAYOUT_LRBC: "Lft, Rght, Bck, Cnfrm"
STR_FRONT_LAYOUT_LBCR: "Lft, Bck, Cnfrm, Rght"
STR_PREV_NEXT: "Forrige/Næste"
STR_NEXT_PREV: "Næste/Forrige"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +162,6 @@ STR_NO_UPDATE: "Ingen opdatering tilgængelig"
STR_UPDATE_FAILED: "Opdatering mislykkedes"
STR_UPDATE_COMPLETE: "Opdatering færdig"
STR_POWER_ON_HINT: "Hold tænd/sluk-knappen nede for at tænde igen"
STR_EXTERNAL_FONT: "Ekstern skrifttype"
STR_BUILTIN_DISABLED: "Indbygget (deaktiveret)"
STR_NO_ENTRIES: "Ingen poster fundet"
STR_DOWNLOADING: "Downloader..."
STR_DOWNLOAD_FAILED: "Download mislykkedes"
@@ -216,7 +172,6 @@ STR_FETCH_FEED_FAILED: "Kunne ikke hente feed"
STR_PARSE_FEED_FAILED: "Kunne ikke fortolke feed"
STR_NETWORK_PREFIX: "Netværk: "
STR_IP_ADDRESS_PREFIX: "IP-adresse: "
STR_SCAN_QR_WIFI_HINT: "eller scan QR-kode med din telefon for at oprette forbindelse til WiFi."
STR_ERROR_GENERAL_FAILURE: "Fejl: Generel fejl"
STR_ERROR_NETWORK_NOT_FOUND: "Fejl: Netværk ikke fundet"
STR_ERROR_CONNECTION_TIMEOUT: "Fejl: Forbindelses timeout"
@@ -224,7 +179,6 @@ STR_SD_CARD: "SD-kort"
STR_BACK: "« Tilbage"
STR_EXIT: "« Afslut"
STR_HOME: "« Hjem"
STR_SAVE: "« Gem"
STR_SELECT: "Vælg"
STR_TOGGLE: "Skift"
STR_CONFIRM: "Bekræft"
@@ -237,22 +191,14 @@ STR_YES: "Ja"
STR_NO: "Nej"
STR_STATE_ON: "TÆNDT"
STR_STATE_OFF: "SLUKKET"
STR_SET: "Indstil"
STR_NOT_SET: "Ikke indstillet"
STR_DIR_LEFT: "Venstre"
STR_DIR_RIGHT: "Højre"
STR_DIR_UP: "Op"
STR_DIR_DOWN: "Ned"
STR_CAPS_ON: "CAPS"
STR_CAPS_OFF: "caps"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[ON]"
STR_SLEEP_COVER_FILTER: "Hvile-skærm omslag-filter"
STR_FILTER_CONTRAST: "Kontrast"
STR_STATUS_BAR_FULL_PERCENT: "Fuld m/ procent"
STR_STATUS_BAR_FULL_BOOK: "Fuld m/ boglinje"
STR_STATUS_BAR_BOOK_ONLY: "Kun boglinje"
STR_STATUS_BAR_FULL_CHAPTER: "Fuld m/ kapitellinje"
STR_UI_THEME: "Brugergrænseflade tema"
STR_THEME_CLASSIC: "Klassisk"
STR_THEME_LYRA: "Lyra"
@@ -261,7 +207,6 @@ STR_SUNLIGHT_FADING_FIX: "Sollysfading-rettelse"
STR_REMAP_FRONT_BUTTONS: "Omtildel frontknapper"
STR_OPDS_BROWSER: "OPDS Browser"
STR_COVER_CUSTOM: "Omslag + Brugerdefineret"
STR_RECENTS: "Seneste"
STR_MENU_RECENT_BOOKS: "Seneste bøger"
STR_NO_RECENT_BOOKS: "Ingen seneste bøger"
STR_CALIBRE_DESC: "Brug Calibre trådløs enhedsoverførelse"
@@ -288,9 +233,6 @@ STR_DELETE_CACHE: "Slet bogcache"
STR_CHAPTER_PREFIX: "Kapitel: "
STR_PAGES_SEPARATOR: " sider | "
STR_BOOK_PREFIX: "Bog: "
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "LOCK"
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
STR_PERCENT_STEP_HINT: "Venstre/Højre: 1% Op/Ned: 10%"
STR_SYNCING_TIME: "Synkroniserer tid..."
+283
View File
@@ -0,0 +1,283 @@
_language_name: "Nederlands"
_language_code: "NL"
_order: "16"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "OPSTARTEN"
STR_SLEEPING: "SLAAPSTAND"
STR_ENTERING_SLEEP: "Gaat in slaapstand"
STR_BROWSE_FILES: "Bestanden bladeren"
STR_FILE_TRANSFER: "Bestandsoverdracht"
STR_SETTINGS_TITLE: "Instellingen"
STR_CONTINUE_READING: "Verder lezen"
STR_NO_OPEN_BOOK: "Geen geopend boek"
STR_START_READING: "Begin hieronder met lezen"
STR_NO_FILES_FOUND: "Geen bestanden gevonden"
STR_SELECT_CHAPTER: "Selecteer hoofdstuk"
STR_NO_CHAPTERS: "Geen hoofdstukken"
STR_END_OF_BOOK: "Einde van boek"
STR_EMPTY_CHAPTER: "Leeg hoofdstuk"
STR_INDEXING: "Indexeren"
STR_MEMORY_ERROR: "Geheugenfout"
STR_PAGE_LOAD_ERROR: "Fout bij laden pagina"
STR_EMPTY_FILE: "Leeg bestand"
STR_OUT_OF_BOUNDS: "Buiten bereik"
STR_LOADING: "Laden..."
STR_LOADING_POPUP: "Laden"
STR_WIFI_NETWORKS: "Wifi-netwerken"
STR_NO_NETWORKS: "Geen netwerken gevonden"
STR_NETWORKS_FOUND: "%zu netwerken gevonden"
STR_SCANNING: "Scannen..."
STR_CONNECTING: "Verbinden..."
STR_CONNECTED: "Verbonden!"
STR_CONNECTION_FAILED: "Verbinding mislukt"
STR_FORGET_NETWORK: "Netwerk vergeten?"
STR_SAVE_PASSWORD: "Wachtwoord opslaan voor volgende keer?"
STR_PRESS_OK_SCAN: "Druk op OK om opnieuw te scannen"
STR_JOIN_NETWORK: "Verbind met netwerk"
STR_CREATE_HOTSPOT: "Hotspot maken"
STR_JOIN_DESC: "Verbind met een bestaand wifi-netwerk"
STR_HOTSPOT_DESC: "Maak een wifi-netwerk waar anderen mee kunnen verbinden"
STR_STARTING_HOTSPOT: "Hotspot starten..."
STR_HOTSPOT_MODE: "Hotspot-modus"
STR_CONNECT_WIFI_HINT: "Verbind je apparaat met dit wifi-netwerk"
STR_OPEN_URL_HINT: "Open deze URL in je browser"
STR_OR_HTTP_PREFIX: "of http://"
STR_SCAN_QR_HINT: "of scan de QR-code met je telefoon:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_CALIBRE_WEB_URL: "Calibre Web URL"
STR_NETWORK_LEGEND: "* = Beveiligd | + = Opgeslagen"
STR_MAC_ADDRESS: "MAC-adres:"
STR_CHECKING_WIFI: "Wifi controleren..."
STR_ENTER_WIFI_PASSWORD: "Voer wifi-wachtwoord in"
STR_TO_PREFIX: "met "
STR_CALIBRE_RECEIVING: "Bezig met ontvangen: "
STR_CALIBRE_RECEIVED: "Ontvangen: "
STR_CALIBRE_INSTRUCTION_1: "1) Installeer CrossPoint Reader plugin"
STR_CALIBRE_INSTRUCTION_2: "2) Gebruik hetzelfde wifi-netwerk"
STR_CALIBRE_INSTRUCTION_3: "3) In Calibre: \"Send to device\""
STR_CALIBRE_INSTRUCTION_4: "\"Houd dit scherm open tijdens verzenden\""
STR_CAT_DISPLAY: "Scherm"
STR_CAT_READER: "Lezer"
STR_CAT_CONTROLS: "Bediening"
STR_CAT_SYSTEM: "Systeem"
STR_SLEEP_SCREEN: "Slaapscherm"
STR_SLEEP_COVER_MODE: "Slaapscherm omslag-modus"
STR_HIDE_BATTERY: "Batterij % verbergen"
STR_EXTRA_SPACING: "Extra regelafstand alinea"
STR_TEXT_AA: "Tekst Anti-Aliasing"
STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop"
STR_ORIENTATION: "Leesstand"
STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)"
STR_LONG_PRESS_SKIP: "Hoofdstuk overslaan (lang indrukken)"
STR_FONT_FAMILY: "Lettertype lezer"
STR_FONT_SIZE: "Lettergrootte lezer"
STR_LINE_SPACING: "Regelafstand lezer"
STR_SCREEN_MARGIN: "Schermmarge lezer"
STR_PARA_ALIGNMENT: "Uitlijning alinea lezer"
STR_HYPHENATION: "Woordafbreking"
STR_TIME_TO_SLEEP: "Tijd tot slaapstand"
STR_REFRESH_FREQ: "Verversingsfrequentie"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Controleren op updates"
STR_LANGUAGE: "Taal"
STR_CLEAR_READING_CACHE: "Leescache wissen"
STR_USERNAME: "Gebruikersnaam"
STR_PASSWORD: "Wachtwoord"
STR_SYNC_SERVER_URL: "Sync-server URL"
STR_DOCUMENT_MATCHING: "Documentkoppeling"
STR_AUTHENTICATE: "Authenticatie"
STR_KOREADER_USERNAME: "KOReader gebruikersnaam"
STR_KOREADER_PASSWORD: "KOReader wachtwoord"
STR_FILENAME: "Bestandsnaam"
STR_BINARY: "Binair"
STR_SET_CREDENTIALS_FIRST: "Stel eerst inloggegevens in"
STR_WIFI_CONN_FAILED: "Wifi-verbinding mislukt"
STR_AUTHENTICATING: "Authenticeren..."
STR_AUTH_SUCCESS: "Authenticatie geslaagd!"
STR_KOREADER_AUTH: "KOReader-authenticatie"
STR_SYNC_READY: "KOReader sync is klaar voor gebruik"
STR_AUTH_FAILED: "Authenticatie mislukt"
STR_DONE: "Klaar"
STR_CLEAR_CACHE_WARNING_1: "Dit wist alle gecachte boekgegevens."
STR_CLEAR_CACHE_WARNING_2: "Alle leesvoortgang gaat verloren!"
STR_CLEAR_CACHE_WARNING_3: "Boeken moeten opnieuw worden geïndexeerd"
STR_CLEAR_CACHE_WARNING_4: "wanneer ze weer worden geopend."
STR_CLEARING_CACHE: "Cache wissen..."
STR_CACHE_CLEARED: "Cache gewist"
STR_ITEMS_REMOVED: "items verwijderd"
STR_FAILED_LOWER: "mislukt"
STR_CLEAR_CACHE_FAILED: "Cache wissen mislukt"
STR_CHECK_SERIAL_OUTPUT: "Check seriële output voor details"
STR_DARK: "Donker"
STR_LIGHT: "Licht"
STR_CUSTOM: "Aangepast"
STR_COVER: "Omslag"
STR_NONE_OPT: "Geen"
STR_FIT: "Passend"
STR_CROP: "Bijsnijden"
STR_NEVER: "Nooit"
STR_IN_READER: "In lezer"
STR_ALWAYS: "Altijd"
STR_IGNORE: "Negeren"
STR_SLEEP: "Slaap"
STR_PAGE_TURN: "Pagina omslaan"
STR_PORTRAIT: "Staand"
STR_LANDSCAPE_CW: "Liggend (rechtsom)"
STR_INVERTED: "Omgekeerd"
STR_LANDSCAPE_CCW: "Liggend (linksom)"
STR_PREV_NEXT: "Vorige/Volgende"
STR_NEXT_PREV: "Volgende/Vorige"
STR_BOOKERLY: "Bookerly"
STR_NOTO_SANS: "Noto Sans"
STR_OPEN_DYSLEXIC: "Open Dyslexic"
STR_SMALL: "Klein"
STR_MEDIUM: "Gemiddeld"
STR_LARGE: "Groot"
STR_X_LARGE: "Extra groot"
STR_TIGHT: "Smal"
STR_NORMAL: "Normaal"
STR_WIDE: "Breed"
STR_JUSTIFY: "Uitvullen"
STR_ALIGN_LEFT: "Links"
STR_CENTER: "Centreren"
STR_ALIGN_RIGHT: "Rechts"
STR_MIN_1: "1 min"
STR_MIN_5: "5 min"
STR_MIN_10: "10 min"
STR_MIN_15: "15 min"
STR_MIN_30: "30 min"
STR_PAGES_1: "1 pagina"
STR_PAGES_5: "5 pagina's"
STR_PAGES_10: "10 pagina's"
STR_PAGES_15: "15 pagina's"
STR_PAGES_30: "30 pagina's"
STR_UPDATE: "Update"
STR_CHECKING_UPDATE: "Controleren op update..."
STR_NEW_UPDATE: "Nieuwe update beschikbaar!"
STR_CURRENT_VERSION: "Huidige versie: "
STR_NEW_VERSION: "Nieuwe versie: "
STR_UPDATING: "Updaten..."
STR_NO_UPDATE: "Geen update beschikbaar"
STR_UPDATE_FAILED: "Update mislukt"
STR_UPDATE_COMPLETE: "Update voltooid"
STR_POWER_ON_HINT: "Houd de aan/uit-knop ingedrukt om in te schakelen"
STR_NO_ENTRIES: "Geen items gevonden"
STR_DOWNLOADING: "Downloaden..."
STR_DOWNLOAD_FAILED: "Download mislukt"
STR_ERROR_MSG: "Fout:"
STR_UNNAMED: "Naamloos"
STR_NO_SERVER_URL: "Geen server-URL ingesteld"
STR_FETCH_FEED_FAILED: "Ophalen feed mislukt"
STR_PARSE_FEED_FAILED: "Verwerken feed mislukt"
STR_NETWORK_PREFIX: "Netwerk: "
STR_IP_ADDRESS_PREFIX: "IP-adres: "
STR_ERROR_GENERAL_FAILURE: "Fout: Algemene fout"
STR_ERROR_NETWORK_NOT_FOUND: "Fout: Netwerk niet gevonden"
STR_ERROR_CONNECTION_TIMEOUT: "Fout: Verbindingstime-out"
STR_SD_CARD: "SD-kaart"
STR_BACK: "« Terug"
STR_EXIT: "« Sluit"
STR_HOME: "« Home"
STR_SELECT: "Kies"
STR_TOGGLE: "Wissel"
STR_CONFIRM: "Bevestig"
STR_CANCEL: "Annuleer"
STR_CONNECT: "Verbind"
STR_OPEN: "Open"
STR_DOWNLOAD: "Download"
STR_RETRY: "Opnieuw"
STR_YES: "Ja"
STR_NO: "Nee"
STR_SHOW: "Toon"
STR_HIDE: "Verberg"
STR_STATE_ON: "AAN"
STR_STATE_OFF: "UIT"
STR_NOT_SET: "Niet ingesteld"
STR_DIR_LEFT: "Links"
STR_DIR_RIGHT: "Rechts"
STR_DIR_UP: "Omhoog"
STR_DIR_DOWN: "Omlaag"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Slaapscherm omslag-filter"
STR_FILTER_CONTRAST: "Contrast"
STR_CUSTOMISE_STATUS_BAR: "Statusbalk aanpassen"
STR_CHAPTER_PAGE_COUNT: "Paginanummering hoofdstuk"
STR_BOOK_PROGRESS_PERCENTAGE: "Percentage voortgang boek"
STR_PROGRESS_BAR: "Voortgangsbalk"
STR_PROGRESS_BAR_THICKNESS: "Dikte voortgangsbalk"
STR_PROGRESS_BAR_THIN: "Dun"
STR_PROGRESS_BAR_MEDIUM: "Gemiddeld"
STR_PROGRESS_BAR_THICK: "Dik"
STR_BOOK: "Boek"
STR_CHAPTER: "Hoofdstuk"
STR_EXAMPLE_CHAPTER: "Hoofdstuk 21"
STR_EXAMPLE_BOOK: "Boektitel"
STR_PREVIEW: "Voorbeeld"
STR_TITLE: "Titel"
STR_BATTERY: "Batterij"
STR_UI_THEME: "UI Thema"
STR_THEME_CLASSIC: "Klassiek"
STR_THEME_LYRA: "Lyra"
STR_THEME_LYRA_EXTENDED: "Lyra Uitgebreid"
STR_SUNLIGHT_FADING_FIX: "Zonlicht vervaging fix"
STR_REMAP_FRONT_BUTTONS: "Knoppen voorzijde wijzigen"
STR_OPDS_BROWSER: "OPDS-browser"
STR_COVER_CUSTOM: "Omslag + Aangepast"
STR_MENU_RECENT_BOOKS: "Recente boeken"
STR_NO_RECENT_BOOKS: "Geen recente boeken"
STR_CALIBRE_DESC: "Gebruik Calibre draadloze overdracht"
STR_FORGET_AND_REMOVE: "Netwerk vergeten en wachtwoord verwijderen?"
STR_FORGET_BUTTON: "Vergeet"
STR_CALIBRE_STARTING: "Calibre starten..."
STR_CALIBRE_SETUP: "Installatie"
STR_CALIBRE_STATUS: "Status"
STR_CLEAR_BUTTON: "Wis"
STR_DEFAULT_VALUE: "Standaard"
STR_REMAP_PROMPT: "Druk op een knop voorop voor elke functie"
STR_UNASSIGNED: "Niet toegewezen"
STR_ALREADY_ASSIGNED: "Al toegewezen"
STR_REMAP_RESET_HINT: "Zijknop Omhoog: Standaardindeling herstellen"
STR_REMAP_CANCEL_HINT: "Zijknop Omlaag: Toewijzen annuleren"
STR_HW_BACK_LABEL: "Terug (1e knop)"
STR_HW_CONFIRM_LABEL: "Bevestig (2e knop)"
STR_HW_LEFT_LABEL: "Links (3e knop)"
STR_HW_RIGHT_LABEL: "Rechts (4e knop)"
STR_GO_TO_PERCENT: "Ga naar %"
STR_GO_HOME_BUTTON: "Naar Home"
STR_SYNC_PROGRESS: "Voortgang synchroniseren"
STR_DELETE_CACHE: "Boekcache verwijderen"
STR_DISPLAY_QR: "Pagina als QR tonen"
STR_CHAPTER_PREFIX: "Hoofdstuk: "
STR_PAGES_SEPARATOR: " pagina's | "
STR_BOOK_PREFIX: "Boek: "
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
STR_PERCENT_STEP_HINT: "Links/Rechts: 1% Omhoog/Omlaag: 10%"
STR_SYNCING_TIME: "Tijd synchroniseren..."
STR_CALC_HASH: "Document-hash berekenen..."
STR_HASH_FAILED: "Document-hash berekenen mislukt"
STR_FETCH_PROGRESS: "Voortgang ophalen..."
STR_UPLOAD_PROGRESS: "Voortgang uploaden..."
STR_NO_CREDENTIALS_MSG: "Geen inloggegevens ingesteld"
STR_KOREADER_SETUP_HINT: "Stel KOReader-account in bij Instellingen"
STR_PROGRESS_FOUND: "Voortgang gevonden!"
STR_REMOTE_LABEL: "Extern:"
STR_LOCAL_LABEL: "Lokaal:"
STR_PAGE_OVERALL_FORMAT: "Pagina %d, %.2f%% totaal"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% totaal"
STR_DEVICE_FROM_FORMAT: " Van: %s"
STR_APPLY_REMOTE: "Externe voortgang toepassen"
STR_UPLOAD_LOCAL: "Lokale voortgang uploaden"
STR_NO_REMOTE_MSG: "Geen externe voortgang gevonden"
STR_UPLOAD_PROMPT: "Huidige positie uploaden?"
STR_UPLOAD_SUCCESS: "Voortgang geüpload!"
STR_SYNC_FAILED_MSG: "Sync mislukt"
STR_SECTION_PREFIX: "Sectie "
STR_UPLOAD: "Uploaden"
STR_BOOK_S_STYLE: "Stijl van boek"
STR_EMBEDDED_STYLE: "Ingebedde stijl"
STR_OPDS_SERVER_URL: "OPDS-server URL"
STR_FOOTNOTES: "Voetnoten"
STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Screenshot maken"
+10 -55
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Going to sleep"
STR_BROWSE_FILES: "Browse Files"
STR_FILE_TRANSFER: "File Transfer"
STR_SETTINGS_TITLE: "Settings"
STR_CALIBRE_LIBRARY: "Calibre Library"
STR_CONTINUE_READING: "Continue Reading"
STR_NO_OPEN_BOOK: "No open book"
STR_START_READING: "Start reading below"
STR_BOOKS: "Books"
STR_NO_BOOKS_FOUND: "No books found"
STR_NO_FILES_FOUND: "No files found"
STR_SELECT_CHAPTER: "Select Chapter"
STR_NO_CHAPTERS: "No chapters"
STR_END_OF_BOOK: "End of book"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Empty file"
STR_OUT_OF_BOUNDS: "Out of bounds"
STR_LOADING: "Loading..."
STR_LOADING_POPUP: "Loading"
STR_LOAD_XTC_FAILED: "Failed to load XTC"
STR_LOAD_TXT_FAILED: "Failed to load TXT"
STR_LOAD_EPUB_FAILED: "Failed to load EPUB"
STR_SD_CARD_ERROR: "SD card error"
STR_WIFI_NETWORKS: "WiFi Networks"
STR_NO_NETWORKS: "No networks found"
STR_NETWORKS_FOUND: "%zu networks found"
@@ -37,14 +31,9 @@ STR_SCANNING: "Scanning..."
STR_CONNECTING: "Connecting..."
STR_CONNECTED: "Connected!"
STR_CONNECTION_FAILED: "Connection Failed"
STR_CONNECTION_TIMEOUT: "Connection timeout"
STR_FORGET_NETWORK: "Forget Network?"
STR_SAVE_PASSWORD: "Save password for next time?"
STR_REMOVE_PASSWORD: "Remove saved password?"
STR_PRESS_OK_SCAN: "Press OK to scan again"
STR_PRESS_ANY_CONTINUE: "Press any button to continue"
STR_SELECT_HINT: "LEFT/RIGHT: Select | OK: Confirm"
STR_HOW_CONNECT: "How would you like to connect?"
STR_JOIN_NETWORK: "Join a Network"
STR_CREATE_HOTSPOT: "Create Hotspot"
STR_JOIN_DESC: "Connect to an existing WiFi network"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "or http://"
STR_SCAN_QR_HINT: "or scan QR code with your phone:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_CALIBRE_WEB_URL: "Calibre Web URL"
STR_CONNECT_WIRELESS: "Connect as Wireless Device"
STR_NETWORK_LEGEND: "* = Encrypted | + = Saved"
STR_MAC_ADDRESS: "MAC address:"
STR_CHECKING_WIFI: "Checking WiFi..."
STR_ENTER_WIFI_PASSWORD: "Enter WiFi Password"
STR_ENTER_TEXT: "Enter Text"
STR_TO_PREFIX: "to "
STR_CALIBRE_DISCOVERING: "Discovering Calibre..."
STR_CALIBRE_CONNECTING_TO: "Connecting to "
STR_CALIBRE_CONNECTED_TO: "Connected to "
STR_CALIBRE_WAITING_COMMANDS: "Waiting for commands..."
STR_CONNECTION_FAILED_RETRYING: "(Connection failed, retrying)"
STR_CALIBRE_DISCONNECTED: "Calibre disconnected"
STR_CALIBRE_WAITING_TRANSFER: "Waiting for transfer..."
STR_CALIBRE_TRANSFER_HINT: "If transfer fails, enable\\n'Ignore free space' in Calibre's\\nSmartDevice plugin settings."
STR_CALIBRE_RECEIVING: "Receiving: "
STR_CALIBRE_RECEIVED: "Received: "
STR_CALIBRE_WAITING_MORE: "Waiting for more..."
STR_CALIBRE_FAILED_CREATE_FILE: "Failed to create file"
STR_CALIBRE_PASSWORD_REQUIRED: "Password required"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Transfer interrupted"
STR_CALIBRE_INSTRUCTION_1: "1) Install CrossPoint Reader plugin"
STR_CALIBRE_INSTRUCTION_2: "2) Be on the same WiFi network"
STR_CALIBRE_INSTRUCTION_3: "3) In Calibre: \"Send to device\""
@@ -88,37 +63,30 @@ STR_CAT_CONTROLS: "Controls"
STR_CAT_SYSTEM: "System"
STR_SLEEP_SCREEN: "Sleep Screen"
STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
STR_STATUS_BAR: "Status Bar"
STR_HIDE_BATTERY: "Hide Battery %"
STR_EXTRA_SPACING: "Extra Paragraph Spacing"
STR_TEXT_AA: "Text Anti-Aliasing"
STR_IMAGES: "Images"
STR_IMAGES_DISPLAY: "Display"
STR_IMAGES_PLACEHOLDER: "Placeholder"
STR_IMAGES_SUPPRESS: "Suppress"
STR_SHORT_PWR_BTN: "Short Power Button Click"
STR_ORIENTATION: "Reading Orientation"
STR_FRONT_BTN_LAYOUT: "Front Button Layout"
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
STR_LONG_PRESS_SKIP: "Long-press Chapter Skip"
STR_FONT_FAMILY: "Reader Font Family"
STR_EXT_READER_FONT: "External Reader Font"
STR_EXT_CHINESE_FONT: "Reader Font"
STR_EXT_UI_FONT: "UI Font"
STR_FONT_SIZE: "Reader Font Size"
STR_LINE_SPACING: "Reader Line Spacing"
STR_ASCII_LETTER_SPACING: "ASCII Letter Spacing"
STR_ASCII_DIGIT_SPACING: "ASCII Digit Spacing"
STR_CJK_SPACING: "CJK Spacing"
STR_COLOR_MODE: "Color Mode"
STR_SCREEN_MARGIN: "Reader Screen Margin"
STR_PARA_ALIGNMENT: "Reader Paragraph Alignment"
STR_HYPHENATION: "Hyphenation"
STR_TIME_TO_SLEEP: "Time to Sleep"
STR_SHOW_HIDDEN_FILES: "Show Hidden Files"
STR_REFRESH_FREQ: "Refresh Frequency"
STR_CALIBRE_SETTINGS: "Calibre Settings"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Check for updates"
STR_LANGUAGE: "Language"
STR_SELECT_WALLPAPER: "Select Wallpaper"
STR_CLEAR_READING_CACHE: "Clear Reading Cache"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Username"
STR_PASSWORD: "Password"
STR_SYNC_SERVER_URL: "Sync Server URL"
@@ -153,8 +121,6 @@ STR_COVER: "Cover"
STR_NONE_OPT: "None"
STR_FIT: "Fit"
STR_CROP: "Crop"
STR_NO_PROGRESS: "No Progress"
STR_FULL_OPT: "Full"
STR_NEVER: "Never"
STR_IN_READER: "In Reader"
STR_ALWAYS: "Always"
@@ -165,9 +131,6 @@ STR_PORTRAIT: "Portrait"
STR_LANDSCAPE_CW: "Landscape CW"
STR_INVERTED: "Inverted"
STR_LANDSCAPE_CCW: "Landscape CCW"
STR_FRONT_LAYOUT_BCLR: "Bck, Cnfrm, Lft, Rght"
STR_FRONT_LAYOUT_LRBC: "Lft, Rght, Bck, Cnfrm"
STR_FRONT_LAYOUT_LBCR: "Lft, Bck, Cnfrm, Rght"
STR_PREV_NEXT: "Prev/Next"
STR_NEXT_PREV: "Next/Prev"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +167,6 @@ STR_NO_UPDATE: "No update available"
STR_UPDATE_FAILED: "Update failed"
STR_UPDATE_COMPLETE: "Update complete"
STR_POWER_ON_HINT: "Press and hold power button to turn back on"
STR_EXTERNAL_FONT: "External Font"
STR_BUILTIN_DISABLED: "Built-in (Disabled)"
STR_NO_ENTRIES: "No entries found"
STR_DOWNLOADING: "Downloading..."
STR_DOWNLOAD_FAILED: "Download failed"
@@ -216,7 +177,6 @@ STR_FETCH_FEED_FAILED: "Failed to fetch feed"
STR_PARSE_FEED_FAILED: "Failed to parse feed"
STR_NETWORK_PREFIX: "Network: "
STR_IP_ADDRESS_PREFIX: "IP Address: "
STR_SCAN_QR_WIFI_HINT: "or scan QR code with your phone to connect to Wifi."
STR_ERROR_GENERAL_FAILURE: "Error: General failure"
STR_ERROR_NETWORK_NOT_FOUND: "Error: Network not found"
STR_ERROR_CONNECTION_TIMEOUT: "Error: Connection timeout"
@@ -224,8 +184,8 @@ STR_SD_CARD: "SD card"
STR_BACK: "« Back"
STR_EXIT: "« Exit"
STR_HOME: "« Home"
STR_SAVE: "« Save"
STR_SELECT: "Select"
STR_SELECTED: "Selected"
STR_TOGGLE: "Toggle"
STR_CONFIRM: "Confirm"
STR_CANCEL: "Cancel"
@@ -239,16 +199,12 @@ STR_SHOW: "Show"
STR_HIDE: "Hide"
STR_STATE_ON: "ON"
STR_STATE_OFF: "OFF"
STR_SET: "Set"
STR_NOT_SET: "Not Set"
STR_DIR_LEFT: "Left"
STR_DIR_RIGHT: "Right"
STR_DIR_UP: "Up"
STR_DIR_DOWN: "Down"
STR_CAPS_ON: "CAPS"
STR_CAPS_OFF: "caps"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[ON]"
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
STR_FILTER_CONTRAST: "Contrast"
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
@@ -274,7 +230,6 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
STR_OPDS_BROWSER: "OPDS Browser"
STR_COVER_CUSTOM: "Cover + Custom"
STR_RECENTS: "Recents"
STR_MENU_RECENT_BOOKS: "Recent Books"
STR_NO_RECENT_BOOKS: "No recent books"
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
@@ -298,13 +253,11 @@ STR_GO_TO_PERCENT: "Go to %"
STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress"
STR_DELETE_CACHE: "Delete Book Cache"
STR_DELETE: "Delete"
STR_DISPLAY_QR: "Show page as QR"
STR_CHAPTER_PREFIX: "Chapter: "
STR_PAGES_SEPARATOR: " pages | "
STR_BOOK_PREFIX: "Book: "
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "LOCK"
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
STR_PERCENT_STEP_HINT: "Left/Right: 1% Up/Down: 10%"
STR_SYNCING_TIME: "Syncing time..."
@@ -335,3 +288,5 @@ STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
+1 -59
View File
@@ -9,12 +9,10 @@ STR_ENTERING_SLEEP: "Siirrytään lepotilaan"
STR_BROWSE_FILES: "Selaa tiedostoja"
STR_FILE_TRANSFER: "Tiedostonsiirto"
STR_SETTINGS_TITLE: "Asetukset"
STR_CALIBRE_LIBRARY: "Calibre-kirjasto"
STR_CONTINUE_READING: "Jatka lukemista"
STR_NO_OPEN_BOOK: "Kirjaa ei valittu"
STR_START_READING: "Aloita lukeminen"
STR_BOOKS: "Kirjat"
STR_NO_BOOKS_FOUND: "Kirjoja ei löytynyt"
STR_NO_FILES_FOUND: "Ei tiedostoja löytynyt"
STR_SELECT_CHAPTER: "Valitse luku"
STR_NO_CHAPTERS: "Ei lukuja"
STR_END_OF_BOOK: "Kirjan loppu"
@@ -26,10 +24,6 @@ STR_EMPTY_FILE: "Tyhjä tiedosto"
STR_OUT_OF_BOUNDS: "Alueen ulkopuolella"
STR_LOADING: "Ladataan..."
STR_LOADING_POPUP: "Ladataan"
STR_LOAD_XTC_FAILED: "XTC:n lataus epäonnistui"
STR_LOAD_TXT_FAILED: "TXT:n lataus epäonnistui"
STR_LOAD_EPUB_FAILED: "EPUB:n lataus epäonnistui"
STR_SD_CARD_ERROR: "SD-korttivirhe"
STR_WIFI_NETWORKS: "WiFi-verkot"
STR_NO_NETWORKS: "Verkkoja ei löytynyt"
STR_NETWORKS_FOUND: "%zu verkkoa löydetty"
@@ -37,14 +31,9 @@ STR_SCANNING: "Etsitään..."
STR_CONNECTING: "Yhdistetään..."
STR_CONNECTED: "Yhdistetty!"
STR_CONNECTION_FAILED: "Yhteys epäonnistui"
STR_CONNECTION_TIMEOUT: "Yhteys aikakatkaistiin"
STR_FORGET_NETWORK: "Unohda verkko?"
STR_SAVE_PASSWORD: "Tallenna salasana seuraavaa kertaa varten?"
STR_REMOVE_PASSWORD: "Poista tallennettu salasana?"
STR_PRESS_OK_SCAN: "Paina OK etsiäksesi uudelleen"
STR_PRESS_ANY_CONTINUE: "Paina mitä tahansa painiketta jatkaaksesi"
STR_SELECT_HINT: "VASEN/OIKEA: Valitse | OK: Vahvista"
STR_HOW_CONNECT: "Miten haluat yhdistää?"
STR_JOIN_NETWORK: "Liity verkkoon"
STR_CREATE_HOTSPOT: "Luo yhteyspiste"
STR_JOIN_DESC: "Yhdistä olemassa olevaan WiFi-verkkoon"
@@ -57,27 +46,13 @@ STR_OR_HTTP_PREFIX: "tai http://"
STR_SCAN_QR_HINT: "tai skannaa QR-koodi puhelimellasi:"
STR_CALIBRE_WIRELESS: "Calibre langaton"
STR_CALIBRE_WEB_URL: "Calibre-verkko-osoite"
STR_CONNECT_WIRELESS: "Yhdistä langattomana laitteena"
STR_NETWORK_LEGEND: "* = Salattu | + = Tallennettu"
STR_MAC_ADDRESS: "MAC-osoite:"
STR_CHECKING_WIFI: "Tarkistetaan WiFi..."
STR_ENTER_WIFI_PASSWORD: "Syötä WiFi-salasana"
STR_ENTER_TEXT: "Syötä teksti"
STR_TO_PREFIX: "verkkoon "
STR_CALIBRE_DISCOVERING: "Etsitään Calibrea..."
STR_CALIBRE_CONNECTING_TO: "Yhdistetään: "
STR_CALIBRE_CONNECTED_TO: "Yhdistetty: "
STR_CALIBRE_WAITING_COMMANDS: "Odotetaan komentoja..."
STR_CONNECTION_FAILED_RETRYING: "(Yhteys epäonnistui, yritetään uudelleen)"
STR_CALIBRE_DISCONNECTED: "Yhteys Calibreen katkaistiin"
STR_CALIBRE_WAITING_TRANSFER: "Odotetaan siirtoa..."
STR_CALIBRE_TRANSFER_HINT: "Jos siirto epäonnistuu, ota käyttöön\\n'Ignore free space' Calibren\\nSmartDevice-lisäosan asetuksissa."
STR_CALIBRE_RECEIVING: "Vastaanotetaan: "
STR_CALIBRE_RECEIVED: "Vastaanotettu: "
STR_CALIBRE_WAITING_MORE: "Odotetaan lisää..."
STR_CALIBRE_FAILED_CREATE_FILE: "Tiedoston luonti epäonnistui"
STR_CALIBRE_PASSWORD_REQUIRED: "Salasana vaaditaan"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Siirto keskeytyi"
STR_CALIBRE_INSTRUCTION_1: "1) Asenna CrossPoint Reader -lisäosa"
STR_CALIBRE_INSTRUCTION_2: "2) Ole samassa WiFi-verkossa"
STR_CALIBRE_INSTRUCTION_3: "3) Calibressa: \"Lähetä laitteelle\""
@@ -88,37 +63,25 @@ STR_CAT_CONTROLS: "Ohjaimet"
STR_CAT_SYSTEM: "Järjestelmä"
STR_SLEEP_SCREEN: "Lepotilanäyttö"
STR_SLEEP_COVER_MODE: "Lepotilanäytön kansitila"
STR_STATUS_BAR: "Tilapalkki"
STR_HIDE_BATTERY: "Piilota akun %"
STR_EXTRA_SPACING: "Kappaleiden lisäväli"
STR_TEXT_AA: "Tekstin reunanpehmennys"
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
STR_ORIENTATION: "Lukusuunta"
STR_FRONT_BTN_LAYOUT: "Etupainikkeiden asettelu"
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
STR_LONG_PRESS_SKIP: "Pitkä painallus: lukuhyppy"
STR_FONT_FAMILY: "Lukijan fonttiperhe"
STR_EXT_READER_FONT: "Ulkoinen lukijafontti"
STR_EXT_CHINESE_FONT: "Lukijafontti"
STR_EXT_UI_FONT: "Käyttöliittymäfontti"
STR_FONT_SIZE: "Käyttöliittymän fonttikoko"
STR_LINE_SPACING: "Lukijan riviväli"
STR_ASCII_LETTER_SPACING: "ASCII-kirjainväli"
STR_ASCII_DIGIT_SPACING: "ASCII-numeroväli"
STR_CJK_SPACING: "CJK-välistys"
STR_COLOR_MODE: "Väritila"
STR_SCREEN_MARGIN: "Lukijan näyttömarginaali"
STR_PARA_ALIGNMENT: "Lukijan kappaletasaus"
STR_HYPHENATION: "Tavutus"
STR_TIME_TO_SLEEP: "Aika lepotilaan"
STR_REFRESH_FREQ: "Päivitystaajuus"
STR_CALIBRE_SETTINGS: "Calibre-asetukset"
STR_KOREADER_SYNC: "KOReader-synkronointi"
STR_CHECK_UPDATES: "Tarkista päivitykset"
STR_LANGUAGE: "Kieli"
STR_SELECT_WALLPAPER: "Valitse taustakuva"
STR_CLEAR_READING_CACHE: "Tyhjennä lukuvälimuisti"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Käyttäjänimi"
STR_PASSWORD: "Salasana"
STR_SYNC_SERVER_URL: "Synkronointipalvelimen osoite"
@@ -153,8 +116,6 @@ STR_COVER: "Kansi"
STR_NONE_OPT: "Ei mitään"
STR_FIT: "Sovita"
STR_CROP: "Rajaa"
STR_NO_PROGRESS: "Ei edistymistä"
STR_FULL_OPT: "Täysi"
STR_NEVER: "Ei koskaan"
STR_IN_READER: "Lukijassa"
STR_ALWAYS: "Aina"
@@ -165,9 +126,6 @@ STR_PORTRAIT: "Pysty"
STR_LANDSCAPE_CW: "Vaaka myötäpäivään"
STR_INVERTED: "Käännetty"
STR_LANDSCAPE_CCW: "Vaaka vastapäivään"
STR_FRONT_LAYOUT_BCLR: "Tak, Vah, Vas, Oik"
STR_FRONT_LAYOUT_LRBC: "Vas, Oik, Tak, Vah"
STR_FRONT_LAYOUT_LBCR: "Vas, Tak, Vah, Oik"
STR_PREV_NEXT: "Edell/Seur"
STR_NEXT_PREV: "Seur/Edell"
STR_BOOKERLY: "Bookerly"
@@ -204,8 +162,6 @@ STR_NO_UPDATE: "Ei päivitystä saatavilla"
STR_UPDATE_FAILED: "Päivitys epäonnistui"
STR_UPDATE_COMPLETE: "Päivitys valmis"
STR_POWER_ON_HINT: "Pidä virtapainiketta pohjassa käynnistääksesi"
STR_EXTERNAL_FONT: "Ulkoinen fontti"
STR_BUILTIN_DISABLED: "Sisäänrakennettu (pois käytöstä)"
STR_NO_ENTRIES: "Merkintöjä ei löytynyt"
STR_DOWNLOADING: "Ladataan..."
STR_DOWNLOAD_FAILED: "Lataus epäonnistui"
@@ -216,7 +172,6 @@ STR_FETCH_FEED_FAILED: "Syötteen haku epäonnistui"
STR_PARSE_FEED_FAILED: "Syötteen käsittely epäonnistui"
STR_NETWORK_PREFIX: "Verkko: "
STR_IP_ADDRESS_PREFIX: "IP-osoite: "
STR_SCAN_QR_WIFI_HINT: "tai skannaa QR-koodi puhelimellasi yhdistääksesi WiFiin."
STR_ERROR_GENERAL_FAILURE: "Virhe: Yleinen virhe"
STR_ERROR_NETWORK_NOT_FOUND: "Virhe: Verkkoa ei löytynyt"
STR_ERROR_CONNECTION_TIMEOUT: "Virhe: Yhteys aikakatkaistiin"
@@ -224,7 +179,6 @@ STR_SD_CARD: "SD-kortti"
STR_BACK: "« Takaisin"
STR_EXIT: "« Poistu"
STR_HOME: "« Koti"
STR_SAVE: "« Tallenna"
STR_SELECT: "Valitse"
STR_TOGGLE: "Vaihda"
STR_CONFIRM: "Vahvista"
@@ -237,22 +191,14 @@ STR_YES: "Kyllä"
STR_NO: "Ei"
STR_STATE_ON: "PÄÄLLÄ"
STR_STATE_OFF: "POIS"
STR_SET: "Asetettu"
STR_NOT_SET: "Ei asetettu"
STR_DIR_LEFT: "Vasen"
STR_DIR_RIGHT: "Oikea"
STR_DIR_UP: "Ylös"
STR_DIR_DOWN: "Alas"
STR_CAPS_ON: "ISOT"
STR_CAPS_OFF: "pienet"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[PÄÄLLÄ]"
STR_SLEEP_COVER_FILTER: "Lepotilanäytön kansisuodatin"
STR_FILTER_CONTRAST: "Kontrasti"
STR_STATUS_BAR_FULL_PERCENT: "Täysi + prosentti"
STR_STATUS_BAR_FULL_BOOK: "Täysi + kirjapalkki"
STR_STATUS_BAR_BOOK_ONLY: "Vain kirjapalkki"
STR_STATUS_BAR_FULL_CHAPTER: "Täysi + lukupalkki"
STR_UI_THEME: "Käyttöliittymäteema"
STR_THEME_CLASSIC: "Klassinen"
STR_THEME_LYRA: "Lyra"
@@ -261,7 +207,6 @@ STR_SUNLIGHT_FADING_FIX: "Auringonvalon haalistumiskorjaus"
STR_REMAP_FRONT_BUTTONS: "Uudelleenmääritä etupainikkeet"
STR_OPDS_BROWSER: "OPDS-selain"
STR_COVER_CUSTOM: "Kansi + mukautettu"
STR_RECENTS: "Viimeisimmät"
STR_MENU_RECENT_BOOKS: "Viimeisimmät kirjat"
STR_NO_RECENT_BOOKS: "Ei viimeisimpiä kirjoja"
STR_CALIBRE_DESC: "Käytä Calibren langatonta laiteyhteyttä"
@@ -288,9 +233,6 @@ STR_DELETE_CACHE: "Poista kirjan välimuisti"
STR_CHAPTER_PREFIX: "Luku: "
STR_PAGES_SEPARATOR: " sivua | "
STR_BOOK_PREFIX: "Kirja: "
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "LOCK"
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
STR_PERCENT_STEP_HINT: "Vasen/Oikea: 1% Ylös/Alas: 10%"
STR_SYNCING_TIME: "Synkronoidaan aikaa..."

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