Compare commits

..
Author SHA1 Message Date
Leopoldo Pla SempereandJulia Nguyen 4970ef1f6b feat: support transparent PNG sleep overlays (#2974)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-08-13 15:59:55 -04:00
Foulad f449ce9e7d fix: narrow pr-formatting-check.yml to pull_request_target only (#3006) 2026-08-13 19:52:26 +03:00
Thiago Kenji Okada 48e39eb0ee fix: prevent reader resume position rollback (#2962) 2026-08-13 12:05:01 +00:00
Justin Mitchell 9b1fb712de fix: Guard GPIO13 power control for Xteink C3 boards only (#2998) 2026-08-12 22:06:52 -04:00
Justin Mitchell e715532f90 fix: settings UI not updating after language change & line height padding (#2997) 2026-08-12 21:32:03 -04:00
Brian Pugh 70faa29dac perf: light-sleep idle + refresh downclock — ~3.2x active reading time (X3-measured) (#2525)
## Summary

Reduces active-reading power draw ~3.2x, measured end-to-end on an X3
with a [Nordic
PPK2](https://www.nordicsemi.com/Products/Development-hardware/Power-Profiler-Kit-2)
at the battery terminals (slim build, USB cable removed, 3.8v). Three
independent changes:

1. **Light-sleep between input polls when idle** (the big one): after 1
s of inactivity the main loop replaces its busy `delay(50)` with 50 ms
timer-paced `esp_light_sleep_start()` slices. Poll cadence and input
latency are unchanged — the CPU sleeps between polls instead of spinning
at 10 MHz. Guards: never sleeps while a `HalPowerManager::Lock` is held
(render in flight), WiFi is up, or USB is connected (light sleep kills
the CDC link).
2. **Two-stage idle backoff**: downclock at 500 ms, light sleep at 1 s,
replacing the flat 3 s full-speed window. Renders re-raise the clock via
`Lock` the moment they start, so the full-speed tail only ever served
loop bookkeeping. (The 3 s value in #852 was a placeholder;
responsiveness verified on device against stock.)
3. **Refresh BUSY-wait downclock**: drops the CPU to 10 MHz while
`pollBusy()` polls the BUSY pin (0.3–2 s per refresh), via new optional
hooks in the SDK — **depends on Free-Ink/freeink-sdk/pull/8**. Plus a 1
Hz throttle on the X3's I2C-based USB detection (was polled every loop).

(1+2) reduced average static-page-current from 9.68mA to 3.45mA.
Reducing the battery-monitor polling frequency further reduced it to
2.78mA.

Before merging, we'll need to update the submodule reference.

## Measured results (PPK2, X3)

| Metric | Before | After |
|---|---|---|
| Idle on a static page | 9.68 mA | **2.78 mA** |
| Fast page turn (incl. post-turn behavior) | ~100 mC | **28.98 mC** |
| Post-turn 160 MHz tail | 21.2 mA x 3 s | eliminated |
| Session average @ 30 s/page | ~11.6 mA | **~3.6 mA (~3.2x reading
time)** |
| Deep sleep | 12.8 µA | unchanged |

## Typical-day profiles

Assuming someone spends 30s per page.

- **Light**: 1 h reading, 10 lock/wake cycles, remaining ~23 h deep
sleep
- **Heavy**: 2 h reading, 20 lock/wake cycles, remaining ~22 h deep
sleep
- Per-day cost components: reading 11.6 → 3.6 mA (session avg),
lock+wake cycle
0.113 → 0.099 mAh each (sleep-screen mode), deep sleep 12.8 µA
(unchanged,
  ~0.29 mAh/day)
- Excluded: WiFi sessions, cold chapter indexing, self-discharge

### Days per charge (650mAh)

| Profile | Before | After | Ratio |
|---|---|---|---|
| Light (1 h/day, 10 locks) | 13.0 mAh/day → ~50 days | 4.9 mAh/day →
~133 days | 2.7× |
| Heavy (2 h/day, 20 locks) | 25.7 mAh/day → ~25 days | 9.5 mAh/day →
~68 days | 2.7× |
| Pure active reading | 11.6 mA → ~56 h | 3.6 mA → ~180 h | 3.2× |

### Caveats

- "After" saturates in reality: Li-ion self-discharge (~2–3%/month) is
not modeled
  and becomes the limiter once firmware draw is this low.
- Faster page-turners trend toward ~2.8×, slower readers slightly above
3.2×.
- Frequent locking dilutes the ratio (wake = full cold boot, barely
touched by the
power branch): at 10–20 locks/day, cycles are ~20% of the "after"
budget.
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_

---------
2026-08-12 20:57:45 -04:00
Leopoldo Pla SempereandJulia Nguyen cb995809ba fix: backport File Transfer guard from CrossInk (#2990)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-08-13 00:19:03 +03:00
Uri Tauber f16451c729 feat: render HTML dictionary definitions via EPUB engine (#2836) 2026-08-12 17:17:25 -04:00
Leopoldo Pla SempereandPablo Cabeza 268c27f7d0 feat: add transparent sleep overlays (#2937)
Co-authored-by: Pablo Cabeza <josepablocg@gmail.com>
2026-08-12 23:21:43 +03:00
Uri Tauber b1d53ba810 feat: add orangutan translation (#2992)
Translated by Dr Horace Worblehat, B. Thau, D.M.
2026-08-12 22:18:39 +03:00
103 changed files with 2441 additions and 5296 deletions

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
name: "PR Formatting"
on:
pull_request:
pull_request_target:
types:
- opened
@@ -9,7 +8,7 @@ on:
- edited
permissions:
statuses: write
pull-requests: read
jobs:
title-check:
-1
View File
@@ -31,5 +31,4 @@ lib/EpdFont/scripts/output/
/dependencies.lock
/sdkconfig.*
sdkconfig.sticky
sdkconfig.x4pro
flake.*
+1 -1
View File
@@ -36,7 +36,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
- OPDS browser with saved servers (up to 8), search, pagination, and direct download
- OTA update checks and installs from GitHub releases
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes including transparent overlays, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
- **Localization**: 24 UI languages and counting. RTL support.
+14 -4
View File
@@ -186,7 +186,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "None" - A blank screen
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
- "Transparent" - A transparent overlay image drawn over the current screen; see [Sleep Screen](#37-sleep-screen) below for more information
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
@@ -501,6 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
| **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, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
| **Transparent** | A BMP or PNG overlay drawn over the current screen. Supports PNG and 32-bit BGRA alpha transparency, and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **None** | A blank screen. |
#### Cover settings
@@ -515,12 +516,21 @@ When using **Cover** or **Cover + Custom**, two additional settings apply:
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.
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This takes priority over the `.sleep`/`sleep` directories.
#### Transparent overlay images
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP or PNG files on the SD card:
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` or `.png` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place `sleep-overlay.bmp` or `sleep-overlay.png` in the root directory. A root BMP takes priority over a root PNG, and both take priority over the `.sleep-overlay`/`sleep-overlay` directories.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a PNG with an alpha channel or a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in alpha images erase the content behind them.
> [!TIP]
> For best results:
>
> - Use uncompressed BMP files with 24-bit color depth
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
> - For **Transparent** mode, use a PNG or uncompressed 32-bit BGRA BMP for per-pixel alpha, or a regular BMP for white-as-transparent artwork.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
+3 -1
View File
@@ -13,7 +13,7 @@ A dictionary folder must contain:
- `.syn` — synonym index (optional; maps alternate spellings and irregular forms to their headword)
- `.ifo` — metadata (optional)
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error).
## Setting Up a Dictionary
@@ -49,6 +49,8 @@ On the very first lookup with a dictionary (and again whenever the `.idx` or `.s
When a word is found, the definition screen shows the matched headword at the top and the definition text below, with a page counter for long definitions.
HTML dictionaries that declare `sametypesequence=h` use the EPUB text-layout engine for semantic formatting such as headings, bold, italics, lists, and line breaks. Images and CSS styling are ignored. Definitions that are too large or cannot be laid out within the available memory fall back to plain text.
- **Left/Right** or side **Up/Down** — previous / next page
- **Back** — return to word selection
-351
View File
@@ -1,351 +0,0 @@
# SD-card plugins
CrossPoint keeps service integrations off the firmware. A plugin is a folder on
the SD card; the firmware provides three generic, scheme-neutral surfaces that
plugins compose. Adding or updating a plugin never requires a firmware build,
and the firmware carries no vendor names, URLs, or file-format knowledge.
```
<root>/<name>/
manifest.json web UI card metadata (optional): title, description, mount
plugin.js browser-side plugin (optional)
device.json on-device catalog screen (optional): title, description, ...
README.md usage instructions, shown on-device (optional)
...assets
```
**Discovery and the on-device list.** Every plugin folder (anything holding a
`manifest.json`, `plugin.js`, or `device.json`) appears under **Settings →
System → Plugins** on the reader, showing its `title` and one-line
`description`. Selecting a plugin opens an info screen with the description and
the plugin's `README.md` as scrollable usage instructions — so even a
browser-only plugin (no `device.json`) is listed and can explain how to use it
from the web UI. A plugin that ships a `device.json` also gets an **Open**
action there to launch its on-device catalog. `title`/`description` are read
from `manifest.json`, with `device.json` overriding when present.
`<root>` is any of `/.crosspoint/plugins`, `/plugins`, or `/.plugins` — the
first two-dot-free options exist so plugins are easy to copy onto the card
from a computer. All three roots are scanned; on a name collision the earlier
root in that order wins.
A plugin can ship any combination: `plugin.js` alone (web-only),
`device.json` alone (on-device only), or both (e.g. sign in from either side,
browse on the reader).
The plugin sources and examples live in the separate `sd-plugins` repository,
which also documents the browser-side `plugin.js` API. This document covers the
firmware surfaces: the job queue and the `device.json` on-device screens.
## Surface 1: browser plugins (`plugin.js`)
JS loaded into the File Manager or Settings web page, backed by generic device
endpoints (`/api/relay`, `/api/crypto`, `/api/fetch`, `/api/plugin-fs`). See
`docs/webserver-endpoints.md` for the endpoints and the sd-plugins repository
for the JS contract.
## Surface 2: the plugin job queue (external automation)
External systems (a companion app, a script) can trigger plugin actions
without a human clicking the web UI. The firmware stores small opaque
`{plugin, action, args}` JSON blobs in a fixed 6-slot pool; it never interprets
them. Any open page hosting the plugin — including the headless runner at
`GET /plugins-run` — claims jobs, executes the plugin's registered handler in
the browser context, and posts the result back.
| Endpoint | Purpose |
|---|---|
| `POST /api/plugin-jobs` | enqueue `{plugin, action, args?}` → `{id}` (503 when the pool is full) |
| `GET /api/plugin-jobs/claim?plugin=` | executor claims the next pending job |
| `POST /api/plugin-jobs/complete` | executor posts `{id, ok, result?}` |
| `GET /api/plugin-jobs/status?id=` | caller polls: `{id, state, result}`; states: `pending`, `running`, `done`, `error`, `unknown` (recycled) |
| `GET /plugins-run` | headless page that loads every plugin (UI hidden) and executes jobs while open |
Flow for an external system:
```sh
# 1. upload whatever the action needs (e.g. a file, via /upload)
# 2. enqueue
curl -X POST http://crosspoint.local/api/plugin-jobs \
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/folder/file"}}'
# -> {"id":7}
# 3. open http://crosspoint.local/plugins-run in a browser tab or hidden
# webview (jobs only execute while a hosting page is open)
# 4. poll
curl "http://crosspoint.local/api/plugin-jobs/status?id=7"
# -> {"id":7,"state":"done","result":{...}}
```
Limits: plugin/action names < 24 chars, args and result < 192 bytes of JSON
each. A job claimed by a runner that dies is reclaimable after 10 minutes.
Results persist only until their slot is recycled — poll promptly.
Plugins register handlers in `plugin.js`:
```js
api.registerAction('myaction', async (args) => {
// runs in the hosting page; throw -> state "error" with {error: message}
return { anything: 'small' }; // -> state "done" with this result
});
```
## Surface 3: on-device catalog screens (`device.json`)
A declarative manifest the firmware's generic `PluginCatalogActivity` renders
under **Settings → System → Plugins**. It expresses "authenticated JSON
catalog: sign in, browse, download, sidecar" — enough for most book services —
without any code running on the device. Anything beyond this vocabulary
belongs in `plugin.js`.
The firmware pieces (all service-agnostic, in `src/activities/plugins/`):
`PluginListActivity` (the Plugins menu), `PluginInfoActivity` (per-plugin
description + README + Open action), `PluginCatalogActivity` (browse / download
/ sign-in), and `discoverPlugins()` (folder scan when the menu opens; nothing
stays resident).
### Schema
Every string field is a template. Available substitutions:
| Variable | Meaning | Available in |
|---|---|---|
| `{token}` | contents of the token file at `token.path` | everywhere |
| `{cfg.KEY}` | value of `KEY` in the flat JSON config file at `config.file` | everywhere |
| `{page}` | current 1-based page | browse |
| `{limit}` | `page_size + 1` (the extra row detects "more pages") | browse |
| `{id}` `{title}` `{author}` `{url}` | fields of the selected item | download, sidecar |
| `{md5}` | MD5 hex of the destination file path | sidecar |
| `{device_code}` | from the auth request response | auth poll |
Two browse formats:
- **`"json"`** (default) — a paged JSON list, using `items`/`fields` below.
- **`"xml"`** — walks a repeating XML element with optional folder navigation
(Confirm opens a folder, Back goes up). No paging; the JSON `items`/
`page_size` are ignored. The firmware knows no specific protocol: a WebDAV
`PROPFIND` multistatus, an OPDS/Atom feed, or any XML list is all manifest
config. XML field selectors are `"elem"` (child element text), `"elem@attr"`
(child element attribute), or `"@attr"` (attribute on the item element).
```jsonc
{
"title": "Service Name", // menu label; defaults to folder name
"token": { // omit for token-less catalogs
"file": "/.crosspoint/<name>.json", // written by auth (either side)
"path": "token" // dotted JSON path inside the file
},
"config": { // optional: flat JSON of {cfg.KEY} values,
"file": "/.crosspoint/<name>-cfg.json" // e.g. a user-entered server URL + credentials
},
"browse": { // required
"format": "json", // or "xml"
"url": "https://.../search",
"method": "POST", // default GET
"headers": { "Authorization": "Bearer {token}" },
"body": "{\"page\":{page},\"per_page\":{limit}}",
// --- json format ---
"items": "", // dotted path to the item array; "" = response root
"fields": { // json: dotted paths (numeric = array index)
"title": "title", // required (items without one are dropped)
"author": "authors.0.name",
"id": "id",
"url": "download_url" // when the item carries a direct file URL
},
"page_size": 8, // 1..16; response should honor {limit}
"lists": [ // optional named sub-catalogs (json only):
{ "title": "All Books" }, // a picker screen precedes browsing;
{ "title": "Favorites", // each entry may override url and/or
"body": "{\"page\":{page},\"per_page\":{limit},\"list\":\"favorites\"}" }
], // omitted keys fall back to browse's
// --- xml format (ignore the json fields above; fields become selectors) ---
"item": "response", // local-name of the repeating element
"container_element": "collection", // presence marks a navigable folder (omit = flat)
"skip_self": true, // drop the entry equal to the request URL
"resolve_urls": true, // resolve url field against the request origin
"extensions": [".epub", ".pdf"] // allowed file extensions (omit = all)
},
"download": {
"url": "https://.../books/{id}/download",
"method": "POST",
"headers": { "Authorization": "Bearer {token}" }, // sent on the file GET too
"body": "{}",
"url_path": "url", // response field with the file URL;
// omit to treat "url" itself as the file URL
"username": "{cfg.user}", // optional HTTP Basic creds for the file GET
"password": "{cfg.pass}", // omit for token/header auth
"dest_dir": "/ServiceName", // created if missing; falls back to SD root
"filename": "{title}.epub", // {title} is filesystem-sanitized here
"sidecar": { // optional per-book metadata file
"path": "/.crosspoint/<name>_{md5}.json",
"body": "{\"book_id\":{id}}"
}
},
"auth": { // optional on-device sign-in
"type": "device_code", // or "password"
// device_code (interactive: shows a code + QR, polls until authorized):
"request": { "url": "...", "method": "POST", "headers": { ... }, "body": "..." },
"poll": { "url": "...", "method": "POST", "headers": { ... },
"body": "{...\"device_code\":\"{device_code}\"}" },
// device_code response field paths, with their defaults:
"code_path": "user_code", "verify_url_path": "verification_uri",
"device_code_path": "device_code", "interval_path": "interval",
"expires_path": "expires_in", "token_path": "access_token",
"error_path": "error"
// password (silent: mints a token from stored config credentials — for
// OAuth2 password grants and login-returns-token APIs). Only "request" and
// "token_path" apply; no user interaction:
// "type": "password",
// "request": { "url": "{cfg.url}/oauth/v2/token", "method": "POST",
// "headers": {...}, "body": "...{cfg.username}...{cfg.password}..." },
// "token_path": "access_token"
}
}
```
### Behavior
- **Sign-in (`device_code`)**: the not-signed-in screen offers Sign in on the
device: it shows the verification URL (text + QR) and user code, then polls
until authorized, honoring `authorization_pending`, `slow_down`,
`expired_token`, and `access_denied`. The token is written to `token.file`
in the shape `token.path` expects, so the web plugin and the device share
one sign-in.
- **Sign-in (`password`)**: no on-device UI — the device silently POSTs the
stored config credentials to the token endpoint before browsing, and re-mints
a fresh token automatically after any 401 (these tokens are short-lived).
Credentials come from a browser `plugin.js` writing the `config.file`. Set it
up once on the web page; the reader is then standalone.
- Without an `auth` block the not-signed-in screen directs the user to the web
plugin.
- **Stale tokens**: a 401/403 from browse returns to the sign-in screen rather
than an error.
- **Pagination** (json): the browse request should return up to `{limit}`
items; the firmware displays `page_size` and uses the extra row to know
another page exists. Navigation matches the OPDS browser: a "Previous
page" row precedes the items past page 1 and a "Next page" row follows
them while more pages exist — tappable and button-reachable like any row.
- **Lists** (json): with `browse.lists`, opening the catalog shows a picker
of the named entries first; each entry browses with its own url/body
overrides (server-side categories, shelves, sort orders). Back from the
book list returns to the picker.
- **XML navigation**: Confirm on a folder (an item carrying `container_element`)
descends into it, Back climbs out (Back at the root leaves the screen).
With `resolve_urls`, server-relative URLs resolve against the browse URL's
origin. A `config.file` lets values like a server URL and credentials be
user-entered (via a browser `plugin.js`) rather than baked into the manifest.
- **After download**: the book's layout cache is invalidated and the optional
sidecar is written (e.g. a service book id keyed by path MD5, for a future
progress-sync stage).
### Heap budget (ESP32-C3, ~380KB total)
- Manifest ≤ 8KB, token file ≤ 2KB, small API responses (auth, download-url
hop) ≤ 48KB (hard caps).
- Browse responses (json and xml) stream to an SD temp file and are parsed
from it, so the raw body never occupies DRAM — services that inline heavy
per-item metadata (a page of BookFusion JSON runs 60+ KB) work unmodified.
A 1MB cap bounds a misbehaving server.
- Catalog responses are parsed with a dynamically built ArduinoJson filter
containing only the declared field paths, so a page parses into a few KB
no matter the body size.
- One TLS session is kept alive across browse requests: repeated handshakes
permanently fragment the heap.
- The book download streams to SD via `HttpDownloader`; file size is
unconstrained.
- An XML list is parsed from the temp file in 2KB chunks; the engine stops
after 200 entries per folder/feed. Split very large libraries into
subfolders.
### Testing a new manifest
1. Copy the plugin folder to `/.crosspoint/plugins/<name>/` on the SD card.
2. Settings → System → Plugins → your title. With no token and no `auth`
block you should see the not-signed-in screen; with `auth`, the code/QR
screen.
3. Watch serial (`[PCAT]` tag) for request/parse failures — the log includes
HTTP status and truncation flags.
4. Token-less services (public JSON APIs) work by omitting the `token` block
entirely; that is the quickest way to validate `items`/`fields` paths.
## Ideas to build
The two planes decide *how* you build something, so pick by what the idea needs:
- **On-device (`device.json`)** — standalone on the reader, but only the
declarative vocabulary above: an authenticated JSON or XML/OPDS-style catalog,
browse, download to SD, optional device-code or password sign-in and per-book
sidecar. No arbitrary logic.
- **Browser (`plugin.js`)** — anything at all (any protocol, auth, format), but
it runs in a connected browser/app. Use it when the idea needs request
signing, HTML→EPUB conversion, multi-file writes, or a real UI. The job queue
(`/api/plugin-jobs` + `/plugins-run`) lets an external app trigger it.
Rule of thumb: if the service is "log in, list an authenticated JSON/XML
catalog, download files," it's on-device. If it needs computed request bodies
(OAuth 1.0a signing, canonicalized signatures) or format conversion, it's
browser-side.
### On-device (`device.json`) ideas
- **Self-hosted library presets** — Kavita, Komga, Audiobookshelf, Calibre-Web,
COPS. Most expose OPDS (native OPDS browsing already covers those — a plugin
adds value mainly for their JSON APIs or token auth). Build: `browse.format`
`json` against the REST API, or `xml` against the OPDS feed; `download.url`
from the entry; bearer/Basic/`{cfg.*}` auth. See `bookfusion` (JSON + token)
and `webdav` (XML + config creds).
- **Dictionary store** — browse a JSON index of dictionary files, download to
the SD dictionary dir. No native equivalent. Build: token-less `json` catalog,
`download.dest_dir` = the dictionary folder, no sidecar. ~15 lines.
- **Theme store / font store** — same shape as the dictionary store, pointed at
the theme or font directory. Asset catalogs OPDS can't express.
- **More read-later that exports EPUB** — anything with a token API and an
EPUB export URL fits like `wallabag`: `auth.type` `password` (or a stored
bearer), `browse` the entries JSON, `download` the export endpoint with a
Bearer header.
- **A curated free-books catalog** — Standard Ebooks / Gutenberg via their OPDS
feed (`xml` format, token-less). Overlaps native OPDS, but ships as a
zero-config preset.
### Browser (`plugin.js`) ideas
- **Plugin store** — install other plugins from a hosted catalog. Build: fetch
a catalog with `api.relay`, `/mkdir` the target folder, `api.writeFile` each
file. See `plugin-store/`.
- **AO3 / fanfiction downloader** — the site serves per-work EPUB downloads.
Build: take a work URL, `api.fetchToSd` (or `api.relay` + `api.writeFile`) the
EPUB to SD. Browsing/search is HTML, so parse it in the browser.
- **RSS → EPUB digest** — build a "morning edition." Fetch feeds with
`api.relay`, assemble an EPUB in the browser with the JSZip the File Manager
page already loads, then `api.writeFile` it to SD.
- **Read-any-article** — paste a URL, run it through a readability service via
`api.relay`, wrap the result as an EPUB, write to SD.
- **Metadata / cover editor** — a File Manager plugin: open an EPUB with JSZip,
edit the OPF (title/author/cover), rewrite it in place. See
`organize-by-author/` for the File-Manager-endpoints pattern.
- **Library tidy tools** — dedupe, bulk rename, move-by-metadata. Same
same-origin endpoints (`/api/files`, `/mkdir`, `/move`, `/delete`), no device
API needed.
- **Signed-API services** (Instapaper OAuth 1.0a, etc.) — must be browser-side:
do the per-request signing in JS with `crypto.subtle`, relay the calls, and
convert the article HTML to EPUB before writing to SD.
### Building blocks reference
| Need | Use |
|---|---|
| Outbound HTTP(S), any method (CORS-free) | `api.relay(method, url, headers, body)` |
| Download a URL straight to SD | `api.fetchToSd(url, dest, headers)` |
| Write a small file to SD | `api.writeFile(path, base64)` |
| Crypto (hash, HMAC via SHA, AES, RSA) | `api.crypto(op, fields)` or browser `crypto.subtle` |
| Create / delete / move SD files | same-origin `/mkdir`, `/delete`, `/move` |
| On-device catalog/browse/download | `device.json` (this document) |
| External app triggers an action | `api.registerAction(name, fn)` + `/api/plugin-jobs` |
-40
View File
@@ -504,43 +504,3 @@ The final field is the WebSocket upload port.
Calibre Wireless starts the same web server in STA mode and displays setup
instructions plus WebSocket upload progress on the device screen.
## Plugin Job Queue
External systems trigger SD-plugin actions without the web UI. The firmware
stores opaque `{plugin, action, args}` blobs (fixed 6-slot pool; args/result
< 192 bytes JSON); an open page hosting the plugin executes them. See
`docs/sd-plugins.md` for the full contract.
### `POST /api/plugin-jobs`
```bash
curl -X POST http://crosspoint.local/api/plugin-jobs \
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/Books/somefile"}}'
# -> {"id":3} (503 {"error":"job queue full"} when all slots busy)
```
### `GET /api/plugin-jobs/claim?plugin=<name>`
Executor-side (used by the plugin host page): returns the next pending job
`{"id":3,"action":"<action>","args":{...}}` and marks it running, or `{"id":0}`.
### `POST /api/plugin-jobs/complete`
Executor-side: `{"id":3,"ok":true,"result":{...}}` -> `{"ok":true}`.
### `GET /api/plugin-jobs/status?id=<n>`
```bash
curl "http://crosspoint.local/api/plugin-jobs/status?id=3"
# -> {"id":3,"state":"done","result":{"title":"...","dest":"/Books/Book.epub"}}
```
States: `pending`, `running`, `done`, `error`, `unknown` (slot recycled —
poll promptly after completion).
### `GET /plugins-run`
Headless executor page: loads every plugin with its UI hidden so registered
actions run. Keep it open (browser tab or embedded webview) while jobs are
queued; jobs enqueued with no page open stay pending until one opens.
-146
View File
@@ -1,146 +0,0 @@
// SD/HAL binding for the content-protection read path.
//
// The ContentProtection SDK lib is storage-agnostic (it works against a
// ByteSource). This file is the firmware-side glue that backs that seam with
// the device's SD storage: a HalStorage-backed ByteSource, the credential
// lookup, and the openProtectedBook() entry point the reader calls. It lives in
// the firmware — not the SDK lib — so the portable lib carries no HAL dependency.
#include <ByteSource.h>
#include <ContentProtection.h>
#include <Credential.h>
#include <HalStorage.h>
#include <Memory.h>
#include <ProtectedBook.h>
#include <WolfsslCrypto.h>
#include <Zip.h>
#include <esp_heap_caps.h>
#include <time.h>
namespace freeink {
namespace content {
namespace {
// The access credential is provisioned off-device and dropped here.
// Generic path — the reader carries no scheme name.
constexpr const char* kCredentialPath = "/.crosspoint/content.key";
// One shared crypto backend for the whole read path.
WolfsslCrypto& crypto() {
static WolfsslCrypto instance;
return instance;
}
// ByteSource over an SD file (read-only). One open handle per instance.
class SdByteSource : public ByteSource {
public:
explicit SdByteSource(std::string path) : path_(std::move(path)) {}
bool open() {
file_ = Storage.open(path_.c_str(), O_RDONLY);
return file_ && file_.isOpen();
}
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
if (!file_ || !file_.seek64(offset)) return -1;
return file_.read(dst, len);
}
uint64_t size() const override { return file_ ? file_.fileSize64() : 0; }
private:
std::string path_;
mutable HalFile file_;
};
// Adapts an opened ProtectedBook to the reader-facing access interface.
class ProtectedBookDecryptor : public ContentDecryptor {
public:
ProtectedBookDecryptor(std::string epubPath, std::unique_ptr<ProtectedBook> book)
: epubPath_(std::move(epubPath)), book_(std::move(book)) {}
bool isEncrypted(const std::string& itemPath) const override { return book_->isEncrypted(itemPath); }
size_t decryptedSize(const std::string& itemPath) const override { return book_->decryptedSize(itemPath); }
bool decryptToSink(const std::string& itemPath, ContentChunkSink sink, void* context) override {
SdByteSource source(epubPath_);
if (!source.open()) return false;
return book_->decryptEntryToSink(source, crypto(), itemPath, sink, context);
}
private:
std::string epubPath_;
std::unique_ptr<ProtectedBook> book_;
};
} // namespace
std::unique_ptr<ContentDecryptor> openProtectedBook(const std::string& epubPath, std::string& err) {
err.clear();
if (!Storage.exists(epubPath.c_str())) return nullptr;
SdByteSource source(epubPath);
if (!source.open()) return nullptr;
// Classify before initializing crypto or loading credentials, then transfer
// this same ZIP index into ProtectedBook. Protected EPUBs still scan only
// once; plain EPUBs return immediately through the normal reader path.
ZipScan scan;
if (!scan.open(source) || !scan.find("META-INF/encryption.xml")) return nullptr;
// A book carrying encryption.xml may only obfuscate its embedded fonts
// (not content-protected). The SDK demands the credential only after parsing
// the manifest and finding genuinely encrypted entries.
SdByteSource credSource(kCredentialPath);
Credential credential;
const bool haveCredential = credSource.open() && parseCredential(credSource, &credential);
auto book = makeUniqueNoThrow<ProtectedBook>();
if (!book) {
err = "out of memory";
return nullptr;
}
// Prefer an out-of-band rights document delivered as a sidecar next to the
// EPUB ("<book>.epub.rights"), so the EPUB on disk stays byte-identical to
// what the server sent. Falls back to a rights.xml injected into the zip.
std::string rightsOverride;
{
// A real rights document is a few KB; 64KB is a generous ceiling. The
// largest-block check keeps the resize below from aborting on OOM (string
// growth is a bare allocation under -fno-exceptions).
constexpr uint64_t kMaxRightsSize = 64 * 1024;
SdByteSource rightsSource(epubPath + ".rights");
if (rightsSource.open()) {
const uint64_t rsize = rightsSource.size();
if (rsize > 0 && rsize <= kMaxRightsSize &&
heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) > static_cast<size_t>(rsize) + 8 * 1024) {
rightsOverride.resize(static_cast<size_t>(rsize));
const int32_t rn = rightsSource.readAt(0, rightsOverride.data(), static_cast<uint32_t>(rsize));
if (rn <= 0)
rightsOverride.clear();
else
rightsOverride.resize(static_cast<size_t>(rn));
}
}
}
if (!book->openFromScan(source, crypto(), credential, std::move(scan), rightsOverride)) {
err = haveCredential ? ("cannot open protected content: " + book->lastError())
: "no content access key on this device";
return nullptr;
}
// An encryption manifest containing only font obfuscation does not require
// this read path; let the reader open it normally.
if (!book->isProtected()) return nullptr;
const int64_t now = static_cast<int64_t>(time(nullptr));
if (book->isExpired(now)) {
err = "access expired";
return nullptr;
}
auto decryptor = makeUniqueNoThrow<ProtectedBookDecryptor>(epubPath, std::move(book));
if (!decryptor) err = "out of memory";
return decryptor;
}
} // namespace content
} // namespace freeink
+28 -72
View File
@@ -8,10 +8,6 @@
#include <Utf8.h>
#include <ZipFile.h>
#include <cstdlib>
#include <cstring>
#include <vector>
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNavParser.h"
@@ -361,22 +357,6 @@ void Epub::parseCssFiles() const {
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
// Open the optional encrypted-entry accessor. A null result without an error
// means normal ZIP reads should be used. A hard error refuses the open with
// a user-presentable reason.
{
std::string err;
decryptor = freeink::content::openProtectedBook(filepath, err);
if (!err.empty()) {
LOG_ERR("EBP", "protected content unavailable: %s", err.c_str());
protectionError = err;
return false;
}
if (decryptor) {
LOG_DBG("EBP", "protected content; on-read access path open");
}
}
// Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath));
// Always create CssParser - needed for inline style parsing even without CSS files
@@ -600,9 +580,15 @@ bool Epub::generateCoverBmp(bool cropped) const {
if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
HalFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
@@ -628,9 +614,15 @@ bool Epub::generateCoverBmp(bool cropped) const {
if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
HalFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
@@ -677,9 +669,15 @@ bool Epub::generateThumbBmp(int height) const {
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
HalFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
@@ -708,9 +706,15 @@ bool Epub::generateThumbBmp(int height) const {
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
HalFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
@@ -752,38 +756,6 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
const std::string path = FsHelpers::normalisePath(itemHref);
// Decode encrypted entries on demand in memory.
if (decryptor && decryptor->isEncrypted(path)) {
const size_t plainSize = decryptor->decryptedSize(path);
if (plainSize > SIZE_MAX - (trailingNullByte ? 1 : 0)) return nullptr;
const size_t total = plainSize + (trailingNullByte ? 1 : 0);
uint8_t* content = static_cast<uint8_t*>(malloc(total > 0 ? total : 1));
if (!content) {
LOG_ERR("EBP", "insufficient memory for %s (%u bytes)", path.c_str(), static_cast<unsigned>(total));
return nullptr;
}
struct BufferSink {
uint8_t* data;
size_t capacity;
size_t written;
} state{content, plainSize, 0};
auto append = [](void* context, const uint8_t* data, size_t size) {
auto* target = static_cast<BufferSink*>(context);
if (size > target->capacity - target->written) return false;
memcpy(target->data + target->written, data, size);
target->written += size;
return true;
};
if (!decryptor->decryptToSink(path, append, &state) || state.written != plainSize) {
free(content);
LOG_ERR("EBP", "content read failed for %s", path.c_str());
return nullptr;
}
if (trailingNullByte) content[plainSize] = 0;
if (size) *size = plainSize;
return content;
}
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
if (!content) {
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
@@ -801,22 +773,6 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con
}
const std::string path = FsHelpers::normalisePath(itemHref);
if (decryptor && decryptor->isEncrypted(path)) {
struct PrintSink {
Print* output;
} state{&out};
auto append = [](void* context, const uint8_t* data, size_t size) {
auto* target = static_cast<PrintSink*>(context);
return target->output->write(data, size) == size;
};
if (!decryptor->decryptToSink(path, append, &state)) {
LOG_ERR("EBP", "content read failed for %s", path.c_str());
return false;
}
return true;
}
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
}
-9
View File
@@ -1,6 +1,5 @@
#pragma once
#include <ContentProtection.h>
#include <Print.h>
#include <memory>
@@ -30,11 +29,6 @@ class Epub {
std::unique_ptr<CssParser> cssParser;
// CSS files
std::vector<std::string> cssFiles;
// Optional encrypted-entry accessor. Entries are decoded in memory and stay
// encrypted at rest. Null when the accessor is not needed or unavailable.
std::unique_ptr<freeink::content::ContentDecryptor> decryptor;
// User-presentable reason the encrypted-entry accessor could not be opened.
std::string protectionError;
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true);
@@ -55,9 +49,6 @@ class Epub {
void setupCacheDir() const;
const std::string& getCachePath() const;
const std::string& getPath() const;
// Empty unless the encrypted-entry accessor failed to open.
const std::string& getProtectionError() const { return protectionError; }
bool isProtected() const { return decryptor != nullptr; }
const std::string& getTitle() const;
const std::string& getAuthor() const;
const std::string& getLanguage() const;
+3 -3
View File
@@ -144,14 +144,14 @@ struct DirectPixelWriter {
// Write a single 2-bit dithered pixel value to the framebuffer.
// Must be called after beginRow() for the current row.
// No bounds checking — caller guarantees coordinates are valid.
inline void writePixel(int logicalX, uint8_t pixelValue) const {
inline void writePixel(int logicalX, uint8_t pixelValue, bool writeWhiteInBw = false) const {
// Determine whether to draw based on render mode
bool draw;
bool state;
switch (mode) {
case GfxRenderer::BW:
draw = (pixelValue < 3);
state = true;
draw = writeWhiteInBw || pixelValue < 3;
state = pixelValue < 3;
break;
case GfxRenderer::GRAYSCALE_MSB:
draw = (pixelValue == 1 || pixelValue == 2);
@@ -18,6 +18,9 @@ struct RenderConfig {
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
float sourceCropX = 0.0f; // Fraction cropped equally from the left and right edges
float sourceCropY = 0.0f; // Fraction cropped equally from the top and bottom edges
bool preserveAlpha = false; // Skip transparent pixels instead of compositing them against white
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
@@ -7,6 +7,7 @@
#include <Memory.h>
#include <PNGdec.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <new>
@@ -21,6 +22,7 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
PNG* decoder{nullptr};
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
@@ -30,14 +32,18 @@ struct PngContext {
float scale{1.f};
int srcWidth{0};
int srcHeight{0};
int cropLeft{0};
int cropTop{0};
int visibleWidth{0};
int visibleHeight{0};
int dstWidth{0};
int dstHeight{0};
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint8_t* alphaLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -131,19 +137,31 @@ uint8_t expandSampleToByte(uint8_t sample, int bitsPerSample) {
return static_cast<uint8_t>((sample * 255U) / maxSample);
}
// Convert entire source line to grayscale with alpha blending to white background.
uint8_t alphaThreshold4x4(const int x, const int y) {
static constexpr uint8_t BAYER_4X4[16] = {0, 128, 32, 160, 192, 64, 224, 96, 48, 176, 16, 144, 240, 112, 208, 80};
return BAYER_4X4[((y & 0x03) << 2) | (x & 0x03)];
}
// Convert an entire source line to grayscale and optionally retain alpha.
// Low-bit-depth grayscale/indexed scanlines are packed most-significant sample first.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, int bitsPerSample,
uint8_t* palette, int hasAlpha) {
uint8_t* palette, int hasAlpha, uint32_t transparentColor, uint8_t* alphaLine) {
const auto store = [grayLine, alphaLine](const int x, const uint8_t gray, const uint8_t alpha) {
grayLine[x] = alphaLine ? gray : static_cast<uint8_t>((gray * alpha + 255u * (255u - alpha)) / 255u);
if (alphaLine) alphaLine[x] = alpha;
};
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
if (bitsPerSample == 8) {
if (bitsPerSample == 8 && !hasAlpha && !alphaLine) {
memcpy(grayLine, pPixels, width);
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
const uint8_t sample = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t gray = expandSampleToByte(sample, bitsPerSample);
store(x, gray, hasAlpha && sample == static_cast<uint8_t>(transparentColor) ? 0 : 255);
}
}
break;
@@ -151,53 +169,44 @@ void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint32_t color = (static_cast<uint32_t>(p[0]) << 16) | (static_cast<uint32_t>(p[1]) << 8) | p[2];
store(x, gray, hasAlpha && color == transparentColor ? 0 : 255);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
for (int x = 0; x < width; x++) {
const uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t* p = &palette[idx * 3];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, hasAlpha ? palette[768 + idx] : 255);
}
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
store(x, expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample), 255);
}
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
store(x, pPixels[x * 2], pPixels[x * 2 + 1]);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, p[3]);
}
break;
default:
memset(grayLine, 128, width);
if (alphaLine) memset(alphaLine, 255, width);
break;
}
}
@@ -208,25 +217,29 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
if (srcY < ctx->cropTop || srcY >= ctx->cropTop + ctx->visibleHeight) return 1;
const int visibleSrcY = srcY - ctx->cropTop;
// Map source rows with the exact output-height ratio. During downscaling,
// multiple source rows can select the same output row; during upscaling, one
// source row must be repeated across every output row in its range. Emitting
// only the first row of an upscale leaves zero-filled (black) gaps in the
// streamed pixel cache.
int firstDstY = (srcY * ctx->dstHeight) / ctx->srcHeight;
int firstDstY = (visibleSrcY * ctx->dstHeight) / ctx->visibleHeight;
int endDstY = firstDstY + 1;
if (ctx->dstHeight > ctx->srcHeight) {
endDstY = ((srcY + 1) * ctx->dstHeight) / ctx->srcHeight;
if (ctx->dstHeight > ctx->visibleHeight) {
endDstY = ((visibleSrcY + 1) * ctx->dstHeight) / ctx->visibleHeight;
}
if (firstDstY <= ctx->lastDstY) firstDstY = ctx->lastDstY + 1;
if (firstDstY >= endDstY || firstDstY >= ctx->dstHeight) return 1;
if (endDstY > ctx->dstHeight) endDstY = ctx->dstHeight;
// Convert entire source line to grayscale (improves cache locality)
// PNGdec parses tRNS while decoding, after open() returns, so query the
// transparent color here rather than caching its pre-decode value.
const uint32_t transparentColor = ctx->decoder ? ctx->decoder->getTransparentColor() : 0;
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->iBpp, pDraw->pPalette,
pDraw->iHasAlpha);
pDraw->iHasAlpha, transparentColor, ctx->alphaLineBuffer);
// Render scaled rows using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
@@ -261,27 +274,30 @@ int pngDrawCallback(PNGDRAW* pDraw) {
}
}
int srcX = 0;
int srcX = ctx->cropLeft;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
const uint8_t alpha = ctx->alphaLineBuffer ? ctx->alphaLineBuffer[srcX] : 255;
if (alpha >= 8 && alpha > alphaThreshold4x4(outX, outY)) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray, ctx->alphaLineBuffer != nullptr);
if (caching) cw.writePixel(outX, ditheredGray);
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
// Bresenham-style stepping: advance srcX based on ratio visibleWidth/dstWidth
error += ctx->visibleWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
@@ -340,6 +356,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
PngContext ctx;
ctx.decoder = png.get();
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
@@ -360,28 +377,36 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
ctx.cropLeft = static_cast<int>(ctx.srcWidth * std::clamp(config.sourceCropX, 0.0f, 0.99f) / 2.0f);
ctx.cropTop = static_cast<int>(ctx.srcHeight * std::clamp(config.sourceCropY, 0.0f, 0.99f) / 2.0f);
ctx.visibleWidth = ctx.srcWidth - 2 * ctx.cropLeft;
ctx.visibleHeight = ctx.srcHeight - 2 * ctx.cropTop;
if (ctx.visibleWidth <= 0 || ctx.visibleHeight <= 0) {
LOG_ERR("PNG", "PNG crop leaves no visible pixels");
return false;
}
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
ctx.scale = (float)ctx.dstWidth / ctx.visibleWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
float scaleX = (float)config.maxWidth / ctx.visibleWidth;
float scaleY = (float)config.maxHeight / ctx.visibleHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
ctx.dstWidth = (int)(ctx.visibleWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.visibleHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
const int pixelType = png->getPixelType();
const int bitsPerSample = png->getBpp();
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth,
ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
LOG_DBG("PNG", "PNG %dx%d (visible %dx%d) -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight,
ctx.visibleWidth, ctx.visibleHeight, ctx.dstWidth, ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType, bitsPerSample);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
@@ -410,12 +435,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
auto grayLineBuffer = makeUniqueNoThrow<uint8_t[]>(grayBufSize);
if (!grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
// tRNS chunks are parsed during decode(), so hasAlpha() is not reliable yet
// for color-key and indexed transparency. Reserve the alpha line whenever
// the caller requests preservation; opaque pixels simply store alpha 255.
const bool retainAlpha = config.preserveAlpha;
const size_t lineBufferBytes = grayBufSize * (retainAlpha ? 2u : 1u);
auto lineBuffers = makeUniqueNoThrow<uint8_t[]>(lineBufferBytes);
if (!lineBuffers) {
LOG_ERR("PNG", "Failed to allocate PNG line buffers (%u bytes)", static_cast<unsigned>(lineBufferBytes));
return false;
}
ctx.grayLineBuffer = grayLineBuffer.get();
ctx.grayLineBuffer = lineBuffers.get();
ctx.alphaLineBuffer = retainAlpha ? ctx.grayLineBuffer + grayBufSize : nullptr;
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
@@ -423,7 +454,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.cachePath.empty();
ctx.caching = !config.preserveAlpha && !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
@@ -436,6 +467,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
unsigned long decodeTime = millis() - decodeStart;
ctx.grayLineBuffer = nullptr;
ctx.alphaLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
+2 -2
View File
@@ -206,8 +206,8 @@ STR_UNNAMED: "Без імя"
STR_NO_SERVER_URL: "URL сервера не наладжаны"
STR_FETCH_FEED_FAILED: "Не ўдалося атрымаць стужку"
STR_PARSE_FEED_FAILED: "Не ўдалося апрацаваць стужку"
STR_NEXT_PAGE: "Наступная старонка"
STR_PREV_PAGE: "Папярэдняя старонка"
STR_NEXT_PAGE: "Наступная старонка »"
STR_PREV_PAGE: "« Папярэдняя старонка"
STR_NETWORK_PREFIX: "Сетка:"
STR_IP_ADDRESS_PREFIX: "IP-адрас:"
STR_ERROR_GENERAL_FAILURE: "Памылка: Агульная памылка"
+2 -2
View File
@@ -218,8 +218,8 @@ STR_HOLD_OPEN_TO_DELETE: "Drži Otvori za brisanje"
STR_NO_SERVER_URL: "Nije podešen URL servera"
STR_FETCH_FEED_FAILED: "Dohvatanje feeda nije uspjelo"
STR_PARSE_FEED_FAILED: "Obrada feeda nije uspjela"
STR_NEXT_PAGE: "Sljedeća stranica"
STR_PREV_PAGE: "Prethodna stranica"
STR_NEXT_PAGE: "Sljedeća stranica »"
STR_PREV_PAGE: "« Prethodna stranica"
STR_NETWORK_PREFIX: "Mreža: "
STR_IP_ADDRESS_PREFIX: "IP adresa: "
STR_ERROR_GENERAL_FAILURE: "Greška: Opšta greška"
+3 -2
View File
@@ -278,6 +278,7 @@ STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_QUICK_RESUME: "Represa ràpida"
STR_TRANSPARENT: "Transparent"
STR_MENU_RECENT_BOOKS: "Llibres recents"
STR_REMOVE_FROM_RECENTS: "Vols suprimir-lo de Llibres recents?"
STR_NO_RECENT_BOOKS: "No hi ha llibres recents"
@@ -351,8 +352,8 @@ STR_AUTO_TURN_PAGES_PER_MIN: "Pas automàtic de pàgina (pàg./min)"
STR_TILT_PAGE_TURN: "Pas de pàgina per inclinació"
STR_FORCE_REFRESH: "Refresca la pantalla"
STR_RESTARTING_HINT: "S'està reiniciant... Si el dispositiu no es reinicia, mantén premut el botó d'encesa durant uns segons."
STR_NEXT_PAGE: "Pàgina següent"
STR_PREV_PAGE: "Pàgina anterior"
STR_NEXT_PAGE: "Pàgina següent »"
STR_PREV_PAGE: "« Pàgina anterior"
STR_XTC_STATUS_BAR: "Barra d'estat XTC"
STR_BOTTOM: "Inferior"
STR_TOP: "Superior"
+2 -2
View File
@@ -390,7 +390,7 @@ STR_LONG_PRESS_MENU: "Dlouhé stisknutí Nabídky"
STR_LOOKUP: "Vyhledat"
STR_MANAGE_FONTS: "Správa písem"
STR_NEXT_FIELD: "Další"
STR_NEXT_PAGE: "Další stránka"
STR_NEXT_PAGE: "Další stránka »"
STR_NO_BIN_FILES: "Nenalezeny žádné soubory .bin"
STR_NO_FONTS_AVAILABLE: "Žádná písma nejsou k dispozici"
STR_NO_FOOTNOTES: "Na této stránce nejsou žádné poznámky pod čarou"
@@ -398,7 +398,7 @@ STR_NO_SERVERS: "Nejsou nakonfigurovány žádné OPDS servery"
STR_OPDS_SD_ROOT: "Kořen SD karty"
STR_OPDS_SERVERS: "OPDS servery"
STR_PREVIEW: "Náhled"
STR_PREV_PAGE: "Předchozí stránka"
STR_PREV_PAGE: "« Předchozí stránka"
STR_PROGRESS_BAR: "Ukazatel průběhu"
STR_PROGRESS_BAR_MEDIUM: "Střední"
STR_PROGRESS_BAR_THICK: "Široký"
+3 -14
View File
@@ -213,25 +213,14 @@ STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power b
STR_NO_ENTRIES: "No entries found"
STR_DOWNLOADING: "Downloading..."
STR_DOWNLOAD_FAILED: "Download failed"
STR_DOWNLOAD_COMPLETE: "Download complete"
STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Unnamed"
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"
STR_NO_SERVER_URL: "No server URL configured"
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
STR_PARSE_FEED_FAILED: "Failed to parse feed"
STR_PLUGINS: "Plugins"
STR_NO_PLUGINS_INSTALLED: "No plugins with device screens installed"
STR_PLUGIN_NOT_SIGNED_IN: "Not signed in. Open the web Settings page and sign in via the plugin first."
STR_PLUGIN_MANIFEST_INVALID: "Plugin manifest is missing or invalid"
STR_PLUGIN_SIGN_IN: "Sign in"
STR_PLUGIN_SIGN_IN_HINT: "Not signed in yet"
STR_PLUGIN_AUTH_WAITING: "Waiting for authorization..."
STR_PLUGIN_AUTH_FAILED: "Sign-in failed or expired. Try again."
STR_PLUGIN_WEB_ONLY: "This plugin runs in the web interface. Connect from a browser to use it."
STR_DRM_PROTECTED_FILE: "DRM-protected file"
STR_NEXT_PAGE: "Next Page"
STR_PREV_PAGE: "Previous Page"
STR_NEXT_PAGE: "Next Page »"
STR_PREV_PAGE: "« Previous Page"
STR_NETWORK_PREFIX: "Network: "
STR_IP_ADDRESS_PREFIX: "IP Address: "
STR_ERROR_GENERAL_FAILURE: "Error: General failure"
@@ -250,7 +239,6 @@ STR_CANCEL: "Cancel"
STR_CONNECT: "Connect"
STR_OPEN: "Open"
STR_DOWNLOAD: "Download"
STR_FETCH: "Fetch"
STR_RETRY: "Retry"
STR_TAP_TO_RETRY: "Tap to retry"
STR_YES: "Yes"
@@ -318,6 +306,7 @@ STR_OPDS_BROWSER: "OPDS Browser"
STR_SEARCH: "Search"
STR_COVER_CUSTOM: "Cover + Custom"
STR_QUICK_RESUME: "Quick Resume"
STR_TRANSPARENT: "Transparent"
STR_MENU_RECENT_BOOKS: "Recent Books"
STR_REMOVE_FROM_RECENTS: "Remove from Recent Books?"
STR_NO_RECENT_BOOKS: "No recent books"
+2 -2
View File
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Maintenir Ouvrir pour supprimer"
STR_NO_SERVER_URL: "URL serveur non configurée"
STR_FETCH_FEED_FAILED: "Échec récupération flux"
STR_PARSE_FEED_FAILED: "Échec analyse flux"
STR_NEXT_PAGE: "Page suivante"
STR_PREV_PAGE: "Page précédente"
STR_NEXT_PAGE: "Page suivante »"
STR_PREV_PAGE: "« Page précédente"
STR_NETWORK_PREFIX: "Réseau : "
STR_IP_ADDRESS_PREFIX: "IP : "
STR_ERROR_GENERAL_FAILURE: "Erreur : Échec général"
Loaded 20 of 103 files, more files were not shown because too many files have changed in this diff. Show more