Compare commits

..
Author SHA1 Message Date
Uri Tauber 95a847c721 Merge branch 'develop' 2026-08-07 10:04:31 +03:00
179 changed files with 4468 additions and 12035 deletions

No files matched your search

+8 -52
View File
@@ -63,8 +63,7 @@ jobs:
- name: Run cppcheck
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
build-default:
name: Build default
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -91,10 +90,14 @@ jobs:
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build default firmware
- name: Build CrossPoint
# Both envs in ONE pio invocation: a second `pio run` can wipe the whole
# .pio/build tree (project-checksum mismatch after the S3 toolchain
# installs into the restored cache), deleting the default firmware.bin
# before the artifact upload.
run: |
set -euo pipefail
pio run -e default | tee pio.log
pio run -e default -e sticky | tee pio.log
- name: Extract firmware stats
@@ -117,52 +120,6 @@ jobs:
path: .pio/build/default/firmware.bin
if-no-files-found: error
build-sticky:
name: Build sticky
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install PlatformIO Core
run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip
- name: Cache PlatformIO packages
uses: actions/cache@v4
with:
path: ~/.platformio
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build sticky firmware
run: |
set -euo pipefail
pio run -e sticky | tee pio.log
- name: Extract firmware stats
run: |
set -euo pipefail
ram_line="$(grep -E "RAM:\\s" -m1 pio.log || true)"
flash_line="$(grep -E "Flash:\\s" -m1 pio.log || true)"
echo "ram_line=${ram_line}" >> "$GITHUB_OUTPUT"
echo "flash_line=${flash_line}" >> "$GITHUB_OUTPUT"
{
echo "## Sticky firmware build stats"
if [ -n "$ram_line" ]; then echo "- ${ram_line}"; else echo "- RAM: not found"; fi
if [ -n "$flash_line" ]; then echo "- ${flash_line}"; else echo "- Flash: not found"; fi
} >> "$GITHUB_STEP_SUMMARY"
unit-tests:
runs-on: ubuntu-latest
steps:
@@ -195,8 +152,7 @@ jobs:
test-status:
name: Test Status
needs:
- build-default
- build-sticky
- build
- clang-format
- cppcheck
- unit-tests
+47
View File
@@ -0,0 +1,47 @@
name: Compile Release
on:
push:
tags:
- '*'
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install PlatformIO Core
run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip
- name: Cache PlatformIO packages
uses: actions/cache@v4
with:
path: ~/.platformio
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build CrossPoint
run: pio run -e gh_release
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: CrossPoint-${{ github.ref_name }}
path: |
.pio/build/gh_release/bootloader.bin
.pio/build/gh_release/firmware.bin
.pio/build/gh_release/firmware.elf
.pio/build/gh_release/firmware.map
.pio/build/gh_release/partitions.bin
-2
View File
@@ -31,5 +31,3 @@ lib/EpdFont/scripts/output/
/dependencies.lock
/sdkconfig.*
sdkconfig.sticky
sdkconfig.x4pro
flake.*
+68 -179
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
.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.
---
+4 -6
View File
@@ -10,10 +10,9 @@ A dictionary folder must contain:
- `.idx` — word index (required, **must be uncompressed** — a `.idx.gz` will not work; decompress it on your computer with `gzip -d` first)
- `.dict` or `.dict.dz` — definition data (`.dict.dz` is supported as-is; entries are decompressed on the fly during lookup)
- `.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: `.syn` synonym files (ignored), 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.
## Setting Up a Dictionary
@@ -36,14 +35,13 @@ One word on the page becomes highlighted:
2. Press **Confirm** to look up the highlighted word.
3. Press **Back** to return to the reader.
On the very first lookup with a dictionary (and again whenever the `.idx` or `.syn` source file changes), the reader shows *"Indexing dictionary…"* while it builds small sidecar files next to them — a `.qidx` for the word index, and a `.sidx` when a `.syn` synonym file is present. Each sidecar is rebuilt independently, only when its own source changes. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecars can be deleted safely at any time — they will simply be rebuilt.
On the very first lookup with a dictionary (and again if the dictionary file changes), the reader shows *"Indexing dictionary…"* while it builds a small `.qidx` sidecar file next to the `.idx`. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecar can be deleted safely at any time — it will simply be rebuilt.
### How Lookup Works
1. **Direct match** — the word is found as-is (case-insensitive) in the dictionary index. Surrounding punctuation is ignored.
2. **Synonyms** — on a miss, if the dictionary ships a `.syn` file, alternate spellings and irregular forms recorded there are resolved to their headword (e.g. `oxen` → `ox`, `colour` → `color`). This step is skipped if the `.sidx` sidecar could not be built (e.g. transient low memory during indexing); the dictionary otherwise stays usable, and the build is retried the next time it is opened.
3. **Stemming** — still no match: common English word forms are retried automatically: possessives and plurals (`dogs` → `dog`, `stories` → `story`) and verb endings (`walked` → `walk`, `running` → `run`, `making` → `make`).
4. **Not found** — a short popup appears and you return to word selection.
2. **Stemming** — on a miss, common English word forms are retried automatically: possessives and plurals (`dogs` → `dog`, `stories` → `story`) and verb endings (`walked` → `walk`, `running` → `run`, `making` → `make`).
3. **Not found** — a short popup appears and you return to word selection.
## The Definition Screen
+3 -10
View File
@@ -90,19 +90,12 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 37
### Version 35
Each file in `sections/*.bin` stores one laid-out spine section. The header is
also the cache-busting key: if any layout-affecting setting differs from the
current reader settings, the section is discarded and rebuilt.
Version 37 increases the fixed-size footnote href field from 96 to 256 bytes.
This changes each serialized footnote record from 128 to 288 bytes, so older
section caches must be discarded and rebuilt.
Version 36 invalidates cached word positions after ruby and CJK justification
layout changes.
Version 35 adds a header offset and a `uint32_t` entry per page for the
visible-text offset LUT. The other section LUTs remain unchanged.
@@ -142,10 +135,10 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 37
#define EXPECTED_VERSION 35
#define MAX_STRING_LENGTH 65535
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 256
#define FOOTNOTE_HREF_LEN 96
struct String {
u32 length [[hidden, comment("String byte length")]];
-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
+29 -78
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);
}
@@ -846,12 +802,7 @@ int Epub::getSpineItemsCount() const {
return bookMetadataCache->getSpineCount();
}
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0;
}
return bookMetadataCache->getCumulativeSize(spineIndex);
}
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
-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;
-19
View File
@@ -481,30 +481,11 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.coverItemHref);
serialization::readString(bookFile, coreMetadata.textReferenceHref);
// Cache cumulative spine sizes in RAM. The progress bar (every render) and percent
// jumps otherwise pay 2 seeks + a heap-allocating SpineEntry read per access. Spine
// entries are stored contiguously in index order immediately after the LUTs, so read
// them in a single sequential pass.
cumulativeSizes.clear();
cumulativeSizes.reserve(spineCount);
const uint32_t lutSize = (static_cast<uint32_t>(spineCount) + tocCount) * sizeof(uint32_t);
bookFile.seek(lutOffset + lutSize);
for (uint16_t i = 0; i < spineCount; i++) {
cumulativeSizes.push_back(readSpineEntry(bookFile).cumulativeSize);
}
loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
return true;
}
uint32_t BookMetadataCache::getCumulativeSize(const int index) const {
if (index < 0 || index >= static_cast<int>(cumulativeSizes.size())) {
return 0;
}
return cumulativeSizes[index];
}
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
if (!loaded) {
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
-9
View File
@@ -7,7 +7,6 @@
#include <deque>
#include <memory>
#include <string>
#include <vector>
class BookMetadataCache {
public:
@@ -63,11 +62,6 @@ class BookMetadataCache {
// wrapper serves whichever pass is active (spine, then toc).
std::unique_ptr<serialization::BufferedFileWriter> passOut;
// Cumulative spine sizes, cached in RAM at load() so progress/percent lookups are
// O(1) instead of 2 seeks + a heap-allocating SpineEntry read per access (4 bytes
// per spine item; <1KB for typical books).
std::vector<uint32_t> cumulativeSizes;
// Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry {
uint64_t hrefHash; // FNV-1a 64-bit hash
@@ -119,9 +113,6 @@ class BookMetadataCache {
bool load();
SpineEntry getSpineEntry(int index);
TocEntry getTocEntry(int index);
// Cumulative byte size up to and including the given spine item (0 if out of range
// or not loaded). Backed by the in-RAM cumulativeSizes cache populated in load().
uint32_t getCumulativeSize(int index) const;
int getSpineCount() const { return spineCount; }
int getTocCount() const { return tocCount; }
bool isLoaded() const { return loaded; }
+1 -4
View File
@@ -3,10 +3,7 @@
#include <cstring>
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 256
// ponytail: bumped from 96 to 256; calibre-generated EPUBs with long filenames
// and URL-encoded characters routinely exceed 96 chars (e.g.
// "Author-Title_split_NNN.html#_ftnN" encoded is ~150 chars).
#define FOOTNOTE_HREF_LEN 96
struct FootnoteEntry {
char number[FOOTNOTE_NUMBER_LEN];
+1 -3
View File
@@ -31,9 +31,7 @@ namespace {
// match. Keeps <br>-per-paragraph books (common CJK formatting) from
// re-adding container spacing at every paragraph.
// 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;
constexpr uint8_t SECTION_FILE_VERSION = 36;
// 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
-2
View File
@@ -357,7 +357,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
renderer.preserveImagePolarity(x, y, width, height);
return; // Successfully rendered from cache
}
@@ -420,7 +419,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return;
}
renderer.preserveImagePolarity(x, y, width, height);
LOG_DBG("IMG", "Decode successful");
}
Loaded 20 of 179 files, more files were not shown because too many files have changed in this diff. Show more