Compare commits

..
Author SHA1 Message Date
Justin Mitchell bf9fea5923 Fix image decode overflow and viewport clipping issues
Prevent integer overflow when validating large image dimensions by using 64-bit arithmetic. Add periodic yielding during JPEG/PNG decode to prevent watchdog timeouts on multi-second operations. Clamp image top margins to prevent full-height images from overflowing page bottom and failing bounds checks. Increase max source pixels from 3MP to 8MP to support high-resolution ebook covers while keeping decode times reasonable.
2026-08-10 14:48:47 -04:00
154 changed files with 3840 additions and 10628 deletions

No files matched your search

-1
View File
@@ -31,5 +31,4 @@ lib/EpdFont/scripts/output/
/dependencies.lock
/sdkconfig.*
sdkconfig.sticky
sdkconfig.x4pro
flake.*
+1 -1
View File
@@ -1 +1 @@
.skills/SKILL.md
/home/urit/Documents/CrossPoint/crosspoint-reader/.skills/SKILL.md
+1 -1
View File
@@ -136,7 +136,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
- [Web server endpoints](./docs/webserver-endpoints.md)
- [Project scope](./SCOPE.md)
- [Contributing docs](./docs/contributing/README.md)
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - how to build new screens on the FreeInkUI activity bases (UiListActivity and friends), plus build envs for the non-Xteink touch devices
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - FreeInkUI components for new screens, the touch bridge for existing ones, and build envs for the non-Xteink touch devices
---
+113 -94
View File
@@ -1,88 +1,23 @@
# Touch and UI Development
CrossPoint runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles.
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
**There is one supported way to build a new screen: FreeInkUI, hosted through the firmware base classes below.** Touch hit-testing, tap highlighting, long-press, swipe scrolling, and button focus navigation all come from the shared stack; you never hand-roll coordinate math.
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
The old bridge helpers (`rowTouch`, `colTouch`, `wasTapInRect`, manual rect `contains()` checks) are legacy. They survive only for the two remaining hand-rolled surfaces (the theme-driven home screen and the reader page) and must not appear in new code. PRs that add new uses will be asked to convert.
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
---
## Picking the right host
## Path 1: New screens with FreeInkUI
| Your screen is... | Use | In-tree reference |
|---|---|---|
| A single list of rows | subclass `UiListActivity` | [`LanguageSelectActivity`](../../src/activities/settings/LanguageSelectActivity.cpp) (minimal), [`RecentBooksActivity`](../../src/activities/home/RecentBooksActivity.cpp) (long-press) |
| Tabbed lists | subclass `UiTabListActivity` | [`SettingsActivity`](../../src/activities/settings/SettingsActivity.cpp) |
| Custom FUI layout (sliders, prompts, state machines) | inherit `UiAppHost` directly | [`EpubReaderPercentSelectionActivity`](../../src/activities/reader/EpubReaderPercentSelectionActivity.cpp), [`WifiSelectionActivity`](../../src/activities/network/WifiSelectionActivity.cpp) |
| A modal picker or confirm inside a legacy activity | `OptionPopup` (or push `ConfirmationActivity`) | [`OtaUpdateActivity`](../../src/activities/settings/OtaUpdateActivity.cpp) |
| A Yes/No prompt inside a FUI screen | build `fui::optionDialog` into the screen | `WifiSelectionActivity::buildPromptDialog` |
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
All four paths route input through the same SDK interaction table, so touch and physical buttons fire the same actions with no per-screen coordinate code.
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
## The hosting stack
`UiAppHost` ([src/components/UiAppHost.h](../../src/components/UiAppHost.h)) owns what every FUI screen shares: the font-bound render target, the `FreeInkApp`, and the `uiReady` handshake that lets the loop task route touch snapshots against the interaction table the render task rebuilds. Never re-implement that handshake; it is a cross-task protocol and lives in exactly one place.
`UiListActivity` ([src/activities/UiListActivity.h](../../src/activities/UiListActivity.h)) layers the list protocol on top: swipes scroll the viewport without moving the selection, buttons move the selection and pull the viewport along (`fui::ListNav`), plus the render skeleton (header chrome, app, footer hints). `UiTabListActivity` is the tab-bar sibling; its selection ring puts the tab bar at position 0 and the rows after it.
### New list screen skeleton
Subclasses supply the data; the base owns the loop. The whole contract:
```cpp
class MyListActivity final : public UiListActivity {
public:
MyListActivity(GfxRenderer& r, MappedInputManager& in) : UiListActivity("MyList", r, in) {}
private:
std::vector<Entry> entries; // the activity's data, loaded in onEnter
std::vector<fui::ListItem> rows; // activity-owned row cache: rebuilt only when entries changes, reused every render
int listCount() const override { return entries.size(); }
const char* headerTitle() const override { return tr(STR_MY_TITLE); }
void onEnter() override {
UiListActivity::onEnter();
entries = /* ... load from wherever ... */;
rebuildRows();
}
// Called whenever entries changes (here, only onEnter; a mutable list would
// also call this after any add/remove). NOT called from buildScreen().
void rebuildRows() {
rows.clear();
rows.reserve(entries.size());
// ... one fui::ListItem per entry (label, actionValue = index) ...
}
void buildScreen(UiScreen& screen) override {
// set content margin from the theme safe area, then:
fui::ListProps props;
props.items = rows.data();
props.count = rows.size();
props.action = ACTION_ROW;
props.inputMask = fui::InputTouch; // physical buttons stay in the base loop
syncListViewport(screen, props); // selection/viewport handoff, always right before list()
screen.list(props);
}
void activateIndex(int index) override {
app.clearTapFlash(); // leaving the screen: a lingering flash would gray the next render
// ... open the thing ...
}
};
```
See [`FileBrowserActivity`](../../src/activities/home/FileBrowserActivity.cpp)'s `rebuildRowItems()` for this pattern applied to a directory listing that can run into the hundreds of entries, and `LanguageSelectActivity.cpp` for the rest of the skeleton (content-margin math, footer, etc.) — note that file still builds its row vector locally inside `buildScreen()`; match `FileBrowserActivity`, not that file, for the row cache. Optional overrides: `onRowLongPress(index)`, `drawFooter()`, `handleButtons()` for extra physical-button handling, and `ACTION_USER`-and-up action ids for non-row elements (register handlers in `onEnter` after the base's).
### Rules that apply to every FUI screen
- Register actions with `app.on(...)` in `onEnter` (after `resetUi()` for direct `UiAppHost` users). Handlers receive a `void* user` you cast back to the activity.
- Handlers that leave the current screen call `app.clearTapFlash()` first.
- Theme tokens are shared and bound by `resetUi()`; never call `app.setTheme` yourself. Metrics flow from the active UITheme through [`UIThemeTokens.h`](../../src/components/UIThemeTokens.h), including the per-board bezel insets that keep scrollbars visible.
- `TextStyle.maxLines` defaults to 1 and truncates with an ellipsis. Set `maxLines` explicitly on any dialog headline or message that can wrap.
- Everything stays allocation-free in steady state. A local `std::vector` inside `buildScreen()` is **not** allocation-free even with `reserve()` first: it starts at zero capacity on every call, `reserve()` allocates, and the destructor frees that storage before the call returns — real allocator work and fragmentation risk on every repaint (cursor move, tap flash, ...), not just on data changes. Build `ListItem` rows into activity-owned storage instead, reserved once when the underlying data loads (`onEnter()`/a `load*()` — see the skeleton above and `FileBrowserActivity::rebuildRowItems()`), and reused unchanged by every `buildScreen()` call. Use a fixed-capacity array (e.g. `ListItem rows[MAX]`, as `OptionPopup` and `KOReaderSyncActivity`'s action rows do) when the count is small and bounded. Do not hold FUI `props` across renders — only the row storage they point into.
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
### Component inventory
@@ -98,11 +33,106 @@ All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
| Text | `text-field`, `text-area` |
One exception to "always go through a host": `KeyboardEntryActivity` drives the keyboard component with a raw `fui::Frame` and `TouchHoldRouter` because per-key hold repeat needs its own routing. If you think your screen needs that, raise it in the PR first.
### Integration skeleton
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
```cpp
#include <FreeInkUIGfxRenderer.h>
namespace fui = freeink::ui;
// Render step: draw the component and (re)register its hit rects.
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
fui::KeyboardProps props;
props.layout = &currentLayout();
props.keyAction = ACTION_KEY; // one action id; the key is the event value
props.inputMask = fui::InputTouch | fui::InputLongPress;
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
```
```cpp
// Input step (each loop): feed touch state, act on routed events.
int tx, ty, tapX, tapY, hx, hy;
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
tapped, tapX, tapY, inContact, millis());
if (result.event) {
activateValue(result.event.value, result.event.longPress);
requestUpdate();
} else if (result.activeChanged) {
requestUpdate(); // repaint touch-down highlight
}
```
Notes:
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
---
## Global gestures: do not reimplement these
## Path 2: Adding touch to existing hand-rolled screens
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
| Helper | Use for |
|---|---|
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
### Pattern A: standard themed list
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
```cpp
int tapped = -1;
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
selectorIndex = tapped;
openBookmark();
return;
}
```
### Pattern B: custom rows with hold highlight
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
```cpp
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = scrollOffset + row;
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
} else { // RowTouch::Tap
selectedIndex = touched;
activateSelection();
}
return;
}
```
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
### Global gestures: do not reimplement these
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
@@ -112,25 +142,14 @@ Three gestures are handled once, for every screen. Activities must not add their
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes.
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
---
### Bridge rules
## Legacy bridge helpers (do not use in new code)
`MappedInputManager` still exposes raw touch accessors. The FUI stack consumes them internally (via `touchSnapshotFrom` in [`UiAppHelpers.h`](../../src/components/UiAppHelpers.h)); activities should not.
| Helper | Status |
|---|---|
| `wasScreenTapped` / `wasScreenTouchDown` / `isScreenTouchHeld` | Consumed by the FUI snapshot builder. Direct use only in the two legacy surfaces |
| `wasTapInRect(x, y, w, h)` | Legacy one-off hit test |
| `rowTouch` / `colTouch` | Legacy row/column band math. Sole remaining user: `HomeActivity` (theme-driven layout) |
| `wasSwipe()` | Raw swipe direction, for behavior beyond the global gestures (reader page turns) |
| `hasTouch()` | Still fine anywhere: gate touch-only chrome (on-screen Cancel/OK pairs) on it |
The `wasListItemTapped` / `wasListItemTouchedDown` helpers from the button-era bridge have been removed; every themed list is a `UiListActivity` now. If you are porting a branch that still calls them, convert the screen to `UiListActivity` rather than resurrecting the helpers.
As with all input: never call the SDK `InputManager` or read GPIO directly. The HAL rule from the main guide applies to touch too.
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
---
-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;
+4 -1
View File
@@ -33,7 +33,10 @@ namespace {
// v35: Persist a uint32_t visible-text start offset for every page.
// v36: Ruby and CJK justification layout changes invalidate cached word positions.
// v37: Footnote href records grew from 96 to 256 bytes.
constexpr uint8_t SECTION_FILE_VERSION = 37;
// v38: Image top margin is clamped so a full-viewport-height image cannot
// overflow the page bottom; older caches can hold placements that panels
// with no bottom inset refuse to draw.
constexpr uint8_t SECTION_FILE_VERSION = 38;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
@@ -1,16 +1,28 @@
#include "ImageToFramebufferDecoder.h"
#include <Arduino.h>
#include <Logging.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
if (width * height > MAX_SOURCE_PIXELS) {
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
format.c_str(), MAX_SOURCE_PIXELS);
// 64-bit multiply: a malformed header (e.g. 40000x60000) overflows a plain
// int product before the comparison can reject it.
const int64_t pixels = static_cast<int64_t>(width) * static_cast<int64_t>(height);
if (pixels > MAX_SOURCE_PIXELS) {
LOG_ERR("IMG", "Image too large (%dx%d = %lld pixels %s), max supported: %lld pixels", width, height,
static_cast<long long>(pixels), format.c_str(), static_cast<long long>(MAX_SOURCE_PIXELS));
return false;
}
return true;
}
void ImageToFramebufferDecoder::yieldDuringDecode(uint32_t& lastYieldMs) {
const uint32_t now = millis();
if (now - lastYieldMs >= 250) {
lastYieldMs = now;
vTaskDelay(1);
}
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
imagePath.c_str());
@@ -31,9 +31,21 @@ class ImageToFramebufferDecoder {
virtual const char* getFormatName() const = 0;
// Call from per-row/per-MCU decode callbacks (free functions, hence public):
// yields one tick at most every 250 ms so multi-second decodes keep the idle
// task (and its watchdog) fed. `lastYieldMs` is caller-held state,
// initialized to the decode start time.
static void yieldDuringDecode(uint32_t& lastYieldMs);
protected:
// Size validation helpers
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
// Size validation helpers. The cap bounds decode TIME, not memory: both decoders
// stream (JPEG in MCU bands at 1/2..1/8 coarse scale, PNG scanline-by-scanline
// with its own width-based row-buffer guard), so RAM never scales with source
// area. 8 MP admits real-world ebook covers (KDP recommends 1600x2560 and
// 2000x3000) while keeping a worst-case single decode in single-digit seconds;
// the row callbacks yield periodically so a long decode cannot starve the idle
// task's watchdog.
static constexpr int64_t MAX_SOURCE_PIXELS = 8388608; // 8 MP (e.g. 2048 * 4096)
bool validateImageDimensions(int width, int height, const std::string& format);
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
@@ -46,6 +46,8 @@ struct JpegContext {
PixelCache cache;
bool caching{false};
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -122,6 +124,8 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0;
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
// 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);
@@ -494,6 +498,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
unsigned long decodeStart = millis();
ctx.lastYieldMs = decodeStart;
rc = jpeg->decode(0, 0, jpegScaleOption);
unsigned long decodeTime = millis() - decodeStart;
@@ -38,6 +38,8 @@ struct PngContext {
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -206,6 +208,8 @@ int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
@@ -432,6 +436,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
unsigned long decodeStart = millis();
ctx.lastYieldMs = decodeStart;
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
@@ -793,7 +793,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentPageVisibleOffsetSet = false;
}
// Apply top margin from container block
// Apply top margin from container block. Clamp it so the image never
// overflows the page bottom: a full-viewport-height image leaves no room
// for the margin, and the break above only fires on non-empty pages, so a
// fresh page would otherwise place the image at y=marginTop and run
// marginTop pixels past viewportHeight. A large bottom reserve (status
// bar / big screen margin) absorbs that overflow silently, but with a
// thin reserve it crosses the physical screen edge and fails
// ImageBlock::render's bounds check, dropping the image entirely.
if (self->currentPageNextY + imageMarginTop + displayHeight > self->viewportHeight) {
const int room = self->viewportHeight - displayHeight - self->currentPageNextY;
imageMarginTop = static_cast<int16_t>(room > 0 ? room : 0);
}
self->currentPageNextY += imageMarginTop;
// Create ImageBlock and add to page
+4 -7
View File
@@ -727,14 +727,11 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int
// Border is inside the rectangle
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth,
const bool state) const {
// Keep the border inside [x, x+width) like the thin overload: the previous
// right/bottom edges at x+width / y+height sat one pixel outside the rect,
// so stroked boxes looked shifted against fills computed from the rect.
for (int i = 0; i < lineWidth; i++) {
drawLine(x + i, y + i, x + width - 1 - i, y + i, state);
drawLine(x + width - 1 - i, y + i, x + width - 1 - i, y + height - 1 - i, state);
drawLine(x + width - 1 - i, y + height - 1 - i, x + i, y + height - 1 - i, state);
drawLine(x + i, y + height - 1 - i, x + i, y + i, state);
drawLine(x + i, y + i, x + width - i, y + i, state);
drawLine(x + width - i, y + i, x + width - i, y + height - i, state);
drawLine(x + width - i, y + height - i, x + i, y + height - i, state);
drawLine(x + i, y + height - i, x + i, y + i, state);
}
}
+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"
+2 -2
View File
@@ -351,8 +351,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"
Loaded 20 of 154 files, more files were not shown because too many files have changed in this diff. Show more