Compare commits
221
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a328fd912d | ||
|
|
e3f2e27078 | ||
|
|
9bb488dc86 | ||
|
|
c331651e4e | ||
|
|
bfb8aacdae | ||
|
|
e027f60bf0 | ||
|
|
78c71f1c77 | ||
|
|
d364e54a69 | ||
|
|
3421171670 | ||
|
|
0848649ce2 | ||
|
|
5776911af0 | ||
|
|
3c5c5e8aa7 | ||
|
|
6f5c5a0900 | ||
|
|
f2b00f6b93 | ||
|
|
44ff313740 | ||
|
|
ca1b833126 | ||
|
|
3e1dd31e53 | ||
|
|
685d4e88f9 | ||
|
|
79b4d63ee2 | ||
|
|
57447a56a6 | ||
|
|
5c86bfe1fd | ||
|
|
4cdefb137c | ||
|
|
e7f3406704 | ||
|
|
6f08a88b12 | ||
|
|
e4d3981469 | ||
|
|
c9d27826f9 | ||
|
|
13bd00bf9d | ||
|
|
fef39da23c | ||
|
|
fd43ca2fe1 | ||
|
|
28255061fb | ||
|
|
43ff1b6644 | ||
|
|
2754a5ff01 | ||
|
|
6edeed73c5 | ||
|
|
8ac302018c | ||
|
|
a11517a62d | ||
|
|
cbaa498ccc | ||
|
|
ebebc6f202 | ||
|
|
970b2c6ca1 | ||
|
|
b6ce599b20 | ||
|
|
f54eab2725 | ||
|
|
0d4c9ab91b | ||
|
|
0daa9db243 | ||
|
|
a2f2eea79e | ||
|
|
86a9b9c4a2 | ||
|
|
ba44978ac4 | ||
|
|
555f76da88 | ||
|
|
0a57c0a5a7 | ||
|
|
a09aef0889 | ||
|
|
8626d69f46 | ||
|
|
fc89e57e69 | ||
|
|
487613b082 | ||
|
|
8d5b119644 | ||
|
|
7271c00d35 | ||
|
|
6e8dbd7f23 | ||
|
|
c4b1d9644a | ||
|
|
f6e59aab72 | ||
|
|
9ad2da0950 | ||
|
|
1db1442319 | ||
|
|
362dcb2a65 | ||
|
|
d1abcc00a2 | ||
|
|
0ce874f8d4 | ||
|
|
282514f755 | ||
|
|
d1e4650e19 | ||
|
|
370f87ea01 | ||
|
|
8b9a73a735 | ||
|
|
29c69b3d8f | ||
|
|
4b5a84dd81 | ||
|
|
b9874c8114 | ||
|
|
b1131795d8 | ||
|
|
5e990b3991 | ||
|
|
ff1951c715 | ||
|
|
121c0690b0 | ||
|
|
16b0853654 | ||
|
|
7d639cf880 | ||
|
|
22f3575064 | ||
|
|
d4069aeae5 | ||
|
|
54d5a788a5 | ||
|
|
f66220a245 | ||
|
|
2266060e84 | ||
|
|
90039d7f4d | ||
|
|
16eb66d7cf | ||
|
|
02bab00be6 | ||
|
|
55a56914d7 | ||
|
|
8634cb8ad3 | ||
|
|
21caebd6e4 | ||
|
|
6f05e401a1 | ||
|
|
f2e3d117dc | ||
|
|
9202522a39 | ||
|
|
301f1d1a38 | ||
|
|
1f83087aa5 | ||
|
|
68138fffdb | ||
|
|
eb98735115 | ||
|
|
b94b58756f | ||
|
|
ca6ebc56fb | ||
|
|
2ea042a68a | ||
|
|
9c12b90737 | ||
|
|
7f01eab62e | ||
|
|
3a1e9f3023 | ||
|
|
fad1a801a4 | ||
|
|
67936cb3af | ||
|
|
fd5b8078c6 | ||
|
|
bd101b2af8 | ||
|
|
19d51ec08c | ||
|
|
ea231ffd18 | ||
|
|
a2d4c6a139 | ||
|
|
b5b1f650e2 | ||
|
|
f04b8aa9a9 | ||
|
|
d9bcef7a58 | ||
|
|
a60f31cdd4 | ||
|
|
f055fdd774 | ||
|
|
bb078bae09 | ||
|
|
2d65808302 | ||
|
|
6e75e5a6be | ||
|
|
db94a86fba | ||
|
|
b12839d1d4 | ||
|
|
50e4d550fb | ||
|
|
03f73fadc7 | ||
|
|
0d64980d25 | ||
|
|
4fe80ba7a5 | ||
|
|
b229428a17 | ||
|
|
36d0e52e4b | ||
|
|
94c0ba466c | ||
|
|
da2e4eabb0 | ||
|
|
3392b3e35a | ||
|
|
52e7446757 | ||
|
|
d83605e003 | ||
|
|
ebf0413fe1 | ||
|
|
66d9e4403a | ||
|
|
174a24ba42 | ||
|
|
9897025ded | ||
|
|
f5bc554ae7 | ||
|
|
cc2079a578 | ||
|
|
75a35f0578 | ||
|
|
f872f1f549 | ||
|
|
02573dc592 | ||
|
|
dfefbfe9b5 | ||
|
|
36a3a0cc3a | ||
|
|
bbb3e06eb1 | ||
|
|
213972badc | ||
|
|
c5e861d71c | ||
|
|
34e923d722 | ||
|
|
f4e7eaa198 | ||
|
|
94d4b0c7bb | ||
|
|
6dbfc29c7e | ||
|
|
4ee406897b | ||
|
|
67973166e3 | ||
|
|
50bc9325a4 | ||
|
|
d736e52bdd | ||
|
|
91d17d9f48 | ||
|
|
d5c28fa1f1 | ||
|
|
f69650fb86 | ||
|
|
e3298c6e43 | ||
|
|
56ba9e4337 | ||
|
|
b56d3d853d | ||
|
|
e9120888fa | ||
|
|
d7797baff2 | ||
|
|
b53ac3d52c | ||
|
|
38210be820 | ||
|
|
88b10c82a3 | ||
|
|
19954aa1b5 | ||
|
|
2823a4a2cd | ||
|
|
929f290042 | ||
|
|
0f021ea5f1 | ||
|
|
99ab8b2772 | ||
|
|
7accc607af | ||
|
|
f39ba7037f | ||
|
|
2dd491b62e | ||
|
|
dc404b41c7 | ||
|
|
4ffc2a7e7e | ||
|
|
c44555007b | ||
|
|
d9aa5b4de1 | ||
|
|
a7aa4c55d8 | ||
|
|
3800179595 | ||
|
|
0b9d1a7d23 | ||
|
|
252a64fa97 | ||
|
|
75764bc6eb | ||
|
|
0ddefdc0c9 | ||
|
|
41e6e15229 | ||
|
|
082b5f295b | ||
|
|
beb7876ccf | ||
|
|
b69111bea5 | ||
|
|
86a9510957 | ||
|
|
acc1ed4358 | ||
|
|
08461c08e7 | ||
|
|
dac7fef49d | ||
|
|
a14c8e762d | ||
|
|
a525606d7f | ||
|
|
df53faab91 | ||
|
|
f2adefe729 | ||
|
|
6a98c2d865 | ||
|
|
cfd3a381ed | ||
|
|
151bf1dae4 | ||
|
|
06d28d6ffa | ||
|
|
8a11f44571 | ||
|
|
061c7688a4 | ||
|
|
510ee10153 | ||
|
|
c6d116024c | ||
|
|
85e08f9a93 | ||
|
|
0af0ad5a17 | ||
|
|
93e81daf41 | ||
|
|
a3e51f9b1e | ||
|
|
90d4c885e1 | ||
|
|
ee947b06d1 | ||
|
|
28b907321e | ||
|
|
77afea4d95 | ||
|
|
2f342508bc | ||
|
|
7bb1f7ed76 | ||
|
|
7acc31bc34 | ||
|
|
938ca3fac1 | ||
|
|
0fbe6ff469 | ||
|
|
43b20bd8be | ||
|
|
b186529120 | ||
|
|
5fa5a71ba2 | ||
|
|
aa43bd3569 | ||
|
|
8377ac9e31 | ||
|
|
71518b3336 | ||
|
|
30b14f2ecf | ||
|
|
6f7f4c592a | ||
|
|
bc57e5d64b | ||
|
|
db3bb850b2 | ||
|
|
30a209de8b |
@@ -0,0 +1,33 @@
|
||||
# CrossPoint Reader: Claude Code skills
|
||||
|
||||
Project skills for Claude Code. Claude auto-discovers them and loads one when the
|
||||
task matches its `description`; you do not invoke them by hand. They encode how
|
||||
this project wants C/C++ written: the judgment calls and self-review gates that
|
||||
keep the firmware small, stable, and reviewable.
|
||||
|
||||
These are written for capable agents, not beginners. They are principle- and
|
||||
decision-focused on purpose. They deliberately avoid line-number citations,
|
||||
which drift; they anchor on durable names (APIs, types, macros, files).
|
||||
|
||||
This is separate from `.skills/SKILL.md`, the GitHub coding-agent guide that
|
||||
mirrors CLAUDE.md. CLAUDE.md stays the always-loaded rule set; these skills are
|
||||
the applied decision procedures that load on demand and add the judgment layer
|
||||
CLAUDE.md does not carry.
|
||||
|
||||
| Skill | Loads when you are... |
|
||||
|---|---|
|
||||
| `heap-discipline` | allocating memory: new/malloc/vector/string, buffers, caches |
|
||||
| `control-flow-clarity` | writing branching logic, state flags, modes, if/else ladders |
|
||||
| `hal-and-abstractions` | touching storage, input, display, settings, i18n, rendering |
|
||||
| `scope-discipline` | adding a feature, activity, lib, setting, or dependency |
|
||||
| `refactor-for-review` | refactoring, cleaning up, or preparing a change for PR |
|
||||
|
||||
Each skill ends with a self-review checklist Claude runs against its own diff
|
||||
before handing it back. Reviewing a PR? Those checklists double as a fast rubric.
|
||||
|
||||
## Maintaining these
|
||||
|
||||
Edit the `SKILL.md` under each directory. Keep them tight. Do not restate
|
||||
CLAUDE.md; add the judgment CLAUDE.md cannot afford to carry. Trigger quality
|
||||
lives in the `description` field: it must name the situations that should pull
|
||||
the skill in, in the words a contributor's task would use.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: control-flow-clarity
|
||||
description: Branching and state-modeling clarity in C/C++. Use when writing or refactoring logic that branches on discrete values: if/else-if ladders, status flags, mode or state ints, or anything that should be an enum plus an exhaustive switch. Covers enum class over magic ints, exhaustive switch over nested if, early-return guard clauses, table dispatch, and when each is the right call.
|
||||
---
|
||||
|
||||
# Control-Flow Clarity
|
||||
|
||||
The goal: a reviewer verifies correctness by reading, not by tracing. Branching
|
||||
that mirrors the problem's shape is self-evident; branching that encodes it in
|
||||
ad-hoc ints and nesting forces the reader to reconstruct intent.
|
||||
|
||||
## Core moves
|
||||
|
||||
- **Model a closed set of states/modes as an `enum class`, not ints or bools.**
|
||||
A variable kept honest by a comment ("0 = hidden, 1 = showing, 2 = confirm")
|
||||
is a latent bug. Make it an enum and the comment becomes the type.
|
||||
- **Dispatch on an enum with an exhaustive `switch`, no `default`.** This
|
||||
codebase relies on it: omitting `default` lets the compiler flag the
|
||||
unhandled case when someone adds an enum value. A `default:` that swallows the
|
||||
unknown case throws that safety away. Add `default` only when "every other
|
||||
value does nothing" is a deliberate, documented decision.
|
||||
- **Replace nested `if/else-if` ladders that branch on one discriminant with a
|
||||
`switch`.** If each branch only maps input to a value, prefer a lookup table
|
||||
(`static constexpr` array) over both.
|
||||
- **Prefer early-return guard clauses over nested success bodies.** Handle the
|
||||
error/empty/skip cases first and return; keep the main path at the left
|
||||
margin.
|
||||
|
||||
## When NOT to switch
|
||||
|
||||
- Branches test unrelated conditions, not one discriminant: a guarded `if`
|
||||
sequence is honest; a switch would be forced.
|
||||
- Two outcomes on a genuine boolean: keep the `if`.
|
||||
- The discriminant is an open or unbounded set (arbitrary ints, strings): table
|
||||
or map, not a switch.
|
||||
|
||||
## Enum hygiene
|
||||
|
||||
- `enum class` by default for type safety. Plain `enum` only when values must
|
||||
implicitly convert (e.g. a value that doubles as a UI dropdown index), and
|
||||
then give it a trailing `_COUNT` sentinel for safe bounds/iteration, matching
|
||||
the existing settings enums.
|
||||
- Name the discriminant after what it selects, not its storage:
|
||||
`Orientation orientation`, not `uint8_t mode`.
|
||||
- No magic numeric codes for states. If you write a comment mapping numbers to
|
||||
meanings, you owe an enum.
|
||||
|
||||
## Self-review
|
||||
|
||||
- [ ] No int/bool standing in for a closed set of modes; it is an `enum class`.
|
||||
- [ ] Enum dispatch is an exhaustive `switch` with no catch-all `default` (or
|
||||
the `default` is a documented deliberate choice).
|
||||
- [ ] No nested if/else-if ladder on a single discriminant that should be a
|
||||
switch or a table.
|
||||
- [ ] Error/skip cases are early-return guards; the happy path is not buried.
|
||||
- [ ] No magic numbers where a named enum or `constexpr` would state the intent.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: hal-and-abstractions
|
||||
description: Layering and abstraction discipline for the firmware. Use when touching storage, input, display, settings, i18n, or rendering, or any code that could reach into the SDK. Covers routing through the HAL (HalStorage / HalGPIO / HalDisplay) instead of raw SDK classes, MappedInputManager logical buttons instead of raw GPIO indices, UITheme/GUI for all rendering, the singleton macros, tr() for user-facing text, and where a new abstraction boundary belongs.
|
||||
---
|
||||
|
||||
# HAL and Abstractions
|
||||
|
||||
CLAUDE.md lists the HAL classes and the SdFat-concurrency reason they exist.
|
||||
This is when and how to route through them, and where to draw a new boundary.
|
||||
|
||||
## Route through the layer, always
|
||||
|
||||
- **SD card I/O:** `Storage` (HalStorage) and `HalFile`. Never `SdFat`,
|
||||
`FsFile`, `SdSpiCard`, `FsBaseFile`, or `SDCardManager` directly. The HAL
|
||||
serializes every SD access through one mutex; bypassing it races the SPI state
|
||||
machine and panics FreeRTOS (CLAUDE.md has the failure mode). This is a
|
||||
correctness boundary, not a style preference.
|
||||
- **Display:** `HalDisplay` over `EInkDisplay`. **Input:** `HalGPIO` over
|
||||
`InputManager`.
|
||||
- **Rendering:** everything through the `GUI` macro (UITheme) and the renderer's
|
||||
oriented metrics. No hardcoded fonts, colors, coordinates, or 800/480
|
||||
literals; ask the renderer for width/height and use the oriented viewable
|
||||
area.
|
||||
- **Input in activities:** `MappedInputManager::Button` logical enums
|
||||
(`Button::Confirm`, `Button::PageForward`, ...). Never raw `HalGPIO::BTN_*`
|
||||
indices outside `ButtonRemapActivity`. Logical buttons survive user remapping
|
||||
and orientation; raw indices do not.
|
||||
- **Shared state:** the singleton macros (`SETTINGS`, `APP_STATE`, `GUI`,
|
||||
`Storage`, `I18N`), not threaded pointers.
|
||||
|
||||
## User-facing text
|
||||
|
||||
Every string a user reads goes through `tr(STR_*)`. Add the key to the English
|
||||
YAML, regenerate with `scripts/gen_i18n.py`, then use the `StrId`. Log lines
|
||||
(`LOG_*`) stay hardcoded.
|
||||
|
||||
## Drawing a new boundary
|
||||
|
||||
When you need an SDK capability the HAL does not expose yet, **add the method to
|
||||
the HAL; do not reach around it.** The new method inherits the mutex, logging,
|
||||
and error contract the rest of the HAL carries. A one-off direct SDK call in an
|
||||
activity is exactly the layering violation the mutex discipline cannot tolerate.
|
||||
|
||||
Keep abstractions thin. A wrapper that only renames an SDK call without adding
|
||||
the mutex, logging, or an error contract is dead weight. Add a layer only when
|
||||
it carries one of those contracts or hides a real implementation choice.
|
||||
|
||||
## Self-review
|
||||
|
||||
- [ ] No direct SdFat / FsFile / SDCardManager / EInkDisplay / InputManager use
|
||||
outside `lib/hal`.
|
||||
- [ ] File access uses `HalFile`; no `.close()` on a local handle
|
||||
(DESTRUCTOR_CLOSES_FILE); members closed in `onExit`.
|
||||
- [ ] Input uses `MappedInputManager::Button`, not raw `BTN_*` indices.
|
||||
- [ ] Rendering goes through GUI/UITheme and oriented metrics; no 800/480 or
|
||||
hardcoded fonts/coords.
|
||||
- [ ] User-facing strings use `tr(STR_*)`; new keys added to YAML and
|
||||
regenerated.
|
||||
- [ ] Any new SDK capability is exposed as a HAL method, not called inline.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: heap-discipline
|
||||
description: Memory allocation discipline for the ESP32-C3 (~380KB RAM, no PSRAM, single 48KB framebuffer). Use whenever writing or reviewing code that allocates: new / malloc / std::vector / std::string, buffers, caches, or anything held across a loop or an activity lifecycle. Covers makeUniqueNoThrow vs raw new/malloc, fragmentation avoidance, reserve-before-push_back, alloc-once-reuse, stack vs heap sizing, and the chunked grayscale buffer pattern.
|
||||
---
|
||||
|
||||
# Heap Discipline (ESP32-C3)
|
||||
|
||||
CLAUDE.md states the allocation rules. This is the procedure you run while
|
||||
writing the code and the gate you run before handing it back.
|
||||
|
||||
The constraint that makes every call matter: ~380KB RAM, no PSRAM, one 48KB
|
||||
framebuffer. **Fragmentation, not total usage, is what kills this device.**
|
||||
Free-heap can read fine while the largest free block is too small for the next
|
||||
allocation. Optimize for not leaving holes, not just for using fewer bytes.
|
||||
|
||||
## Allocation decision procedure
|
||||
|
||||
Ask in order; stop at the first yes.
|
||||
|
||||
1. **Stack?** Local, bounded, under ~256 bytes total: plain array/struct. No
|
||||
heap, no fragmentation. Keep frames lean; the task stack is small.
|
||||
2. **Compile-time constant?** `static constexpr` lives in flash, costs zero DRAM.
|
||||
3. **Allocated once and reused for an activity's lifetime?** Allocate in
|
||||
`onEnter`, hold in a member, release in `onExit`. Never per-frame, never
|
||||
per-iteration.
|
||||
4. **Dynamic and fallible?** `makeUniqueNoThrow<T>(...)` /
|
||||
`makeUniqueNoThrow<T[]>(n)` from `lib/Memory/Memory.h`. Null-check, `LOG_ERR`
|
||||
with the size, return false. It frees on every exit path.
|
||||
5. **A C/SDK API takes ownership and frees it itself?** Only then raw
|
||||
`new (std::nothrow)` / `malloc`, with a comment naming who frees it.
|
||||
|
||||
Bare `new` / `new[]` is never correct here: under `-fno-exceptions` it calls
|
||||
`abort()` on OOM instead of returning null.
|
||||
|
||||
## Fragmentation rules
|
||||
|
||||
- `std::vector`: `reserve(n)` before any `push_back` loop. Each growth is
|
||||
alloc-copy-free (three heap ops) and leaves a hole. Unknown n: estimate high.
|
||||
- No repeated `new`/`delete` or growing containers inside a loop or render path.
|
||||
Hoist the allocation out of the loop.
|
||||
- Large contiguous blocks fragment worst. Full-screen-class buffers use the
|
||||
chunked `storeBwBuffer` / `restoreBwBuffer` path in `GfxRenderer` so they
|
||||
never demand one contiguous 48KB block. Reuse that path. Do not malloc a
|
||||
second full-screen buffer.
|
||||
- `std::string` / Arduino `String`: acceptable on cold paths (file I/O, one-shot
|
||||
setup). Banned on hot/render paths. Build text with a stack `char[]` +
|
||||
`snprintf`; if a `std::string` is unavoidable, `reserve` it first.
|
||||
|
||||
## Justify every allocation
|
||||
|
||||
Per CLAUDE.md's evidence rule: when you add a heap allocation, state in one line
|
||||
why stack/static/reuse was rejected and the worst-case size. If you cannot name
|
||||
the size, you cannot budget it, and you should not allocate it.
|
||||
|
||||
## Self-review before handoff
|
||||
|
||||
- [ ] No bare `new`/`new[]`. Every fallible alloc is `makeUniqueNoThrow`, or a
|
||||
raw alloc with an explicit owner comment.
|
||||
- [ ] Every allocation is null-checked with `LOG_ERR` before the error return.
|
||||
- [ ] No allocation inside a loop or render path that could be hoisted.
|
||||
- [ ] Every `push_back` loop has a preceding `reserve`.
|
||||
- [ ] Anything allocated in `onEnter` is released in `onExit`; member `HalFile`
|
||||
closed there too.
|
||||
- [ ] No second full-screen buffer; grayscale uses store/restoreBwBuffer.
|
||||
- [ ] Each new allocation carries a one-line size + why-not-stack/static note.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: refactor-for-review
|
||||
description: Producing small, single-concern, reviewable changes. Use when refactoring, cleaning up, restructuring, decomposing, or preparing a change for PR, especially in this multi-contributor AI-assisted codebase that is prone to sprawl diffs. Covers one-concern-per-commit, extracting helpers without widening scope, not bundling unrelated edits, decomposing oversized activities, comment hygiene, and a pre-handoff self-review checklist.
|
||||
---
|
||||
|
||||
# Refactor for Review
|
||||
|
||||
This is a multi-contributor, AI-assisted codebase, and the dominant failure mode
|
||||
is the sprawl diff: a one-line intent that touches thirty files. The goal is a
|
||||
change a reviewer can verify in one sitting. Cleaner structure that makes the
|
||||
next change easier is the win, not lines added.
|
||||
|
||||
## One concern per change
|
||||
|
||||
- A commit/PR does one thing. A bug fix is not also a rename is not also a
|
||||
reformat. If you spot an unrelated improvement mid-change, leave it or capture
|
||||
it separately; do not fold it in.
|
||||
- When the working tree has bundled two changes, separate them with the
|
||||
copy-affected-files-aside, reset, re-apply one concern, restore the rest
|
||||
pattern, not by committing the tangle.
|
||||
- Refactor and behavior change do not ride together. A pure refactor must not
|
||||
alter behavior; a behavior change should not drag a refactor along. If both
|
||||
are needed: two commits, refactor first.
|
||||
|
||||
## Keep the diff narrow
|
||||
|
||||
- Extract a helper to remove real duplication or to name a concept, not to chase
|
||||
abstraction. Three-plus copies, or a block that needs a name to be understood:
|
||||
extract. Two similar lines: leave them.
|
||||
- No "while I'm here" scope creep. A signature or type change that ripples to
|
||||
many call sites is its own PR: map every caller first, update them in one
|
||||
topological pass, and land it separately, not as a rider on a feature.
|
||||
- Match the surrounding code: comment density, naming, idiom. The diff should
|
||||
read like the file, not like a different author.
|
||||
|
||||
## Decompose oversized units
|
||||
|
||||
An activity or function that has outgrown one screen of responsibility (multiple
|
||||
unrelated state machines, or a file far larger than its siblings) is a
|
||||
decomposition candidate. Extract a cohesive sub-responsibility into its own
|
||||
unit, as a standalone behavior-preserving refactor, verified on its own, never
|
||||
mixed into a feature change.
|
||||
|
||||
## Comments earn their place
|
||||
|
||||
Comments explain why: an invariant, a defense, a past incident, a non-obvious
|
||||
constraint. Never what the next line already says. Delete narration, phase-marker
|
||||
comments ("now we loop over..."), and restated function names. If a comment and
|
||||
the code it sits on say the same thing, the comment is the thing to cut.
|
||||
|
||||
## Self-review before handoff
|
||||
|
||||
- [ ] The change does exactly one thing; nothing unrelated rode along.
|
||||
- [ ] Refactor and behavior change are not mixed in one commit.
|
||||
- [ ] No "while I'm here" creep; rename/signature ripples are split out.
|
||||
- [ ] Extractions remove real duplication or name a real concept, not
|
||||
speculative abstraction.
|
||||
- [ ] New comments say why, not what; no narration or phase markers.
|
||||
- [ ] A reviewer can understand the diff without running it.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: scope-discipline
|
||||
description: Feature-scope discipline for a dedicated e-reader (not a Swiss Army knife). Use when adding a feature, a new activity, a new lib, a setting, or a dependency, or when a request would grow the firmware's surface. Covers the SCOPE.md test, the RAM-cost vs reading-benefit gate, preferring no-code or existing-mechanism solutions, awareness of the existing activity surface, and how to push back on out-of-scope asks.
|
||||
---
|
||||
|
||||
# Scope Discipline
|
||||
|
||||
The mission: do one thing exceptionally well, focused reading on constrained
|
||||
hardware. `SCOPE.md` is the source of truth for what is in and out. Read it
|
||||
before adding surface. This is the gate to run before writing a new feature.
|
||||
|
||||
## The gate
|
||||
|
||||
Before adding a feature, activity, lib, setting, or dependency, answer in order:
|
||||
|
||||
1. **Is it in `SCOPE.md`?** Explicitly out: interactive apps (notepad,
|
||||
calculator, games), active connectivity (RSS, news, browser), media/audio
|
||||
playback. If it is out, say so and stop.
|
||||
2. **Does it materially improve focused reading?** If the benefit is
|
||||
"nice to have" or serves a different use case, it is out. This is not a PDA.
|
||||
3. **What does it cost in RAM and in the largest-free-block budget?** A feature
|
||||
that adds steady-state RAM or a large transient allocation needs a reading
|
||||
benefit that clearly outweighs it. Quantify with `firmware_size_history.py`
|
||||
and `script_profile_mem.sh` rather than guessing.
|
||||
4. **Can it be done with no new code?** Prefer an existing activity, an existing
|
||||
setting, or a doc over a new code path. The cheapest feature is the one
|
||||
already built.
|
||||
|
||||
If a request fails the gate, push back with the specific reason and the
|
||||
`SCOPE.md` basis, and offer the in-scope alternative. Make the call and say why;
|
||||
do not just hand over a menu.
|
||||
|
||||
## Surface awareness
|
||||
|
||||
The firmware already carries dozens of activities. Each new one is permanent
|
||||
RAM, permanent maintenance, and another thing every future refactor must not
|
||||
break. Default to extending an existing activity or setting before adding a new
|
||||
screen. New top-level surface needs a real justification, not "it would be
|
||||
convenient."
|
||||
|
||||
## Settings are not free
|
||||
|
||||
A new setting is a field to persist, migrate, validate, translate, and render,
|
||||
plus combinatorial test burden. Add one only when users genuinely need the
|
||||
choice; otherwise pick a sensible fixed default.
|
||||
|
||||
## Self-review
|
||||
|
||||
- [ ] Checked against `SCOPE.md`; not on the out-of-scope list.
|
||||
- [ ] Stated the concrete reading benefit, not a generic "useful."
|
||||
- [ ] Named the RAM/size cost (measured, not guessed) and why the benefit wins.
|
||||
- [ ] Checked whether an existing activity/setting/doc already covers it.
|
||||
- [ ] New setting (if any) is justified by a real user need, not added
|
||||
"just in case."
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
github: [daveallie]
|
||||
ko_fi: daveallie
|
||||
custom: ["https://app.royalty.dev/crosspoint-reader/crosspoint-reader"]
|
||||
|
||||
@@ -53,6 +53,13 @@ jobs:
|
||||
- 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: Run cppcheck
|
||||
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
|
||||
|
||||
@@ -76,6 +83,13 @@ jobs:
|
||||
- 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: |
|
||||
set -euo pipefail
|
||||
@@ -102,6 +116,33 @@ jobs:
|
||||
path: .pio/build/default/firmware.bin
|
||||
if-no-files-found: error
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build
|
||||
|
||||
- name: Cache googletest source
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: build/test/_deps/googletest-src
|
||||
key: ${{ runner.os }}-googletest-${{ hashFiles('test/CMakeLists.txt') }}
|
||||
|
||||
- name: Configure
|
||||
run: cmake -S test -B build/test -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build/test
|
||||
|
||||
- name: Run tests
|
||||
run: ctest --test-dir build/test --output-on-failure -j
|
||||
|
||||
# This job is used as the PR required actions check, allows for changes to other steps in the future without breaking
|
||||
# PR requirements.
|
||||
test-status:
|
||||
@@ -110,6 +151,7 @@ jobs:
|
||||
- build
|
||||
- clang-format
|
||||
- cppcheck
|
||||
- unit-tests
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -25,6 +25,13 @@ jobs:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ jobs:
|
||||
- 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: Extract env
|
||||
run: |
|
||||
echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
|
||||
@@ -19,3 +19,7 @@ build
|
||||
lib/EpdFont/scripts/downloaded_fonts/
|
||||
lib/EpdFont/scripts/instanced_fonts/
|
||||
lib/EpdFont/scripts/output/
|
||||
# Claude Code: share project skills with the team, keep local agent state
|
||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
[submodule "open-x4-sdk"]
|
||||
path = open-x4-sdk
|
||||
url = https://github.com/crosspoint-reader/community-sdk.git
|
||||
[submodule "freeink-sdk"]
|
||||
path = freeink-sdk
|
||||
url = https://github.com/Free-Ink/freeink-sdk.git
|
||||
branch = main
|
||||
|
||||
+72
-32
@@ -7,7 +7,7 @@ Mission: Provide a lightweight, high-performance reading experience focused on E
|
||||
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
|
||||
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
|
||||
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
|
||||
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the open-x4-sdk or official docs first.
|
||||
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first.
|
||||
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
|
||||
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
|
||||
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
|
||||
@@ -58,6 +58,7 @@ find src -name "*.cpp" -o -name "*.h" | xargs clang-format -i
|
||||
6. `constexpr` First: Compile-time constants and lookup tables must be `constexpr`, not just `static const`. This moves computation to compile time, enables dead-branch elimination, and guarantees flash placement. Use `static constexpr` for class-level constants.
|
||||
7. `std::vector` Pre-allocation: Always call `.reserve(N)` before any `push_back()` loop. Each growth event allocates a new block (2×), copies all elements, then frees the old one — three heap operations that fragment DRAM. When the final size is unknown, estimate conservatively.
|
||||
8. SPIFFS Write Throttling: Never write a settings file on every user interaction. Guard all writes with a value-change check (`if (newVal == _current) return;`). Progress saves during reading must be debounced — write on activity exit or every N page turns, not on every turn. SPIFFS sectors have a finite erase cycle limit.
|
||||
9. `new` is not nothrow on ESP32: With `-fno-exceptions`, bare `new` that fails calls `abort()` — it does NOT return `nullptr`. Always use `new (std::nothrow)` and null-check the result, or use `makeUniqueNoThrow<T>()` from `lib/Memory/Memory.h`. Never write bare `new` for any fallible allocation.
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
||||
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
|
||||
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
|
||||
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
|
||||
* open-x4-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
||||
* freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
|
||||
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
|
||||
|
||||
### Hardware Abstraction Layer (HAL)
|
||||
@@ -151,14 +152,19 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
|
||||
#include <HalStorage.h>
|
||||
|
||||
// Use Storage singleton (defined via macro)
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
|
||||
// Read from file
|
||||
// No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above).
|
||||
**Usage**: Use `HalFile` (the mutex-wrapping handle), NOT raw SdFat `FsFile` or Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above).
|
||||
|
||||
**SdFat is not thread-safe; all SD access MUST go through HalStorage**:
|
||||
- SdFat's `SdSpiCard` tracks SPI bus state with an unsynchronized `m_spiActive` bool. Two tasks calling SdFat concurrently can confuse that state machine and end with one task calling `SPIClass::endTransaction()` against a paramLock the *other* task is holding. That trips FreeRTOS's `xTaskPriorityDisinherit` assert (`tasks.c:5156, pxTCB == pxCurrentTCBs[0]`) and panics the system. See SdFat issue #518.
|
||||
- `HalStorage` serializes everything via `storageMutex`. Downstream code uses `HalFile` (declared in `<HalStorage.h>`); every method call (read, write, seek, close) takes the mutex. `HalFile`'s destructor also takes the mutex before letting the underlying SdFat `FsFile` close.
|
||||
- **Never** call into `SdFat` / `SdSpiCard` / `FsBaseFile` / `SDCardManager` / raw `FsFile` directly — that bypasses the mutex.
|
||||
|
||||
---
|
||||
|
||||
@@ -266,43 +272,78 @@ When a template is necessary, limit instantiations: use explicit template instan
|
||||
|
||||
**Rules**: NO exceptions, NO abort(), ALWAYS log before error return
|
||||
|
||||
### Acceptable malloc/free Patterns
|
||||
### Heap Buffer Allocation
|
||||
|
||||
**Source**: [src/activities/home/HomeActivity.cpp:166](../src/activities/home/HomeActivity.cpp), [lib/GfxRenderer/GfxRenderer.cpp:439-440](../lib/GfxRenderer/GfxRenderer.cpp)
|
||||
**Prefer `makeUniqueNoThrow` over `malloc`.** Both are nothrow (return `nullptr` on OOM rather than calling `abort()`), but `malloc` requires a manual `free` on every return path — a common source of leaks. `makeUniqueNoThrow<uint8_t[]>(size)` from `lib/Memory/Memory.h` frees automatically when it goes out of scope.
|
||||
|
||||
Despite "prefer stack allocation," malloc is acceptable for:
|
||||
1. **Large temporary buffers** (> 256 bytes, won't fit on stack)
|
||||
2. **One-time allocations** during activity initialization
|
||||
3. **Bitmap rendering buffers** (variable size, used briefly)
|
||||
|
||||
**Pattern**:
|
||||
**Preferred pattern**:
|
||||
```cpp
|
||||
// Allocate
|
||||
auto* buffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||
#include <Memory.h>
|
||||
|
||||
auto buffer = makeUniqueNoThrow<uint8_t[]>(bufferSize);
|
||||
if (!buffer) {
|
||||
LOG_ERR("MODULE", "malloc failed: %d bytes", bufferSize);
|
||||
return false; // Handle allocation failure
|
||||
LOG_ERR("MODULE", "OOM: %d bytes", bufferSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use buffer
|
||||
processData(buffer, bufferSize);
|
||||
processData(buffer.get(), bufferSize);
|
||||
// freed automatically — no manual free needed, no leak on early return
|
||||
```
|
||||
|
||||
// Free immediately after use
|
||||
free(buffer);
|
||||
buffer = nullptr;
|
||||
**`malloc` or `new (std::nothrow)` are still acceptable** when the buffer must be passed to a C API that takes ownership and frees it itself (e.g., certain SDK callbacks). In that case follow the manual pattern:
|
||||
```cpp
|
||||
auto* buffer = static_cast<uint8_t*>(malloc(bufferSize)); // or new (std::nothrow) uint8_t[bufferSize]
|
||||
if (!buffer) {
|
||||
LOG_ERR("MODULE", "OOM: %d bytes", bufferSize);
|
||||
return false;
|
||||
}
|
||||
sdkApiThatTakesOwnership(buffer, bufferSize); // SDK calls free() / delete[]
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- **ALWAYS check for nullptr** after malloc
|
||||
- **Free immediately** after use (don't hold across multiple operations)
|
||||
- **Set to nullptr** after free (avoid use-after-free)
|
||||
- **Document size**: Comment why stack allocation was rejected
|
||||
- **Prefer `makeUniqueNoThrow`** — automatic cleanup eliminates leak risk on error paths
|
||||
- **ALWAYS check for nullptr** after any allocation and `LOG_ERR` before returning false
|
||||
- **Raw allocation only** when a C API takes ownership; document why in a comment
|
||||
|
||||
**Examples in codebase**:
|
||||
- Memory utilities: [Memory.h](../lib/Memory/Memory.h) (`makeUniqueNoThrow`)
|
||||
- Cover image buffers: [HomeActivity.cpp:166](../src/activities/home/HomeActivity.cpp)
|
||||
- Text chunk buffers: [TxtReaderActivity.cpp:259](../src/activities/reader/TxtReaderActivity.cpp)
|
||||
- Bitmap rendering: [GfxRenderer.cpp:439-440](../lib/GfxRenderer/GfxRenderer.cpp)
|
||||
- OTA update buffer: [OtaUpdater.cpp:40](../src/network/OtaUpdater.cpp)
|
||||
|
||||
### Heap Allocation with `new`: Always Use `makeUniqueNoThrow`
|
||||
|
||||
**CRITICAL**: With `-fno-exceptions`, bare `new` on OOM calls `abort()` — it does NOT return `nullptr`. Always use `makeUniqueNoThrow` from `lib/Memory/Memory.h`, which wraps `new (std::nothrow)` and returns a `std::unique_ptr` that is null on OOM and automatically frees on scope exit.
|
||||
|
||||
**Preferred pattern**:
|
||||
```cpp
|
||||
#include <Memory.h>
|
||||
|
||||
auto obj = makeUniqueNoThrow<MyClass>(args);
|
||||
if (!obj) { LOG_ERR("MOD", "OOM: MyClass"); return false; }
|
||||
|
||||
auto buf = makeUniqueNoThrow<uint8_t[]>(size);
|
||||
if (!buf) { LOG_ERR("MOD", "OOM: %d bytes", size); return false; }
|
||||
|
||||
// Pass to C APIs via .get(); unique_ptr frees automatically on return
|
||||
someApi(buf.get(), size);
|
||||
```
|
||||
|
||||
**`new (std::nothrow)` directly is acceptable** when the object must be passed to a C API that takes ownership and calls `delete` itself:
|
||||
```cpp
|
||||
auto* obj = new (std::nothrow) MyClass(args);
|
||||
if (!obj) { LOG_ERR("MOD", "OOM: MyClass"); return false; }
|
||||
sdkApiThatTakesOwnership(obj); // SDK calls delete
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- **Prefer `makeUniqueNoThrow`** — automatic cleanup eliminates leak risk on error paths
|
||||
- **NEVER use bare `new`** — always `makeUniqueNoThrow` or `new (std::nothrow)`
|
||||
- **ALWAYS `LOG_ERR` before returning false** on OOM
|
||||
- **Use `.get()`** to pass the raw pointer to C-style APIs; ownership stays with the `unique_ptr`
|
||||
- **`new (std::nothrow)` directly only** when a C API takes ownership; document why in a comment
|
||||
|
||||
**Examples in codebase**:
|
||||
- Memory utilities: [Memory.h](../lib/Memory/Memory.h) (`makeUniqueNoThrow`)
|
||||
|
||||
---
|
||||
|
||||
@@ -416,7 +457,6 @@ void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Act
|
||||
**All fonts are loaded as global static objects** at firmware startup:
|
||||
- Noto Serif: 12, 14, 16, 18pt (4 styles each: regular, bold, italic, bold-italic)
|
||||
- Noto Sans: 12, 14, 16, 18pt (4 styles each)
|
||||
- OpenDyslexic: 8, 10, 12, 14pt (4 styles each)
|
||||
- Ubuntu UI fonts: 10, 12pt (2 styles)
|
||||
|
||||
**Total**: ~80+ global `EpdFont` and `EpdFontFamily` objects
|
||||
@@ -856,8 +896,8 @@ rm -rf /path/to/sd/.crosspoint/epub_<hash>/sections/
|
||||
**Source**: `lib/Epub/Epub/Section.cpp`, `lib/Epub/Epub/BookMetadataCache.cpp`
|
||||
|
||||
**Current Versions** (as of docs/file-formats.md):
|
||||
- `book.bin`: **Version 5** (metadata structure)
|
||||
- `section.bin`: **Version 12** (layout structure)
|
||||
- `book.bin`: **Version 7** (metadata structure)
|
||||
- `section.bin`: **Version 25** (layout structure)
|
||||
|
||||
**Version Increment Rules**:
|
||||
1. **ALWAYS increment version** BEFORE changing binary structure
|
||||
@@ -867,7 +907,7 @@ rm -rf /path/to/sd/.crosspoint/epub_<hash>/sections/
|
||||
**Example** (incrementing section format version):
|
||||
```cpp
|
||||
// lib/Epub/Epub/Section.cpp
|
||||
static constexpr uint8_t SECTION_FILE_VERSION = 13; // Was 12, now 13
|
||||
static constexpr uint8_t SECTION_FILE_VERSION = 26; // Was 25, now 26
|
||||
|
||||
// Add new field to structure
|
||||
struct PageLine {
|
||||
@@ -878,4 +918,4 @@ struct PageLine {
|
||||
|
||||
---
|
||||
|
||||
Philosophy: We are building a dedicated e-reader, not a Swiss Army knife. If a feature adds RAM pressure without significantly improving the reading experience, it is Out of Scope.
|
||||
Philosophy: We are building a dedicated e-reader, not a Swiss Army knife. If a feature adds RAM pressure without significantly improving the reading experience, it is Out of Scope.
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# CrossPoint Reader
|
||||
|
||||
[](https://app.royalty.dev/crosspoint-reader/crosspoint-reader)
|
||||
|
||||
CrossPoint is open-source e-reader firmware - community-built, fully hackable, free forever. It's maintained by a growing community of developers and readers who believe your device should do what you want - not what a manufacturer decided for you.
|
||||
|
||||
**Now running on:** ESP32C3-based Xteink [X4](https://www.xteink.com/products/xteink-x4) and [X3](https://www.xteink.com/products/xteink-x3).
|
||||
|
||||

|
||||
|
||||
> If you're planning to buy an Xteink device, consider purchasing an **X3/X4 Developer Edition** through https://crosspointreader.com. CrossPoint receives a small share of each sale, helping fund development costs.
|
||||
|
||||
## What can CrossPoint do?
|
||||
|
||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||
|
||||
- **Various formats**: native handling for `.epub`, `.xtc/.xtch`, `.txt`, and `.bmp`.
|
||||
|
||||
@@ -27,21 +31,17 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
||||
- Web settings UI/API (edit many device settings from browser)
|
||||
- WebSocket fast uploads
|
||||
- WebDAV handler
|
||||
- AP mode (hotspot) and STA mode (join existing WiFi), both with QR helpers
|
||||
- AP mode (hotspot) and STA mode (join existing Wi-Fi), both with QR helpers
|
||||
- Calibre wireless connect flow
|
||||
- OPDS browser with saved servers (up to 8), search, pagination, and direct download
|
||||
- OTA update checks and installs from GitHub releases
|
||||
|
||||
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
|
||||
|
||||
- **Localization**: 22 UI languages and counting.
|
||||
- **Localization**: 24 UI languages and counting. RTL support.
|
||||
|
||||
### Coming soon:
|
||||
|
||||
- RTL support — Arabic, Hebrew, and Farsi.
|
||||
|
||||
- Bookmarks.
|
||||
|
||||
- Dictionary lookup — inline word lookup without leaving the reader.
|
||||
|
||||
- More themes.
|
||||
@@ -69,10 +69,6 @@ USB port or browser before assuming the device is locked. Only reach for the unl
|
||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
||||
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
||||
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
||||
>
|
||||
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
|
||||
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
|
||||
> Papyrix (or any other unsupported firmware) on a locked device.**
|
||||
|
||||
## Install firmware
|
||||
|
||||
@@ -121,6 +117,20 @@ See [Development quick start](#development-quick-start) below.
|
||||
|
||||
---
|
||||
|
||||
## Custom SD-card fonts
|
||||
|
||||
Convert your own TTF/OTF files into `.cpfont` files that load from the SD card. No firmware reflash is needed.
|
||||
|
||||
1. Go to https://crosspointreader.com/fonts and open the "SD-card font builder" form.
|
||||
2. Upload up to four styles (regular, bold, italic, bold-italic), set the family name, point sizes, and Unicode range.
|
||||
3. Download the generated `.cpfont` files.
|
||||
4. Copy them to your SD card under `/fonts/YourFont/` (or `/.fonts/YourFont/` to hide the folder).
|
||||
5. Select the font on the device from the font settings.
|
||||
|
||||
Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [User Guide](./USER_GUIDE.md)
|
||||
@@ -195,7 +205,7 @@ CrossPoint Reader is pretty aggressive about caching data down to the SD card to
|
||||
|
||||
### Data caching
|
||||
|
||||
The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the
|
||||
The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the
|
||||
cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows:
|
||||
|
||||
```text
|
||||
@@ -204,13 +214,18 @@ cache. This cache directory exists at `.crosspoint` on the SD card. The structur
|
||||
│ ├── progress.bin # reading position (chapter, page, etc.)
|
||||
│ ├── cover.bmp # generated cover image
|
||||
│ ├── book.bin # metadata: title, author, spine, TOC
|
||||
│ ├── css_rules.cache # parsed CSS rule cache
|
||||
│ ├── img_* # rendered image cache files
|
||||
│ └── sections/ # per-chapter layout cache
|
||||
│ ├── 0.bin
|
||||
│ ├── 1.bin
|
||||
│ └── ...
|
||||
├── settings.json # device settings
|
||||
├── state.json # resume/runtime state
|
||||
└── recent.json # recent books list
|
||||
```
|
||||
|
||||
Removing `/.crosspoint` clears all cached metadata and forces a full regeneration on next open. Note: the cache isn't cleared automatically when you delete a book, and moving a file to a new path resets its reading progress.
|
||||
Removing `/.crosspoint` clears all cached metadata and forces a full regeneration on next open. Book deletes, overwrites, and moves done through the firmware or web UI clear or re-key matching caches; manual SD-card edits may leave stale cache directories behind.
|
||||
|
||||
For more details on the internal file structures, see the [file formats document](./docs/file-formats.md).
|
||||
|
||||
@@ -232,9 +247,7 @@ One of the best things about open source is that anyone can take the code in a d
|
||||
|
||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||
|
||||
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
||||
|
||||
- [crosspoint-reader (jpirnay)](https://github.com/jpirnay/crosspoint-reader) — Faster integration of functionality. Tracks upstream PRs and integrates the good ones ahead of the official merge.
|
||||
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
|
||||
|
||||
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
||||
|
||||
@@ -244,6 +257,8 @@ One of the best things about open source is that anyone can take the code in a d
|
||||
|
||||
- [crosspoint-reader-papers3](https://github.com/juicecultus/crosspoint-reader-papers3) — Crosspoint port for M5Stack Paper S3.
|
||||
|
||||
- [t5s3-reader](https://github.com/ShallowGreen123/t5s3-reader) — Crosspoint port for LilyGo T5 ePaper S3 / T5S3 4.7-inch e-paper device.
|
||||
|
||||
**Note:** Many of these features will make their way into CrossPoint over time. We maintain a slower pace to ensure rock-solid stability and squash bugs before they reach your device.
|
||||
|
||||
Want to build your own device? Be sure to check out the [de-link](https://github.com/iandchasse/de-link) project.
|
||||
|
||||
+291
-57
@@ -5,6 +5,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [CrossPoint User Guide](#crosspoint-user-guide)
|
||||
- [1. Hardware Overview](#1-hardware-overview)
|
||||
- [Button Layout](#button-layout)
|
||||
- [Taking a Screenshot](#taking-a-screenshot)
|
||||
- [2. Power \& Startup](#2-power--startup)
|
||||
- [Power On / Off](#power-on--off)
|
||||
- [First Launch](#first-launch)
|
||||
@@ -14,31 +15,45 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
||||
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
||||
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
|
||||
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
|
||||
- [Uploading Books](#uploading-books)
|
||||
- [Removing a Book](#removing-a-book)
|
||||
- [3.6 Settings](#36-settings)
|
||||
- [3.6.1 Display](#361-display)
|
||||
- [3.6.2 Reader](#362-reader)
|
||||
- [3.6.3 Controls](#363-controls)
|
||||
- [3.6.4 System](#364-system)
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds)
|
||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [Cover settings](#cover-settings)
|
||||
- [Custom images](#custom-images)
|
||||
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
- [Chapter Navigation](#chapter-navigation)
|
||||
- [Auto Page Turn](#auto-page-turn)
|
||||
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
||||
- [Footnote Navigation](#footnote-navigation)
|
||||
- [System Navigation](#system-navigation)
|
||||
- [Supported Languages](#supported-languages)
|
||||
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
|
||||
- [5. Reader Menu](#5-reader-menu)
|
||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||
- [5.2 Bookmarks](#52-bookmarks)
|
||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
|
||||
|
||||
## 1. Hardware Overview
|
||||
|
||||
The device utilises the standard buttons on the Xteink X4 (in the same layout as the manufacturer firmware, by default):
|
||||
|
||||
### Button Layout
|
||||
|
||||
| Location | Buttons |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** |
|
||||
@@ -47,6 +62,7 @@ The device utilises the standard buttons on the Xteink X4 (in the same layout as
|
||||
Button layout can be customized in the **[Controls Settings](#363-controls)**.
|
||||
|
||||
### Taking a Screenshot
|
||||
|
||||
When the Power Button and Volume Down button are pressed at the same time, it will take a screenshot and save it in the folder `screenshots/`.
|
||||
|
||||
Alternatively, while reading a book, press the **Confirm** button to open the reader menu and select **Take screenshot**.
|
||||
@@ -83,11 +99,12 @@ See [Reading Mode](#4-reading-mode) below for more information.
|
||||
|
||||
### 3.3 Browse Files Screen
|
||||
|
||||
The Browse Files screen acts as a file and folder browser.
|
||||
The Browse Files screen acts as a file and folder browser. The full path to the current directory is shown at the top of the screen. File extensions are displayed alongside each filename, and directories are shown with brackets (e.g. `[folder-name]`). Hidden directories (those beginning with `.`) are also visible.
|
||||
|
||||
* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down.
|
||||
* **Open Selection:** Press **Confirm** to open a folder or read a selected book.
|
||||
* **Delete Files:** Hold and release **Confirm** to delete the selected file. You will be given an option to either confirm or cancel deletion. Folder deletion is not supported.
|
||||
* **Open Selection:** Press **Confirm** to open a folder or start reading a selected book. Selecting a `.bmp` file will open the image viewer.
|
||||
* **Delete Files or Folders:** Hold and release **Confirm** to delete the selected file or folder. You will be given an option to either confirm or cancel. Multiple files can be selected for deletion in a single operation.
|
||||
* **Rename or Move:** Files can be renamed or moved to a different folder from within the browse screen.
|
||||
|
||||
### 3.4 Recent Books Screen
|
||||
|
||||
@@ -95,24 +112,62 @@ The Recent Books screen lists the most recently opened books in a chronological
|
||||
|
||||
### 3.5 File Transfer Screen
|
||||
|
||||
The File Transfer screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with a WiFi selection dialog and then your X4 will start hosting a web server.
|
||||
The File Transfer screen allows you to upload and manage files on the device. When you enter the screen, choose **Join a Network**, **Calibre Wireless**, or **Create Hotspot**. The reader then starts the web server for the selected mode.
|
||||
|
||||
See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
|
||||
See the [web server docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
|
||||
|
||||
The web interface also supports **WebDAV**, allowing you to mount the device as a network drive and manage files directly from your computer's file manager.
|
||||
|
||||
Download links for files already on the device are available in the web interface, so you can retrieve books or screenshots over Wi-Fi without connecting a cable.
|
||||
|
||||
A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined-network web server sessions.
|
||||
|
||||
> [!TIP]
|
||||
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
|
||||
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [web server docs](./docs/webserver.md) for details.
|
||||
> [!TIP]
|
||||
> If your EPUBs have compatibility issues, you can run the built-in **EPUB Optimizer** directly from the device to clean up and reprocess books for better rendering.
|
||||
|
||||
### 3.5.1 Calibre Wireless Transfers
|
||||
|
||||
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
|
||||
|
||||
1. Install the plugin in Calibre:
|
||||
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
|
||||
- Download the zip file.
|
||||
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
||||
2. On the device: File Transfer → Connect to Calibre → Join a network.
|
||||
3. Make sure your computer is on the same WiFi network.
|
||||
4. In Calibre, click "Send to device" to transfer books.
|
||||
#### Installing the Plugin in Calibre
|
||||
|
||||
If you don't already have the plugin installed:
|
||||
|
||||
1. Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
|
||||
2. Download the zip file.
|
||||
3. Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
||||
4. Restart Calibre.
|
||||
|
||||
#### Configuring the CrossPoint Plugin in Calibre
|
||||
1. In Calibre select Preferences.
|
||||
2. In the Preferences dialog select Plugins.
|
||||
3. In Plugins search for "crosspoint".
|
||||
4. Click on "Customize plugin".
|
||||
5. Update the value for "Host" to match the IP for your device.
|
||||
6. Leave the other settings as they are.
|
||||
7. [optional] Modify the "Upload path" to point to a subfolder other than the root "/" folder. Enter this as a path relative to the root folder. Example: `/mybooks`
|
||||
8. Restart Calibre.
|
||||
|
||||
<img width="420" height="385" alt="Image" src="https://github.com/user-attachments/assets/01fc7e33-a9a7-48ba-9e26-2e68d1f9daec" />
|
||||
|
||||
#### Uploading Books
|
||||
|
||||
To upload a book using the CrossPoint plugin in Calibre:
|
||||
|
||||
1. On the device: File Transfer -> Calibre Wireless, then join a network.
|
||||
2. Select one or more books.
|
||||
3. Right-click on that selection.
|
||||
4. Select "Send to Device" > "Send to main memory"
|
||||
|
||||
The CrossPoint plugin will connect to your device, create a folder for the book's author in the root folder (or the folder you configured for the plugin), then copy the book into that folder.
|
||||
|
||||
<img width="783" height="310" alt="Image" src="https://github.com/user-attachments/assets/741b0909-2e1d-4f16-8af0-2c43fbda5ce6" />
|
||||
|
||||
#### Removing a Book
|
||||
|
||||
Books cannot be removed from your device through Calibre. Use the web interface instead.
|
||||
|
||||
### 3.6 Settings
|
||||
|
||||
@@ -121,96 +176,155 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
#### 3.6.1 Display
|
||||
|
||||
- **Sleep Screen**: Which sleep screen to display when the device sleeps:
|
||||
|
||||
- "Dark" (default) - The default dark Crosspoint logo sleep screen
|
||||
- "Light" - The same default sleep screen, on a white background
|
||||
- "Custom" - Custom images from the SD card; see [Sleep Screen](#37-sleep-screen) below for more information
|
||||
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
||||
- "None" - A blank screen
|
||||
- "Cover + Custom" - The book cover image, falls back to "Custom" behavior
|
||||
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
||||
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
|
||||
|
||||
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
||||
|
||||
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
|
||||
- "Crop" - Scale the image down and crop as necessary to try to fill the screen (Note: this is experimental and may not work as expected)
|
||||
|
||||
- **Sleep Screen Cover Filter**: What filter will be applied to the book cover when "Cover" sleep screen is selected:
|
||||
|
||||
- "None" (default) - The cover image will be converted to a grayscale image and displayed as it is
|
||||
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
||||
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
|
||||
|
||||
- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled.
|
||||
|
||||
- **Status Bar**: Configure the status bar displayed while reading:
|
||||
|
||||
- "None" - No status bar
|
||||
- "No Progress" - Show status bar without reading progress
|
||||
- "Full w/ Percentage" - Show status bar with book progress (as percentage)
|
||||
- "Full w/ Book Bar" - Show status bar with book progress (as bar)
|
||||
- "Book Bar Only" - Show book progress (as bar)
|
||||
- "Full w/ Chapter Bar" - Show status bar with chapter progress (as bar)
|
||||
|
||||
- **Hide Battery %**: Configure where to suppress the battery percentage display in the status bar; the battery icon will still be shown:
|
||||
|
||||
- "Never" (default) - Always show battery percentage
|
||||
- "In Reader" - Show battery percentage everywhere except in reading mode
|
||||
- "Always" - Always hide battery percentage
|
||||
|
||||
- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting; options are every 1, 5, 10, 15, or 30 pages.
|
||||
|
||||
- **UI Theme**: Set which UI theme to use:
|
||||
|
||||
- "Classic" - The original Crosspoint theme
|
||||
- "Lyra" - The new theme for Crosspoint featuring rounded elements and menu icons
|
||||
- "Lyra Extended" - Lyra, but displays 3 books instead of 1 on the **[Home Screen](#31-home-screen)**
|
||||
- "RoundedRaff" - A rounded theme with additional visual styling
|
||||
|
||||
- **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight:
|
||||
|
||||
- "OFF" (default) - Disable the fix
|
||||
- "ON" - Enable the fix
|
||||
|
||||
> [!NOTE]
|
||||
> A battery charging indicator is shown on the battery icon whenever the device is actively charging.
|
||||
|
||||
#### 3.6.2 Reader
|
||||
|
||||
- **Reader Font Family**: Choose the font used for reading:
|
||||
|
||||
- "Noto Serif" (default) - Google's serif font
|
||||
- "Noto Sans" - Google's sans-serif font
|
||||
- "Open Dyslexic" - Font designed for readers with dyslexia
|
||||
|
||||
- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium" (default), "Large", or "X Large".
|
||||
|
||||
- **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal" (default), or "Wide".
|
||||
|
||||
- **Reader Screen Margin**: Controls the screen margins in Reading Mode between 5 and 40 pixels in 5-pixel increments.
|
||||
|
||||
- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right".
|
||||
|
||||
- **Embedded Style**: Whether to use the EPUB file's embedded HTML and CSS stylisation and formatting; options are "ON" or "OFF".
|
||||
|
||||
- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF".
|
||||
|
||||
- **Reading Orientation**: Set the screen orientation for reading EPUB files:
|
||||
|
||||
- "Portrait" (default) - Standard portrait orientation
|
||||
- "Landscape CW" - Landscape, rotated clockwise
|
||||
- "Inverted" - Portrait, upside down
|
||||
- "Landscape CCW" - Landscape, rotated counter-clockwise
|
||||
|
||||
- **Extra Paragraph Spacing**: Set how to handle paragraph breaks:
|
||||
|
||||
- "ON" - Vertical space will be added between paragraphs in Reading Mode
|
||||
- "OFF" - Paragraphs will not have vertical space added, but will have first-line indentation
|
||||
|
||||
- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly.
|
||||
|
||||
- **Images**: Whether to display embedded images (JPG/PNG) found in EPUB files; options are "ON" (default) or "OFF".
|
||||
|
||||
- **Focus Reading**: Bolds the first part of each word to create visual fixation points, similar to Bionic Reading. This can help improve reading speed and focus; options are "ON" or "OFF" (default).
|
||||
|
||||
#### 3.6.3 Controls
|
||||
|
||||
- **Remap Front Buttons**: A menu for customising the function of each bottom edge button.
|
||||
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from "Prev/Next" (default) to "Next/Prev". This change is only in effect when reading.
|
||||
|
||||
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from "Prev/Next" (default) to "Next/Prev". You can also disable them entirely. This change is only in effect when reading.
|
||||
|
||||
- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skips to the next/previous chapter:
|
||||
|
||||
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
|
||||
- "Page Scroll" - Long-pressing scrolls a page up/down
|
||||
- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal:
|
||||
- "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page.
|
||||
- "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly.
|
||||
- "Disabled" - Long-press is ignored; only short-press opens the reader menu.
|
||||
|
||||
- **Short Power Button Click**: Controls the effect of a short click of the power button:
|
||||
|
||||
- "Ignore" (default) - Require a long press to turn off the device
|
||||
- "Sleep" - A short press puts the device into sleep mode
|
||||
- "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off
|
||||
- "Footnotes" - A short press in reading mode opens the footnotes submenu; if only one footnote is present on the page, the referenced page is opened directly. The short press on the power button can be used to select the footnote in the submenu, and to go back to the original page after finish reading the footnote (like the back button).
|
||||
- "Refresh" - A short press triggers a manual full-screen refresh, useful for clearing ghosting
|
||||
- **Quick-return from footnotes**: Toggles on and off the quick return functionality from the footnotes. When the functionality it's active, a short press of the power button will act as the back button from the footnotes page.
|
||||
|
||||
#### 3.6.4 System
|
||||
|
||||
- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep; options are 1, 5, 10 (default), 15 or 30 minutes.
|
||||
- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep; options are 1, 3, 5, 10 (default), 15 or 30 minutes.
|
||||
|
||||
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
|
||||
|
||||
- **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates.
|
||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
|
||||
|
||||
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
|
||||
|
||||
- **Clear Reading Cache**: Clear the internal SD card cache.
|
||||
- **Check for updates**: Check for Crosspoint firmware updates over WiFi.
|
||||
- **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information).
|
||||
|
||||
- **Check for updates**: Check for Crosspoint firmware updates over Wi-Fi. Firmware can also be updated without a USB connection by placing a `firmware.bin` file on the SD card.
|
||||
|
||||
- **Language**: Set the UI language. CrossPoint supports 24 languages: English, Spanish, French, German, Czech, Brazilian Portuguese, Russian, Swedish, Romanian, Catalan, Ukrainian, Belarusian, Italian, Polish, Finnish, Danish, Dutch, Turkish, Kazakh, Hungarian, Lithuanian, Slovenian, Valencian, and Hebrew.
|
||||
|
||||
- **Manage Fonts**: Browse, download, and manage custom font families installed from the SD card. See [Custom Fonts (SD Card)](#38-custom-fonts-sd-card) for more information.
|
||||
|
||||
#### 3.6.5 OPDS Servers (Multiple Libraries)
|
||||
|
||||
CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs.
|
||||
|
||||
1. Open **Settings -> System -> OPDS Servers**.
|
||||
|
||||
2. Select **Add Server** to create a new entry, or select an existing server to edit it.
|
||||
|
||||
3. Configure these fields:
|
||||
- **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog").
|
||||
- **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`).
|
||||
- **Username / Password**: Optional credentials for authenticated servers.
|
||||
|
||||
- **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog").
|
||||
|
||||
- **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`).
|
||||
|
||||
- **Username / Password**: Optional credentials for authenticated servers.
|
||||
|
||||
4. Use **Delete Server** inside a server entry to remove it.
|
||||
|
||||
Behavior notes:
|
||||
@@ -224,22 +338,22 @@ You can also manage OPDS servers from the web interface while in File Transfer m
|
||||
2. Open `http://<device-ip>/settings`.
|
||||
3. Use the **OPDS Servers** card to add, edit, or delete entries.
|
||||
|
||||
For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds).
|
||||
For web-based Wi-Fi network management, see [Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds).
|
||||
|
||||
#### 3.6.6 Web Settings (WiFi + OPDS)
|
||||
#### 3.6.6 Web Settings (Wi-Fi + OPDS)
|
||||
|
||||
While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**.
|
||||
While in **File Transfer** mode, the web settings page includes management cards for both **Wi-Fi Networks** and **OPDS Servers**.
|
||||
|
||||
1. On device: open **File Transfer** and connect to WiFi.
|
||||
1. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
|
||||
1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password).
|
||||
1. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
|
||||
1. On device: open **File Transfer** and connect through **Join a Network** or **Create Hotspot**.
|
||||
2. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
|
||||
3. In **Wi-Fi Networks**, add, edit, or delete saved network entries (SSID + optional password).
|
||||
4. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- Passwords are never shown back in the web UI after saving.
|
||||
- Leaving Password blank while editing keeps the existing saved password unchanged.
|
||||
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow.
|
||||
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on the device-side Wi-Fi connection flow.
|
||||
|
||||
#### 3.6.7 KOReader Sync Quick Setup
|
||||
|
||||
@@ -266,13 +380,19 @@ Already have KOReader Sync credentials? Skip registration; basic sync only requi
|
||||
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
||||
|
||||
2. On each CrossPoint device:
|
||||
|
||||
- Go to **Settings -> System -> KOReader Sync**.
|
||||
|
||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||
|
||||
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
|
||||
|
||||
- Run **Authenticate**.
|
||||
|
||||
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
||||
|
||||
- Choose **Apply Remote** to jump to remote progress.
|
||||
|
||||
- Choose **Upload Local** to push current progress.
|
||||
|
||||
##### Option B: Self-Hosted Server (Docker Compose)
|
||||
@@ -315,7 +435,7 @@ curl -H "Accept: application/vnd.koreader.v1+json" "http://<server-ip>:17200/hea
|
||||
```
|
||||
|
||||
3. Register a user once.
|
||||
CrossPoint authenticates against KOReader Sync (`koreader/kosync`) using an MD5 key, so register using the MD5 of your password:
|
||||
CrossPoint authenticates against KOReader Sync (`koreader/kosync`) using an MD5 key, so register using the MD5 of your password:
|
||||
|
||||
> [!WARNING]
|
||||
> Sending a reusable MD5-derived password over plain HTTP is insecure.
|
||||
@@ -337,29 +457,35 @@ curl -i "http://<server-ip>:17200/users/create" \
|
||||
If this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, the account already exists.
|
||||
|
||||
4. On each CrossPoint device:
|
||||
|
||||
- Go to **Settings -> System -> KOReader Sync**.
|
||||
|
||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||
|
||||
- Set **Sync Server URL** to `http://<server-ip>:17200`.
|
||||
|
||||
- Run **Authenticate**.
|
||||
|
||||
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
||||
|
||||
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
||||
|
||||
- Choose **Apply Remote** to jump to remote progress.
|
||||
|
||||
- Choose **Upload Local** to push current progress.
|
||||
|
||||
### 3.7 Sleep Screen
|
||||
|
||||
The **Sleep Screen** setting controls what is displayed when the device goes to sleep:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Dark** (default) | The CrossPoint logo on a dark background. |
|
||||
| **Light** | The CrossPoint logo on a white background. |
|
||||
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
|
||||
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
|
||||
| **Cover + Custom** | The cover of the currently open book. Falls back to **Custom** behavior if no book is open. |
|
||||
| **None** | A blank screen. |
|
||||
| Mode | Behavior |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dark** (default) | The CrossPoint logo on a dark background. |
|
||||
| **Light** | The CrossPoint logo on a white background. |
|
||||
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
|
||||
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
|
||||
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
|
||||
| **None** | A blank screen. |
|
||||
|
||||
#### Cover settings
|
||||
|
||||
@@ -377,10 +503,30 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
|
||||
|
||||
> [!TIP]
|
||||
> For best results:
|
||||
>
|
||||
> - Use uncompressed BMP files with 24-bit color depth
|
||||
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
|
||||
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
|
||||
|
||||
> [!TIP]
|
||||
> You can set an image as the sleep screen cover directly from the BMP image viewer in the **[Browse Files](#33-browse-files-screen)** screen.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Custom Fonts (SD Card)
|
||||
|
||||
CrossPoint supports loading additional fonts from the SD card, extending beyond the two built-in families (Noto Serif, Noto Sans). Custom fonts can include extended Unicode coverage, enabling CJK (Chinese, Japanese, Korean) and other scripts.
|
||||
|
||||
There are three ways to install fonts:
|
||||
|
||||
1. **Download from device (recommended):** Go to **Settings -> System -> Manage Fonts**, browse the available font families, and select one to download over Wi-Fi.
|
||||
2. **Upload via web interface:** While in **File Transfer** mode, open the web UI in a browser and navigate to the **Fonts** tab to upload `.cpfont` files.
|
||||
3. **Manual SD card copy:** Download font files from the [crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts) and copy them to `/.fonts/` (preferred) or `/fonts/` on your SD card.
|
||||
|
||||
Once installed, custom fonts appear in **Settings → Reader → Font Family** alongside the built-in fonts.
|
||||
|
||||
See [docs/sd-card-fonts.md](./docs/sd-card-fonts.md) for full installation details and SD card folder structure.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reading Mode
|
||||
@@ -388,6 +534,7 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
|
||||
Once you have opened a book, the button layout changes to facilitate reading.
|
||||
|
||||
### Page Turning
|
||||
|
||||
| Action | Buttons |
|
||||
| ----------------- | ------------------------------------ |
|
||||
| **Previous Page** | Press **Left** _or_ **Volume Up** |
|
||||
@@ -398,55 +545,142 @@ The role of the volume (side) buttons can be swapped in the **[Controls Settings
|
||||
If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button.
|
||||
|
||||
### Chapter Navigation
|
||||
|
||||
* **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release.
|
||||
* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release.
|
||||
|
||||
This feature can be disabled in the **[Controls Settings](#363-controls)** to help avoid changing chapters by mistake.
|
||||
|
||||
### Auto Page Turn
|
||||
|
||||
Auto Page Turn automatically advances pages at a set interval, useful for hands-free reading. This feature can be enabled and configured from the **[Reader Menu](#5-reader-menu)** while reading an EPUB.
|
||||
|
||||
### Tilt Page Turn (X3 only)
|
||||
|
||||
On the **Xteink X3**, the gyroscope can be used to turn pages by tilting the device. This feature is available in the Controls settings.
|
||||
|
||||
### Footnote Navigation
|
||||
|
||||
When reading an EPUB that contains footnotes, you can navigate to the footnote text by selecting the footnote reference in the book. From the footnote, you can return to your original reading position.
|
||||
|
||||
If the device goes to sleep or you close the book while viewing a footnote, the book reopens to your original reading position, not the footnote.
|
||||
|
||||
### System Navigation
|
||||
|
||||
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
||||
* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen.
|
||||
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)** screen.
|
||||
* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more.
|
||||
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu.
|
||||
|
||||
### Supported Languages
|
||||
|
||||
CrossPoint renders text using the following Unicode character blocks, enabling support for a wide range of languages:
|
||||
|
||||
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
|
||||
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
|
||||
* **Latin Script (Basic, Supplement, Extended-A/B):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, Catalan, and others.
|
||||
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
|
||||
* **Vietnamese:** Supported via extended Latin glyph coverage in the built-in reader fonts.
|
||||
|
||||
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
|
||||
What is not supported with built-in reader fonts: Chinese, Japanese, Korean, Arabic, Greek, Hebrew, and Farsi. However, **CJK, Hebrew, Greek, and other extended scripts can be enabled by installing custom SD card fonts** — see [Custom Fonts (SD Card)](#38-custom-fonts-sd-card).
|
||||
|
||||
---
|
||||
|
||||
## 5. Chapter Selection Screen
|
||||
## 5. Reader Menu
|
||||
|
||||
Accessible by pressing **Confirm** while inside a book.
|
||||
Press **Confirm** while reading to open the Reader Menu. From here you can access reading utilities and navigation options without leaving the book.
|
||||
|
||||
1. Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to highlight the desired chapter.
|
||||
2. Press **Confirm** to jump to that chapter.
|
||||
3. *Alternatively, press **Back** to cancel and return to your current page.*
|
||||
Available options include:
|
||||
|
||||
- **Select Chapter** – Open the table of contents to jump to a specific chapter (see [Chapter Selection](#51-chapter-selection) below).
|
||||
- **Footnotes** – Navigate to the footnotes for the current section *(only shown in books that contain footnotes)*.
|
||||
- **Reading Orientation** – Cycle through screen orientations without leaving the reader.
|
||||
- **Auto Turn (Pages Per Minute)** – Cycle through automatic page turn speed options for hands-free reading.
|
||||
- **Go to %** – Jump to a specific position in the book by percentage.
|
||||
- **Take screenshot** – Save a screenshot of the current page to the `screenshots/` folder.
|
||||
- **Show page as QR** – Display a QR code encoding the current reading position.
|
||||
- **Go Home** – Close the book and return to the Home screen.
|
||||
- **Sync Progress** – Push or pull reading progress with a KOReader sync server (see [KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)).
|
||||
- **Delete Book Cache** – Clear the cached layout data for the current book, forcing a re-index on next open.
|
||||
|
||||
Press **Back** at any time to close the menu and return to your current page.
|
||||
|
||||
### 5.1 Chapter Selection
|
||||
|
||||
Accessible by selecting **Chapters** from the Reader Menu.
|
||||
|
||||
1. Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to highlight the desired chapter.
|
||||
2. Press **Confirm** to jump to that chapter.
|
||||
3. *Alternatively, press **Back** to cancel and return to your current page.*
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Bookmarks
|
||||
|
||||
Bookmarks can be created to quickly save and restore your place in a book.
|
||||
|
||||
To create a bookmark, hold **Confirm** for about half a second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
|
||||
|
||||
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for about 0.7 seconds, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
|
||||
|
||||
Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format.
|
||||
|
||||
## 6. Current Limitations & Roadmap
|
||||
|
||||
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
|
||||
|
||||
* **Images:** Embedded images in e-books will not render.
|
||||
* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up.
|
||||
* **Unsupported Image Formats:** Most JPG and PNG images in EPUBs render correctly. GIFs and progressive JPEGs are not supported and will fall back to an `[Image]` placeholder.
|
||||
*
|
||||
* **Dictionary Lookup:** Inline word lookup is not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting Issues & Escaping Bootloop
|
||||
|
||||
If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the serial monitor logs. The logs can be obtained by connecting the device to a computer and starting a serial monitor. Either [Serial Monitor](https://www.serialmonitor.org/) or the following command can be used:
|
||||
If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the logs.
|
||||
|
||||
**Crash reports on SD card:** After a crash, CrossPoint automatically saves a crash report to the SD card (no USB connection needed). Check the root of the SD card for a crash log file and include it with any bug report.
|
||||
|
||||
**Serial monitor logs:** For more detailed debugging, connect the device to a computer and run the custom debugging monitor script (requires Python 3 with `pyserial`, `colorama`, and `matplotlib`; install via `pip3 install pyserial colorama matplotlib`):
|
||||
|
||||
```
|
||||
pio device monitor
|
||||
python3 scripts/debugging_monitor.py
|
||||
```
|
||||
|
||||
The script auto-detects the serial port. You can also specify one explicitly:
|
||||
|
||||
```
|
||||
python3 scripts/debugging_monitor.py /dev/ttyACM0 # Linux
|
||||
python3 scripts/debugging_monitor.py /dev/tty.usbmodem1 # macOS
|
||||
python3 scripts/debugging_monitor.py COM7 # Windows
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Color-coded log output by category (errors, memory, display, EPUB parsing, etc.)
|
||||
- Live memory usage graph (free RAM, total RAM, max contiguous allocation) updated every second
|
||||
- Interactive command prompt — type a command and press Enter to send it to the device
|
||||
- Screenshot capture — saves the current display to `screenshot.bmp` when triggered by the device
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
| -------------------- | --------------------------------------------------------- |
|
||||
| `--baud RATE` | Baud rate (default: 115200) |
|
||||
| `--filter KEYWORD` | Show only lines containing the keyword (case-insensitive) |
|
||||
| `--suppress KEYWORD` | Hide lines containing the keyword (case-insensitive) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
# Show only memory-related log lines
|
||||
python3 scripts/debugging_monitor.py --filter MEM
|
||||
|
||||
# Hide noisy SD card log lines
|
||||
python3 scripts/debugging_monitor.py --suppress "[SD]"
|
||||
```
|
||||
|
||||
Press **Ctrl-C** or close the graph window to exit.
|
||||
|
||||
If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen.
|
||||
|
||||
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.bin`, `state.bin`, or `epub_*` cache directories in the `.crosspoint/` folder).
|
||||
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.json`, `state.json`, or `epub_*` cache directories in the `.crosspoint/` folder).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Formats all C/C++ source and header files in the repository, excluding
|
||||
generated, vendored, and build directories (open-x4-sdk, builtinFonts,
|
||||
generated, vendored, and build directories (freeink-sdk, builtinFonts,
|
||||
hyphenation tries, uzlib, .pio, *.generated.h).
|
||||
|
||||
The clang-format binary path is resolved once and cached in
|
||||
@@ -92,7 +92,7 @@ function Resolve-ClangFormat {
|
||||
$clangFormat = Resolve-ClangFormat
|
||||
|
||||
$exclude = @(
|
||||
'open-x4-sdk'
|
||||
'freeink-sdk'
|
||||
'lib\EpdFont\builtinFonts'
|
||||
'lib\Epub\Epub\hyphenation\generated'
|
||||
'lib\uzlib'
|
||||
|
||||
@@ -8,17 +8,18 @@ At a high level, it is firmware that uses an activity-driven application archite
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk HAL]
|
||||
B --> C[src/main.cpp runtime loop]
|
||||
C --> D[Activities layer]
|
||||
C --> E[State and settings]
|
||||
D --> F[Reader flows]
|
||||
D --> G[Home/Library/Settings flows]
|
||||
D --> H[Network/Web server flows]
|
||||
F --> I[lib/Epub parsing + layout + hyphenation]
|
||||
I --> J[SD cache in .crosspoint]
|
||||
D --> K[GfxRenderer]
|
||||
K --> L[E-ink display buffer]
|
||||
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk]
|
||||
B --> C[lib/hal wrappers]
|
||||
C --> D[src/main.cpp runtime loop]
|
||||
D --> E[Activities layer]
|
||||
D --> F[State and settings]
|
||||
E --> G[Reader flows]
|
||||
E --> H[Home/Library/Settings flows]
|
||||
E --> I[Network/Web server flows]
|
||||
G --> J[lib/Epub parsing + layout + hyphenation]
|
||||
J --> K[SD cache in .crosspoint]
|
||||
E --> L[GfxRenderer]
|
||||
L --> M[E-ink display buffer]
|
||||
```
|
||||
|
||||
## Runtime lifecycle
|
||||
@@ -58,7 +59,7 @@ Top-level activity groups:
|
||||
- `src/activities/home/`: home and library navigation
|
||||
- `src/activities/reader/`: EPUB/XTC/TXT reading flows
|
||||
- `src/activities/settings/`: settings menus and configuration
|
||||
- `src/activities/network/`: WiFi selection, AP/STA mode, file transfer server
|
||||
- `src/activities/network/`: Wi-Fi selection, AP/STA mode, file transfer server
|
||||
- `src/activities/boot_sleep/`: boot and sleep transitions
|
||||
|
||||
## Reader and content pipeline
|
||||
@@ -73,10 +74,11 @@ flowchart LR
|
||||
C -->|EPUB| D[lib/Epub/Epub]
|
||||
C -->|XTC| E[lib/Xtc reader]
|
||||
C -->|TXT| F[lib/Txt reader]
|
||||
D --> G[Parse OPF/TOC/CSS]
|
||||
G --> H[Layout pages/sections]
|
||||
H --> I[Write section and metadata caches]
|
||||
I --> J[Render current page via GfxRenderer]
|
||||
D --> G[Parse OPF/TOC and collect CSS refs]
|
||||
G --> H[Build/load book.bin and css_rules.cache]
|
||||
H --> I[Layout pages/sections]
|
||||
I --> J[Write section cache]
|
||||
J --> K[Render current page via GfxRenderer]
|
||||
```
|
||||
|
||||
Why caching matters:
|
||||
@@ -98,7 +100,7 @@ flowchart TD
|
||||
D --> E[Locate container and OPF]
|
||||
E --> F[Build or load BookMetadataCache]
|
||||
F --> G[Load TOC and spine]
|
||||
G --> H[Load or parse CSS rules]
|
||||
G --> H[Load CSS cache or parse manifest/base-dir CSS]
|
||||
|
||||
H --> I[EpubReaderActivity]
|
||||
I --> J{Section cache exists for current settings?}
|
||||
@@ -121,7 +123,12 @@ flowchart TD
|
||||
|
||||
Notes:
|
||||
|
||||
- "section cache exists" depends on cache-busting parameters such as font and layout-related settings
|
||||
- CSS files are collected from the OPF manifest and, when needed, discovered by
|
||||
streaming ZIP paths under the OPF content base directory; the firmware avoids
|
||||
preloading the full ZIP central directory for large books.
|
||||
- "section cache exists" depends on cache-busting parameters such as font,
|
||||
viewport size, paragraph alignment, hyphenation, embedded CSS, image rendering,
|
||||
and Focus Reading settings
|
||||
- rendering favors reusing precomputed layout data to keep page turns responsive on constrained hardware
|
||||
- progress/session state is persisted so the reader can reopen at the last position after reboot/sleep
|
||||
|
||||
@@ -138,14 +145,18 @@ Typical persisted areas on SD:
|
||||
/.crosspoint/
|
||||
epub_<hash>/
|
||||
book.bin
|
||||
css_rules.cache
|
||||
progress.bin
|
||||
cover.bmp
|
||||
sections/*.bin
|
||||
settings.bin
|
||||
state.bin
|
||||
img_* cache files
|
||||
settings.json
|
||||
state.json
|
||||
```
|
||||
|
||||
For binary cache formats, see `docs/file-formats.md`.
|
||||
`sections/*.bin` contains rendered pages plus anchor, paragraph, and list-item
|
||||
lookup tables used for TOC/footnote jumps and KOReader sync refinement. For
|
||||
binary cache formats, see `docs/file-formats.md`.
|
||||
|
||||
## Networking architecture
|
||||
|
||||
@@ -153,14 +164,18 @@ Network file transfer is controlled by `src/activities/network/CrossPointWebServ
|
||||
|
||||
Modes:
|
||||
|
||||
- STA: join existing WiFi network
|
||||
- STA: join existing Wi-Fi network
|
||||
- AP: create hotspot
|
||||
- Calibre Wireless: STA flow specialized for Calibre plugin uploads
|
||||
|
||||
Server behavior:
|
||||
|
||||
- HTTP server on port 80
|
||||
- WebSocket upload server on port 81
|
||||
- WebDAV handler on the HTTP server
|
||||
- UDP discovery listener for upload clients
|
||||
- file operations backed by SD storage
|
||||
- browser APIs for file management, settings, fonts, OPDS servers, and saved Wi-Fi networks
|
||||
- activity requests faster loop responsiveness while server is running
|
||||
|
||||
Endpoint reference: `docs/webserver-endpoints.md`.
|
||||
@@ -170,6 +185,7 @@ Endpoint reference: `docs/webserver-endpoints.md`.
|
||||
Some sources are generated and should not be edited manually.
|
||||
|
||||
- `scripts/build_html.py` generates `src/network/html/*.generated.h` from HTML files
|
||||
- `scripts/gen_i18n.py` generates `lib/I18n/I18nKeys.h`, `I18nStrings.h`, and `I18nStrings.cpp`
|
||||
- `scripts/generate_hyphenation_trie.py` generates hyphenation headers under `lib/Epub/Epub/hyphenation/generated/`
|
||||
|
||||
When editing related source assets, regenerate via normal build steps/scripts.
|
||||
@@ -179,9 +195,10 @@ When editing related source assets, regenerate via normal build steps/scripts.
|
||||
- `src/`: app orchestration, settings/state, and activity implementations
|
||||
- `src/network/`: web server and OTA/update networking
|
||||
- `src/components/`: theming and shared UI components
|
||||
- `lib/hal/`: hardware abstraction wrappers around freeink-sdk
|
||||
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
|
||||
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
|
||||
- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery)
|
||||
- `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs
|
||||
- `docs/`: user and technical documentation
|
||||
|
||||
## Embedded constraints that shape design
|
||||
|
||||
+189
-90
@@ -1,22 +1,26 @@
|
||||
# File Formats
|
||||
|
||||
These formats describe the SD-card cache files under `/.crosspoint/epub_<hash>/`.
|
||||
All POD fields are written in the ESP32 little-endian representation used by
|
||||
`Serialization.h`; strings are length-prefixed UTF-8.
|
||||
|
||||
## `book.bin`
|
||||
|
||||
### Version 3
|
||||
### Version 7
|
||||
|
||||
ImHex Pattern:
|
||||
`book.bin` stores EPUB metadata plus lookup tables for spine and TOC entries.
|
||||
The current firmware writes this version from `BookMetadataCache`.
|
||||
|
||||
ImHex pattern:
|
||||
|
||||
```c++
|
||||
import std.mem;
|
||||
import std.string;
|
||||
import std.core;
|
||||
|
||||
// === Configuration ===
|
||||
#define EXPECTED_VERSION 3
|
||||
#define EXPECTED_VERSION 7
|
||||
#define MAX_STRING_LENGTH 65535
|
||||
|
||||
// === String Structure ===
|
||||
|
||||
struct String {
|
||||
u32 length [[hidden, comment("String byte length")]];
|
||||
if (length > MAX_STRING_LENGTH) {
|
||||
@@ -29,74 +33,56 @@ fn format_string(String s) {
|
||||
return s.data;
|
||||
};
|
||||
|
||||
// === Metadata Structure ===
|
||||
|
||||
struct Metadata {
|
||||
String title [[comment("Book title")]];
|
||||
String author [[comment("Book author")]];
|
||||
String language [[comment("Book language code")]];
|
||||
String coverItemHref [[comment("Path to cover image")]];
|
||||
String textReferenceHref [[comment("Path to guided first text reference")]];
|
||||
} [[comment("Book metadata information")]];
|
||||
|
||||
// === Spine Entry Structure ===
|
||||
};
|
||||
|
||||
struct SpineEntry {
|
||||
String href [[comment("Resource path")]];
|
||||
u32 cumulativeSize [[comment("Cumulative size in bytes"), color("FF6B6B")]];
|
||||
s16 tocIndex [[comment("Index into TOC (-1 if none)"), color("4ECDC4")]];
|
||||
} [[comment("Spine entry defining reading order")]];
|
||||
|
||||
// === TOC Entry Structure ===
|
||||
u32 cumulativeSize [[comment("Cumulative uncompressed spine size through this entry")]];
|
||||
s16 tocIndex [[comment("Index into TOC, or inherited/previous TOC index when no direct entry exists")]];
|
||||
};
|
||||
|
||||
struct TocEntry {
|
||||
String title [[comment("Chapter/section title")]];
|
||||
String href [[comment("Resource path")]];
|
||||
String anchor [[comment("Fragment identifier")]];
|
||||
u8 level [[comment("Nesting level (0-255)"), color("95E1D3")]];
|
||||
s16 spineIndex [[comment("Index into spine (-1 if none)"), color("F38181")]];
|
||||
} [[comment("Table of contents entry")]];
|
||||
|
||||
// === Book Bin Structure ===
|
||||
u8 level [[comment("Nesting level")]];
|
||||
s16 spineIndex [[comment("Index into spine (-1 if none)")]];
|
||||
};
|
||||
|
||||
struct BookBin {
|
||||
// Header
|
||||
u8 version [[comment("Format version"), color("FFD93D")]];
|
||||
|
||||
// Version validation
|
||||
u8 version;
|
||||
if (version != EXPECTED_VERSION) {
|
||||
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
|
||||
}
|
||||
|
||||
u32 lutOffset [[comment("Offset to lookup tables"), color("6BCB77")]];
|
||||
u16 spineCount [[comment("Number of spine entries"), color("4D96FF")]];
|
||||
u16 tocCount [[comment("Number of TOC entries"), color("FF6B9D")]];
|
||||
|
||||
// Metadata section
|
||||
Metadata metadata [[comment("Book metadata")]];
|
||||
|
||||
// Validate LUT offset alignment
|
||||
|
||||
u32 lutOffset [[comment("Offset to lookup tables")]];
|
||||
u16 spineCount;
|
||||
u16 tocCount;
|
||||
|
||||
Metadata metadata;
|
||||
|
||||
u32 currentOffset = $;
|
||||
if (currentOffset != lutOffset) {
|
||||
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
|
||||
}
|
||||
|
||||
// Lookup Tables
|
||||
u32 spineLut[spineCount] [[comment("Spine entry offsets"), color("4D96FF")]];
|
||||
u32 tocLut[tocCount] [[comment("TOC entry offsets"), color("FF6B9D")]];
|
||||
|
||||
// Data Entries
|
||||
SpineEntry spines[spineCount] [[comment("Spine entries (reading order)")]];
|
||||
TocEntry toc[tocCount] [[comment("Table of contents entries")]];
|
||||
};
|
||||
|
||||
// === File Parsing ===
|
||||
u32 spineLut[spineCount] [[comment("Spine entry offsets")]];
|
||||
u32 tocLut[tocCount] [[comment("TOC entry offsets")]];
|
||||
|
||||
SpineEntry spines[spineCount];
|
||||
TocEntry toc[tocCount];
|
||||
};
|
||||
|
||||
BookBin book @ 0x00;
|
||||
|
||||
// Validate we've consumed the entire file
|
||||
u32 fileSize = std::mem::size();
|
||||
u32 parsedSize = $;
|
||||
|
||||
if (parsedSize != fileSize) {
|
||||
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
|
||||
}
|
||||
@@ -104,20 +90,39 @@ if (parsedSize != fileSize) {
|
||||
|
||||
## `section.bin`
|
||||
|
||||
### Version 8
|
||||
### Version 29
|
||||
|
||||
ImHex Pattern:
|
||||
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 29 includes:
|
||||
|
||||
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
||||
image rendering mode, and Focus Reading
|
||||
- page offset LUT
|
||||
- anchor-to-page map for fragment and footnote navigation
|
||||
- paragraph and list-item LUTs used by KOReader sync page refinement
|
||||
- optional per-word Focus Reading split metadata
|
||||
- per-page footnote entries
|
||||
- serialized word style bits for underline, strikethrough, superscript, and
|
||||
subscript
|
||||
- flat TextBlock word storage (v29): per-word arrays plus one shared
|
||||
NUL-terminated text blob, replacing v28's length-prefixed word strings. The
|
||||
on-disk order mirrors the in-RAM arena so the firmware reads a whole block
|
||||
payload with a single allocation and a single SD read
|
||||
|
||||
ImHex pattern:
|
||||
|
||||
```c++
|
||||
import std.mem;
|
||||
import std.string;
|
||||
import std.core;
|
||||
|
||||
// === Configuration ===
|
||||
#define EXPECTED_VERSION 8
|
||||
#define EXPECTED_VERSION 29
|
||||
#define MAX_STRING_LENGTH 65535
|
||||
|
||||
// === String Structure ===
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
|
||||
struct String {
|
||||
u32 length [[hidden, comment("String byte length")]];
|
||||
@@ -131,90 +136,184 @@ fn format_string(String s) {
|
||||
return s.data;
|
||||
};
|
||||
|
||||
// === Page Structure ===
|
||||
|
||||
enum StorageType : u8 {
|
||||
PageLine = 1
|
||||
enum PageElementTag : u8 {
|
||||
TAG_PageLine = 1,
|
||||
TAG_PageImage = 2,
|
||||
TAG_PageHorizontalRule = 3
|
||||
};
|
||||
|
||||
enum WordStyle : u8 {
|
||||
REGULAR = 0,
|
||||
BOLD = 1,
|
||||
ITALIC = 2,
|
||||
BOLD_ITALIC = 3
|
||||
BOLD_ITALIC = 3,
|
||||
UNDERLINE = 4,
|
||||
STRIKETHROUGH = 8,
|
||||
SUP = 16,
|
||||
SUB = 32
|
||||
};
|
||||
|
||||
enum BlockStyle : u8 {
|
||||
enum TextAlign : u8 {
|
||||
JUSTIFIED = 0,
|
||||
LEFT_ALIGN = 1,
|
||||
CENTER_ALIGN = 2,
|
||||
RIGHT_ALIGN = 3,
|
||||
NONE = 4
|
||||
};
|
||||
|
||||
struct BlockStyle {
|
||||
TextAlign alignment;
|
||||
bool textAlignDefined;
|
||||
s16 marginTop;
|
||||
s16 marginBottom;
|
||||
s16 marginLeft;
|
||||
s16 marginRight;
|
||||
s16 paddingTop;
|
||||
s16 paddingBottom;
|
||||
s16 paddingLeft;
|
||||
s16 paddingRight;
|
||||
s16 textIndent;
|
||||
bool textIndentDefined;
|
||||
bool isRtl;
|
||||
bool directionDefined;
|
||||
};
|
||||
|
||||
struct TextBlock {
|
||||
u16 wordCount;
|
||||
u8 hasFocus;
|
||||
u16 textBytes [[comment("Total size of text[], including one NUL per word")]];
|
||||
|
||||
if (wordCount > 0) {
|
||||
u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]];
|
||||
s16 wordXPos[wordCount];
|
||||
if (hasFocus != 0) {
|
||||
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
|
||||
}
|
||||
WordStyle wordStyle[wordCount];
|
||||
if (hasFocus != 0) {
|
||||
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
|
||||
}
|
||||
char text[textBytes] [[comment("All words back to back, each NUL-terminated")]];
|
||||
}
|
||||
|
||||
BlockStyle blockStyle;
|
||||
};
|
||||
|
||||
struct ImageBlock {
|
||||
String imagePath;
|
||||
s16 width;
|
||||
s16 height;
|
||||
};
|
||||
|
||||
struct PageLine {
|
||||
s16 xPos;
|
||||
s16 yPos;
|
||||
u16 wordCount;
|
||||
String words[wordCount];
|
||||
u16 wordXPos[wordCount];
|
||||
WordStyle wordStyle[wordCount];
|
||||
BlockStyle blockStyle;
|
||||
s16 xPos;
|
||||
s16 yPos;
|
||||
TextBlock block;
|
||||
};
|
||||
|
||||
struct PageImage {
|
||||
s16 xPos;
|
||||
s16 yPos;
|
||||
ImageBlock image;
|
||||
};
|
||||
|
||||
struct PageHorizontalRule {
|
||||
s16 xPos;
|
||||
s16 yPos;
|
||||
u16 width;
|
||||
u8 thickness;
|
||||
};
|
||||
|
||||
struct PageElement {
|
||||
u8 pageElementType;
|
||||
if (pageElementType == 1) {
|
||||
PageElementTag pageElementType;
|
||||
if (pageElementType == TAG_PageLine) {
|
||||
PageLine pageLine [[inline]];
|
||||
} else if (pageElementType == TAG_PageImage) {
|
||||
PageImage pageImage [[inline]];
|
||||
} else if (pageElementType == TAG_PageHorizontalRule) {
|
||||
PageHorizontalRule horizontalRule [[inline]];
|
||||
} else {
|
||||
std::error(std::format("Unknown page element type: {}", pageElementType));
|
||||
}
|
||||
};
|
||||
|
||||
struct FootnoteEntry {
|
||||
char number[FOOTNOTE_NUMBER_LEN];
|
||||
char href[FOOTNOTE_HREF_LEN];
|
||||
};
|
||||
|
||||
struct Page {
|
||||
u16 elementCount;
|
||||
PageElement elements[elementCount] [[inline]];
|
||||
|
||||
u16 footnoteCount;
|
||||
FootnoteEntry footnotes[footnoteCount];
|
||||
};
|
||||
|
||||
// === Section Bin Structure ===
|
||||
struct AnchorEntry {
|
||||
String anchor;
|
||||
u16 page;
|
||||
};
|
||||
|
||||
struct AnchorMap {
|
||||
u16 count;
|
||||
AnchorEntry entries[count];
|
||||
};
|
||||
|
||||
struct ParagraphLut {
|
||||
u16 count;
|
||||
u16 paragraphIndex[count];
|
||||
};
|
||||
|
||||
struct SectionBin {
|
||||
// Header
|
||||
u8 version [[comment("Format version"), color("FFD93D")]];
|
||||
|
||||
// Version validation
|
||||
u8 version;
|
||||
if (version != EXPECTED_VERSION) {
|
||||
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
|
||||
}
|
||||
|
||||
// Cache busting parameters
|
||||
|
||||
s32 fontId;
|
||||
float lineCompression;
|
||||
bool extraParagraphSpacing;
|
||||
u8 paragraphAlignment;
|
||||
u16 viewportWidth;
|
||||
u16 vieportHeight;
|
||||
u16 viewportHeight;
|
||||
bool hyphenationEnabled;
|
||||
bool embeddedStyle;
|
||||
u8 imageRendering;
|
||||
bool focusReadingEnabled;
|
||||
|
||||
u16 pageCount;
|
||||
u32 lutOffset;
|
||||
|
||||
Page page[pageCount];
|
||||
|
||||
// Validate LUT offset alignment
|
||||
u32 pageLutOffset;
|
||||
u32 anchorMapOffset;
|
||||
u32 paragraphLutOffset;
|
||||
u32 listItemLutOffset;
|
||||
|
||||
Page pages[pageCount];
|
||||
|
||||
u32 currentOffset = $;
|
||||
if (currentOffset != lutOffset) {
|
||||
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
|
||||
if (currentOffset != pageLutOffset) {
|
||||
std::warning(std::format("Page LUT offset mismatch: expected 0x{:X}, got 0x{:X}", pageLutOffset, currentOffset));
|
||||
}
|
||||
|
||||
u32 pageLut[pageCount] [[comment("Page data offsets")]];
|
||||
|
||||
if (anchorMapOffset != 0) {
|
||||
AnchorMap anchorMap @ anchorMapOffset;
|
||||
}
|
||||
|
||||
if (paragraphLutOffset != 0) {
|
||||
ParagraphLut paragraphLut @ paragraphLutOffset;
|
||||
}
|
||||
|
||||
if (listItemLutOffset != 0 && paragraphLutOffset != 0) {
|
||||
u16 listItemIndex[paragraphLut.count] @ listItemLutOffset;
|
||||
}
|
||||
|
||||
// Lookup Tables
|
||||
u32 lut[pageCount];
|
||||
};
|
||||
|
||||
// === File Parsing ===
|
||||
SectionBin section @ 0x00;
|
||||
|
||||
SectionBin book @ 0x00;
|
||||
|
||||
// Validate we've consumed the entire file
|
||||
u32 fileSize = std::mem::size();
|
||||
u32 parsedSize = $;
|
||||
|
||||
if (parsedSize != fileSize) {
|
||||
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ Focus Reading is a reading aid that bolds the first portion of each word, guidin
|
||||
1. Open **Settings > Reader**
|
||||
2. Toggle **Focus Reading** on
|
||||
|
||||
Toggling the setting will trigger a re-index of your current book, the same as when changing font settings. Once indexing is complete, page turns proceed as normal. No changes are made to your EPUB files.
|
||||
Toggling the setting invalidates affected EPUB section caches for the current layout, the same as changing font settings. Sections are rebuilt on demand, then page turns proceed as normal. No changes are made to your EPUB files.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
+42
-23
@@ -5,17 +5,29 @@ This guide explains the multi-language support system in CrossPoint Reader.
|
||||
## Supported Languages
|
||||
|
||||
- English
|
||||
- French
|
||||
- German
|
||||
- Portuguese
|
||||
- Spanish
|
||||
- Swedish
|
||||
- Czech
|
||||
- Russian
|
||||
- Ukrainian
|
||||
- Polish
|
||||
- Danish
|
||||
- Turkish
|
||||
- Español
|
||||
- Français
|
||||
- Deutsch
|
||||
- Čeština
|
||||
- Português (Brasil)
|
||||
- Русский
|
||||
- Svenska
|
||||
- Română
|
||||
- Català
|
||||
- Українська
|
||||
- Беларуская
|
||||
- Italiano
|
||||
- Polski
|
||||
- Suomi
|
||||
- Dansk
|
||||
- Nederlands
|
||||
- Türkçe
|
||||
- Қазақша
|
||||
- Magyar
|
||||
- Lietuvių
|
||||
- Slovenščina
|
||||
- Valencià
|
||||
- עברית
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +120,9 @@ This automatically:
|
||||
#### 3. Use in code
|
||||
|
||||
```cpp
|
||||
#include <CrossPointSettings.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
|
||||
// Using the tr() macro (recommended)
|
||||
renderer.drawText(font, x, y, tr(STR_MY_NEW_STRING));
|
||||
@@ -175,7 +189,7 @@ The YAML files use UTF-8 encoding. Special characters are automatically converte
|
||||
// tr(id) - Get translated string without StrId:: prefix
|
||||
const char* text = tr(STR_SETTINGS_TITLE);
|
||||
renderer.drawText(font, x, y, tr(STR_BROWSE_FILES));
|
||||
Serial.printf("Status: %s\n", tr(STR_CONNECTED));
|
||||
LOG_INF("I18N", "Status: %s", tr(STR_CONNECTED));
|
||||
|
||||
// I18N - Shorthand for I18n::getInstance()
|
||||
I18N.setLanguage(Language::ES);
|
||||
@@ -191,34 +205,39 @@ const char* text = tr(STR_SETTINGS_TITLE); // Macro (recommended)
|
||||
const char* text = I18N.get(StrId::STR_SETTINGS_TITLE); // Direct call
|
||||
const char* text = I18N[StrId::STR_SETTINGS_TITLE]; // Operator overload
|
||||
|
||||
// Set language
|
||||
// Set runtime language
|
||||
I18N.setLanguage(Language::ES);
|
||||
|
||||
// Get current language
|
||||
Language lang = I18N.getLanguage();
|
||||
|
||||
// Save language setting to file
|
||||
I18N.saveSettings();
|
||||
|
||||
// Load language setting from file
|
||||
I18N.loadSettings();
|
||||
|
||||
// Get character set for font subsetting (static method)
|
||||
const char* chars = I18n::getCharacterSet(Language::FR);
|
||||
|
||||
// Persist a user language choice
|
||||
SETTINGS.language = static_cast<uint8_t>(Language::ES);
|
||||
SETTINGS.saveToFile();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Storage
|
||||
|
||||
Language settings are stored in:
|
||||
The selected language is stored with the rest of the device settings in:
|
||||
```text
|
||||
/.crosspoint/settings.json
|
||||
```
|
||||
|
||||
The JSON field is `language`, stored as a stable language code string such as
|
||||
`"EN"`, `"DE"`, or `"HE"` rather than a raw enum value.
|
||||
|
||||
Older firmware versions used:
|
||||
```text
|
||||
/.crosspoint/language.bin
|
||||
```
|
||||
|
||||
This file contains:
|
||||
- Version byte
|
||||
- Current language selection (1 byte)
|
||||
On load, current firmware migrates that legacy file into `settings.json` and
|
||||
renames it to `language.bin.bak`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
+30
-8
@@ -9,15 +9,15 @@ There are three ways to install fonts:
|
||||
|
||||
### Option 1: Download from device (recommended)
|
||||
|
||||
1. Connect your CrossPoint reader to WiFi
|
||||
1. Connect your CrossPoint reader to Wi-Fi
|
||||
2. Go to **Settings > System > Manage Fonts**
|
||||
3. Browse available font families and tap to download
|
||||
4. Downloaded fonts appear immediately in **Settings > Reader > Font Family**
|
||||
|
||||
### Option 2: Upload via web browser
|
||||
|
||||
1. Connect your CrossPoint reader to WiFi
|
||||
2. Open the web interface in your browser (shown on the WiFi screen)
|
||||
1. Start **File Transfer** and connect through **Join Network** or **Create Hotspot**
|
||||
2. Open the web interface URL shown on the reader
|
||||
3. Navigate to the **Fonts** tab
|
||||
4. Upload `.cpfont` files using the upload form
|
||||
|
||||
@@ -91,14 +91,36 @@ To convert your own TrueType/OpenType fonts:
|
||||
|
||||
| Preset | Coverage |
|
||||
|--------|----------|
|
||||
| `ascii` | U+0020-U+007E (Basic Latin) |
|
||||
| `latin-ext` | European languages (Latin + Extended-A/B) |
|
||||
| `ascii` | U+0020–U+007E (Basic Latin) |
|
||||
| `latin1` | U+0080–U+00FF (Latin-1 Supplement) |
|
||||
| `latin-ext` | European languages (Latin + Extended-A/B + punctuation + ligatures) |
|
||||
| `greek` | Greek + Extended Greek |
|
||||
| `cyrillic` | Cyrillic + Supplement |
|
||||
| `hebrew` | Hebrew + Alphabetic Presentation Forms |
|
||||
| `georgian` | Georgian + Georgian Supplement |
|
||||
| `armenian` | Armenian |
|
||||
| `ethiopic` | Ethiopic + Extended |
|
||||
| `vietnamese` | Vietnamese subset (ơ/ư and combining marks) |
|
||||
| `punctuation` | General punctuation (U+2000–U+206F) |
|
||||
| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth |
|
||||
| `hangul` | Korean Hangul syllables |
|
||||
| `builtin` | Matches built-in Bookerly coverage exactly |
|
||||
| `hangul` | Korean Hangul syllables + Jamo + Compatibility Jamo |
|
||||
| `cherokee` | Cherokee (historic + supplement block) |
|
||||
| `tifinagh` | Tifinagh |
|
||||
| `symbols` | Math, currency, arrows, box-drawing, misc symbols, dingbats |
|
||||
| `reading` | Literary fiction coverage: Latin, Greek, Cyrillic, math/symbol blocks, supplemental punctuation, and CJK quote marks |
|
||||
| `builtin` | Matches the firmware's built-in font conversion intervals |
|
||||
|
||||
Combine presets with commas: `--intervals latin-ext,greek,cyrillic`
|
||||
|
||||
Install custom fonts via WiFi upload or manual SD card copy.
|
||||
You can also specify arbitrary Unicode ranges directly:
|
||||
`--intervals latin-ext,(0x2100-0x214F)`
|
||||
|
||||
To list all presets with codepoint counts:
|
||||
|
||||
python3 lib/EpdFont/scripts/fontconvert_sdcard.py --list-presets
|
||||
|
||||
### Additional options
|
||||
|
||||
`--force-autohint` — force FreeType's auto-hinter instead of the font's native hinting (useful when a font's built-in hints produce poor results at small sizes).
|
||||
|
||||
Install custom fonts via the web interface or manual SD card copy.
|
||||
|
||||
+6
-2
@@ -1,7 +1,9 @@
|
||||
# Translators
|
||||
|
||||
Below is a list of users and languages CrossPoint may support in the future.
|
||||
Note because a language is below does not mean there is official support for the language at this time.
|
||||
Below is a list of translator credits for languages with known contributors.
|
||||
Official UI language support is determined by the YAML files in
|
||||
`lib/I18n/translations/`; see [i18n.md](./i18n.md) for the current supported
|
||||
language list.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -37,6 +39,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
|
||||
- [Skrzakk](https://github.com/Skrzakk)
|
||||
- [pablohc](https://github.com/pablohc)
|
||||
- [DaniPhii](https://github.com/DaniPhii)
|
||||
- [lpla](https://github.com/lpla)
|
||||
|
||||
## Swedish
|
||||
- [dawiik](https://github.com/dawiik)
|
||||
@@ -47,6 +50,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
|
||||
|
||||
## Catalan
|
||||
- [angeldenom](https://github.com/angeldenom)
|
||||
- [lpla](https://github.com/lpla)
|
||||
|
||||
## Finnish
|
||||
- [plahteenlahti](https://github.com/plahteenlahti)
|
||||
|
||||
+13
-10
@@ -1,6 +1,6 @@
|
||||
# Troubleshooting
|
||||
|
||||
This document show most common issues and possible solutions while using the device features.
|
||||
This document shows common issues and possible solutions while using the device features.
|
||||
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Cannot See the Device on the Network](#cannot-see-the-device-on-the-network)
|
||||
@@ -14,25 +14,27 @@ This document show most common issues and possible solutions while using the dev
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify both devices are on the **same WiFi network**
|
||||
- Check your computer/phone WiFi settings
|
||||
- Confirm the CrossPoint Reader shows "Connected" status
|
||||
1. Verify both devices are on the correct network
|
||||
- Check your computer/phone Wi-Fi settings
|
||||
- In **Join Network** mode, your computer/phone and CrossPoint Reader must be on the same Wi-Fi network
|
||||
- In **Create Hotspot** mode, your computer/phone must be connected to the `CrossPoint-Reader` hotspot
|
||||
2. Double-check the IP address
|
||||
- Make sure you typed it correctly
|
||||
- Include `http://` at the beginning
|
||||
- Try the displayed IP address if `http://crosspoint.local/` does not resolve
|
||||
3. Try disabling VPN if you're using one
|
||||
4. Some networks have "client isolation" enabled - check with your network administrator
|
||||
4. Some networks have "client isolation" enabled - use Create Hotspot mode or check with your network administrator
|
||||
|
||||
### Connection Drops or Times Out
|
||||
|
||||
**Problem:** WiFi connection is unstable
|
||||
**Problem:** Wi-Fi connection is unstable
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Move closer to the WiFi router
|
||||
1. Move closer to the Wi-Fi router, or use Create Hotspot mode for a direct connection
|
||||
2. Check signal strength on the device (should be at least `||` or better)
|
||||
3. Avoid interference from other devices
|
||||
4. Try a different WiFi network if available
|
||||
4. Try a different Wi-Fi network if available
|
||||
|
||||
### Upload Fails
|
||||
|
||||
@@ -40,10 +42,11 @@ This document show most common issues and possible solutions while using the dev
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Ensure the file is a valid `.epub` file
|
||||
2. Check that the SD card has enough free space
|
||||
1. Check that the SD card has enough free space
|
||||
2. Check that the filename is valid for the SD card filesystem
|
||||
3. Try uploading a smaller file first to test
|
||||
4. Refresh the browser page and try again
|
||||
5. If WebSocket upload fails repeatedly, refresh the page and retry with the HTTP fallback path
|
||||
|
||||
### Saved Password Not Working
|
||||
|
||||
|
||||
+400
-231
@@ -1,72 +1,36 @@
|
||||
# Webserver Endpoints
|
||||
|
||||
This document describes all HTTP and WebSocket endpoints available on the CrossPoint Reader webserver.
|
||||
This document describes the HTTP, WebSocket, WebDAV, and discovery endpoints
|
||||
available while CrossPoint Reader is in File Transfer or Calibre Wireless mode.
|
||||
|
||||
- [Webserver Endpoints](#webserver-endpoints)
|
||||
- [Overview](#overview)
|
||||
- [HTTP Endpoints](#http-endpoints)
|
||||
- [GET `/` - Home Page](#get----home-page)
|
||||
- [GET `/files` - File Browser Page](#get-files---file-browser-page)
|
||||
- [GET `/api/status` - Device Status](#get-apistatus---device-status)
|
||||
- [GET `/api/files` - List Files](#get-apifiles---list-files)
|
||||
- [POST `/upload` - Upload File](#post-upload---upload-file)
|
||||
- [POST `/mkdir` - Create Folder](#post-mkdir---create-folder)
|
||||
- [POST `/delete` - Delete File or Folder](#post-delete---delete-file-or-folder)
|
||||
- [WebSocket Endpoint](#websocket-endpoint)
|
||||
- [Port 81 - Fast Binary Upload](#port-81---fast-binary-upload)
|
||||
- [Network Modes](#network-modes)
|
||||
- [Station Mode (STA)](#station-mode-sta)
|
||||
- [Access Point Mode (AP)](#access-point-mode-ap)
|
||||
- [Notes](#notes)
|
||||
- HTTP server: port 80
|
||||
- WebSocket upload server: port 81
|
||||
- UDP discovery listener: port 8134
|
||||
- WebDAV: port 80, handled by the same HTTP server
|
||||
|
||||
Examples use `crosspoint.local`. If mDNS does not resolve on your network, use
|
||||
the IP address shown on the device screen.
|
||||
|
||||
## Overview
|
||||
## HTTP Pages
|
||||
|
||||
The CrossPoint Reader exposes a webserver for file management and device monitoring:
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/` | Home/status page |
|
||||
| `GET` | `/files` | File manager page |
|
||||
| `GET` | `/settings` | Web settings page |
|
||||
| `GET` | `/fonts` | SD-card font manager page |
|
||||
| `GET` | `/js/jszip.min.js` | JavaScript asset used by the file manager |
|
||||
|
||||
- **HTTP Server**: Port 80
|
||||
- **WebSocket Server**: Port 81 (for fast binary uploads)
|
||||
## Device Status
|
||||
|
||||
---
|
||||
### `GET /api/status`
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
### GET `/` - Home Page
|
||||
|
||||
Serves the home page HTML interface.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl http://crosspoint.local/
|
||||
```
|
||||
|
||||
**Response:** HTML page (200 OK)
|
||||
|
||||
---
|
||||
|
||||
### GET `/files` - File Browser Page
|
||||
|
||||
Serves the file browser HTML interface.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl http://crosspoint.local/files
|
||||
```
|
||||
|
||||
**Response:** HTML page (200 OK)
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/status` - Device Status
|
||||
|
||||
Returns JSON with device status information.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl http://crosspoint.local/api/status
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
@@ -74,258 +38,463 @@ curl http://crosspoint.local/api/status
|
||||
"mode": "STA",
|
||||
"rssi": -45,
|
||||
"freeHeap": 123456,
|
||||
"uptime": 3600
|
||||
"uptime": 3600,
|
||||
"device": "X4"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------ | --------------------------------------------------------- |
|
||||
| `version` | string | CrossPoint firmware version |
|
||||
| `ip` | string | Device IP address |
|
||||
| `mode` | string | `"STA"` (connected to WiFi) or `"AP"` (access point mode) |
|
||||
| `rssi` | number | WiFi signal strength in dBm (0 in AP mode) |
|
||||
| `freeHeap` | number | Free heap memory in bytes |
|
||||
| `uptime` | number | Seconds since device boot |
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | string | Firmware version |
|
||||
| `ip` | string | Device IP address |
|
||||
| `mode` | string | `"STA"` for joined Wi-Fi or `"AP"` for hotspot mode |
|
||||
| `rssi` | number | Wi-Fi RSSI in dBm; `0` in AP mode |
|
||||
| `freeHeap` | number | Free heap in bytes |
|
||||
| `uptime` | number | Seconds since boot |
|
||||
| `device` | string | `"X3"` or `"X4"` hardware detection |
|
||||
|
||||
---
|
||||
## File Management
|
||||
|
||||
### GET `/api/files` - List Files
|
||||
### `GET /api/files`
|
||||
|
||||
Returns a JSON array of files and folders in the specified directory.
|
||||
Lists files and folders under a directory.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
# List root directory
|
||||
curl http://crosspoint.local/api/files
|
||||
|
||||
# List specific directory
|
||||
curl "http://crosspoint.local/api/files?path=/Books"
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
| --------- | -------- | ------- | ---------------------- |
|
||||
| `path` | No | `/` | Directory path to list |
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|-------------|
|
||||
| `path` | No | `/` | Directory to list |
|
||||
|
||||
Response:
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
[
|
||||
{"name": "MyBook.epub", "size": 1234567, "isDirectory": false, "isEpub": true},
|
||||
{"name": "Notes", "size": 0, "isDirectory": true, "isEpub": false},
|
||||
{"name": "document.pdf", "size": 54321, "isDirectory": false, "isEpub": false}
|
||||
{"name":"MyBook.epub","size":1234567,"isDirectory":false,"isEpub":true},
|
||||
{"name":"Notes","size":0,"isDirectory":true,"isEpub":false}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------- | ---------------------------------------- |
|
||||
| `name` | string | File or folder name |
|
||||
| `size` | number | Size in bytes (0 for directories) |
|
||||
| `isDirectory` | boolean | `true` if the item is a folder |
|
||||
| `isEpub` | boolean | `true` if the file has `.epub` extension |
|
||||
Hidden dotfiles are omitted unless the device setting `showHiddenFiles` is
|
||||
enabled. `System Volume Information` and `XTCache` are always hidden/protected.
|
||||
|
||||
**Notes:**
|
||||
- Hidden files (starting with `.`) are automatically filtered out
|
||||
- System folders (`System Volume Information`, `XTCache`) are hidden
|
||||
### `GET /download`
|
||||
|
||||
---
|
||||
Downloads a file from the SD card.
|
||||
|
||||
### POST `/upload` - Upload File
|
||||
|
||||
Uploads a file to the SD card via multipart form data.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
# Upload to root directory
|
||||
curl -X POST -F "file=@mybook.epub" http://crosspoint.local/upload
|
||||
curl -OJ "http://crosspoint.local/download?path=/Books/MyBook.epub"
|
||||
```
|
||||
|
||||
# Upload to specific directory
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `path` | Yes | File path to download |
|
||||
|
||||
Protected dotfiles, `System Volume Information`, and `XTCache` cannot be
|
||||
downloaded. EPUB files are served as `application/epub+zip`; other files use
|
||||
`application/octet-stream`.
|
||||
|
||||
### `POST /upload`
|
||||
|
||||
Uploads a file with HTTP multipart form data.
|
||||
|
||||
```bash
|
||||
curl -X POST -F "file=@mybook.epub" "http://crosspoint.local/upload?path=/Books"
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
| --------- | -------- | ------- | ------------------------------- |
|
||||
| `path` | No | `/` | Target directory for the upload |
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|-------------|
|
||||
| `path` | No | `/` | Destination directory |
|
||||
|
||||
**Response (200 OK):**
|
||||
```
|
||||
Successful response:
|
||||
|
||||
```text
|
||||
File uploaded successfully: mybook.epub
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
Notes:
|
||||
|
||||
| Status | Body | Cause |
|
||||
| ------ | ----------------------------------------------- | --------------------------- |
|
||||
| 400 | `Failed to create file on SD card` | Cannot create file |
|
||||
| 400 | `Failed to write to SD card - disk may be full` | Write error during upload |
|
||||
| 400 | `Failed to write final data to SD card` | Error flushing final buffer |
|
||||
| 400 | `Upload aborted` | Client aborted the upload |
|
||||
| 400 | `Unknown error during upload` | Unspecified error |
|
||||
- Existing files with the same name are overwritten.
|
||||
- EPUB cache data for the uploaded path is cleared after a successful upload.
|
||||
- HTTP upload uses a 4 KB write buffer before flushing to the SD card.
|
||||
|
||||
**Notes:**
|
||||
- Existing files with the same name will be overwritten
|
||||
- Uses a 4KB buffer for efficient SD card writes
|
||||
### `POST /mkdir`
|
||||
|
||||
---
|
||||
Creates a folder.
|
||||
|
||||
### POST `/mkdir` - Create Folder
|
||||
|
||||
Creates a new folder on the SD card.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST -d "name=NewFolder&path=/" http://crosspoint.local/mkdir
|
||||
```
|
||||
|
||||
**Form Parameters:**
|
||||
Form parameters:
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
| --------- | -------- | ------- | ---------------------------- |
|
||||
| `name` | Yes | - | Name of the folder to create |
|
||||
| `path` | No | `/` | Parent directory path |
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|-------------|
|
||||
| `name` | Yes | - | New folder name |
|
||||
| `path` | No | `/` | Parent folder |
|
||||
|
||||
**Response (200 OK):**
|
||||
```
|
||||
Folder created: NewFolder
|
||||
```
|
||||
### `POST /rename`
|
||||
|
||||
**Error Responses:**
|
||||
Renames a file.
|
||||
|
||||
| Status | Body | Cause |
|
||||
| ------ | ----------------------------- | ----------------------------- |
|
||||
| 400 | `Missing folder name` | `name` parameter not provided |
|
||||
| 400 | `Folder name cannot be empty` | Empty folder name |
|
||||
| 400 | `Folder already exists` | Folder with same name exists |
|
||||
| 500 | `Failed to create folder` | SD card error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/delete` - Delete File or Folder
|
||||
|
||||
Deletes a file or folder from the SD card.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
# Delete a file
|
||||
curl -X POST -d "path=/Books/mybook.epub&type=file" http://crosspoint.local/delete
|
||||
|
||||
# Delete an empty folder
|
||||
curl -X POST -d "path=/OldFolder&type=folder" http://crosspoint.local/delete
|
||||
curl -X POST -d "path=/Books/old.epub&name=new.epub" http://crosspoint.local/rename
|
||||
```
|
||||
|
||||
**Form Parameters:**
|
||||
Form parameters:
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
| --------- | -------- | ------- | -------------------------------- |
|
||||
| `path` | Yes | - | Path to the item to delete |
|
||||
| `type` | No | `file` | Type of item: `file` or `folder` |
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `path` | Yes | Existing file path |
|
||||
| `name` | Yes | New file name, not a path |
|
||||
|
||||
**Response (200 OK):**
|
||||
```
|
||||
Deleted successfully
|
||||
Only files can be renamed through this endpoint. The old EPUB cache path is
|
||||
cleared before the rename.
|
||||
|
||||
### `POST /move`
|
||||
|
||||
Moves a file into an existing folder.
|
||||
|
||||
```bash
|
||||
curl -X POST -d "path=/Books/mybook.epub&dest=/Read" http://crosspoint.local/move
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
Form parameters:
|
||||
|
||||
| Status | Body | Cause |
|
||||
| ------ | --------------------------------------------- | ----------------------------- |
|
||||
| 400 | `Missing path` | `path` parameter not provided |
|
||||
| 400 | `Cannot delete root directory` | Attempted to delete `/` |
|
||||
| 400 | `Folder is not empty. Delete contents first.` | Non-empty folder |
|
||||
| 403 | `Cannot delete system files` | Hidden file (starts with `.`) |
|
||||
| 403 | `Cannot delete protected items` | Protected system folder |
|
||||
| 404 | `Item not found` | Path does not exist |
|
||||
| 500 | `Failed to delete item` | SD card error |
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `path` | Yes | Existing file path |
|
||||
| `dest` | Yes | Existing destination folder |
|
||||
|
||||
**Protected Items:**
|
||||
- Files/folders starting with `.`
|
||||
- `System Volume Information`
|
||||
- `XTCache`
|
||||
Only files can be moved through this endpoint. The old EPUB cache path is
|
||||
cleared before the move.
|
||||
|
||||
---
|
||||
### `POST /delete`
|
||||
|
||||
## WebSocket Endpoint
|
||||
Deletes one or more files or empty folders.
|
||||
|
||||
### Port 81 - Fast Binary Upload
|
||||
|
||||
A WebSocket endpoint for high-speed binary file uploads. More efficient than HTTP multipart for large files.
|
||||
|
||||
**Connection:**
|
||||
```bash
|
||||
curl -X POST -d "path=/Books/mybook.epub" http://crosspoint.local/delete
|
||||
curl -X POST -d 'paths=["/Books/old.epub","/OldFolder"]' http://crosspoint.local/delete
|
||||
```
|
||||
|
||||
Form parameters:
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `path` | Yes, unless `paths` is provided | Single path to delete |
|
||||
| `paths` | Yes, unless `path` is provided | JSON array of paths to delete |
|
||||
|
||||
Protected items cannot be deleted. Non-empty folders are rejected. EPUB cache
|
||||
data for deleted files is cleared.
|
||||
|
||||
## Settings API
|
||||
|
||||
### `GET /api/settings`
|
||||
|
||||
Returns a streamed JSON array of editable settings. Each item contains common
|
||||
fields plus type-specific fields.
|
||||
|
||||
```bash
|
||||
curl http://crosspoint.local/api/settings
|
||||
```
|
||||
|
||||
Example item:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "fontSize",
|
||||
"name": "Font Size",
|
||||
"category": "Reader",
|
||||
"type": "enum",
|
||||
"value": 1,
|
||||
"options": ["Small", "Medium", "Large"]
|
||||
}
|
||||
```
|
||||
|
||||
Types:
|
||||
|
||||
| Type | Extra fields |
|
||||
|------|--------------|
|
||||
| `toggle` | `value` (`0` or `1`) |
|
||||
| `enum` | `value`, `options` |
|
||||
| `value` | `value`, `min`, `max`, `step` |
|
||||
| `string` | `value` |
|
||||
|
||||
The font-family setting includes SD-card font families when they are installed.
|
||||
|
||||
### `POST /api/settings`
|
||||
|
||||
Applies a partial settings update from a JSON object.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fontSize":2,"showHiddenFiles":1}' \
|
||||
http://crosspoint.local/api/settings
|
||||
```
|
||||
|
||||
Successful response:
|
||||
|
||||
```text
|
||||
Applied 2 setting(s)
|
||||
```
|
||||
|
||||
## Font Management API
|
||||
|
||||
### `GET /api/fonts`
|
||||
|
||||
Lists installed SD-card font families.
|
||||
|
||||
```bash
|
||||
curl http://crosspoint.local/api/fonts
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"maxFamilies": 128,
|
||||
"families": [
|
||||
{
|
||||
"name": "Literata",
|
||||
"sizes": [12, 14, 16, 18],
|
||||
"files": [
|
||||
{"name": "Literata_12.cpfont", "size": 123456}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/fonts/upload`
|
||||
|
||||
Uploads one `.cpfont` file into a family folder.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-F "family=Literata" \
|
||||
-F "file=@Literata_12.cpfont" \
|
||||
http://crosspoint.local/api/fonts/upload
|
||||
```
|
||||
|
||||
The handler validates the family name, `.cpfont` filename, and `CPFONT` magic
|
||||
bytes before accepting the file.
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{"ok":true}
|
||||
```
|
||||
|
||||
### `POST /api/fonts/delete`
|
||||
|
||||
Deletes an installed font family.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"family":"Literata"}' \
|
||||
http://crosspoint.local/api/fonts/delete
|
||||
```
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{"ok":true}
|
||||
```
|
||||
|
||||
## OPDS Server API
|
||||
|
||||
### `GET /api/opds`
|
||||
|
||||
Lists saved OPDS servers. Passwords are never returned.
|
||||
|
||||
```bash
|
||||
curl http://crosspoint.local/api/opds
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"name": "My Catalog",
|
||||
"url": "http://calibre.local:8080/opds",
|
||||
"username": "reader",
|
||||
"hasPassword": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /api/opds`
|
||||
|
||||
Adds or updates an OPDS server. Include `index` to update an existing entry.
|
||||
If `password` is omitted during an update, the existing password is preserved.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"My Catalog","url":"http://calibre.local:8080/opds","username":"reader","password":"secret"}' \
|
||||
http://crosspoint.local/api/opds
|
||||
```
|
||||
|
||||
### `POST /api/opds/delete`
|
||||
|
||||
Deletes an OPDS server by index.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"index":0}' \
|
||||
http://crosspoint.local/api/opds/delete
|
||||
```
|
||||
|
||||
## Wi-Fi Credential API
|
||||
|
||||
### `GET /api/wifi`
|
||||
|
||||
Lists saved Wi-Fi networks. Passwords are never returned.
|
||||
|
||||
```bash
|
||||
curl http://crosspoint.local/api/wifi
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"ssid": "HomeWiFi",
|
||||
"hasPassword": true,
|
||||
"isLastConnected": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /api/wifi`
|
||||
|
||||
Adds or updates a saved Wi-Fi network. Include `index` to update an existing
|
||||
entry. If `password` is omitted during an update, the existing password is
|
||||
preserved.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ssid":"HomeWiFi","password":"secret"}' \
|
||||
http://crosspoint.local/api/wifi
|
||||
```
|
||||
|
||||
### `POST /api/wifi/delete`
|
||||
|
||||
Deletes a saved Wi-Fi network by index.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"index":0}' \
|
||||
http://crosspoint.local/api/wifi/delete
|
||||
```
|
||||
|
||||
## WebSocket Upload
|
||||
|
||||
### Port 81
|
||||
|
||||
The WebSocket path is used for fast binary uploads from the file manager and
|
||||
Calibre plugin workflows.
|
||||
|
||||
Connection:
|
||||
|
||||
```text
|
||||
ws://crosspoint.local:81/
|
||||
```
|
||||
|
||||
**Protocol:**
|
||||
Protocol:
|
||||
|
||||
1. **Client** sends TEXT message: `START:<filename>:<size>:<path>`
|
||||
2. **Server** responds with TEXT: `READY`
|
||||
3. **Client** sends BINARY messages with file data chunks
|
||||
4. **Server** sends TEXT progress updates: `PROGRESS:<received>:<total>`
|
||||
5. **Server** sends TEXT when complete: `DONE` or `ERROR:<message>`
|
||||
1. Client sends text: `START:<filename>:<size>:<path>`
|
||||
2. Server replies `READY`
|
||||
3. Client sends binary chunks
|
||||
4. Server sends `PROGRESS:<received>:<total>` every 64 KB or at completion
|
||||
5. Server sends `DONE` when complete or `ERROR:<message>` on failure
|
||||
|
||||
**Example Session:**
|
||||
Example session:
|
||||
|
||||
```
|
||||
Client -> "START:mybook.epub:1234567:/Books"
|
||||
Server -> "READY"
|
||||
Client -> [binary chunk 1]
|
||||
Client -> [binary chunk 2]
|
||||
Server -> "PROGRESS:65536:1234567"
|
||||
Client -> [binary chunk 3]
|
||||
```text
|
||||
Client -> START:mybook.epub:1234567:/Books
|
||||
Server -> READY
|
||||
Client -> [binary chunk]
|
||||
Server -> PROGRESS:65536:1234567
|
||||
...
|
||||
Server -> "PROGRESS:1234567:1234567"
|
||||
Server -> "DONE"
|
||||
Server -> DONE
|
||||
```
|
||||
|
||||
**Error Messages:**
|
||||
Error messages include:
|
||||
|
||||
| Message | Cause |
|
||||
| --------------------------------- | ---------------------------------- |
|
||||
| `ERROR:Failed to create file` | Cannot create file on SD card |
|
||||
| `ERROR:Invalid START format` | Malformed START message |
|
||||
| `ERROR:No upload in progress` | Binary data received without START |
|
||||
| `ERROR:Write failed - disk full?` | SD card write error |
|
||||
| Message | Cause |
|
||||
|---------|-------|
|
||||
| `ERROR:Upload already in progress` | A second upload was started before the first completed |
|
||||
| `ERROR:Invalid START format` | Malformed START message or invalid size token |
|
||||
| `ERROR:Failed to create file` | Destination file could not be opened |
|
||||
| `ERROR:No upload in progress` | Binary data arrived without a matching START |
|
||||
| `ERROR:Upload overflow` | Client sent more bytes than declared |
|
||||
| `ERROR:Write failed - disk full?` | SD write failed |
|
||||
|
||||
**Example with `websocat`:**
|
||||
```bash
|
||||
# Interactive session
|
||||
websocat ws://crosspoint.local:81
|
||||
Incomplete WebSocket uploads are deleted on disconnect or error.
|
||||
|
||||
# Then type:
|
||||
START:mybook.epub:1234567:/Books
|
||||
# Wait for READY, then send binary data
|
||||
## WebDAV
|
||||
|
||||
The same HTTP server registers a WebDAV-compatible handler for file manager clients.
|
||||
|
||||
Supported methods:
|
||||
|
||||
```text
|
||||
OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Progress updates are sent every 64KB or at completion
|
||||
- Disconnection during upload will delete the incomplete file
|
||||
- Existing files with the same name will be overwritten
|
||||
Notes:
|
||||
|
||||
---
|
||||
- `PUT` writes to a temporary `.davtmp` file first, then renames it into place.
|
||||
- Protected paths are rejected.
|
||||
- `LOCK` and `UNLOCK` are accepted for client compatibility only. The server
|
||||
does not implement full WebDAV Class 2 locking semantics such as persistent
|
||||
locks or lock discovery.
|
||||
|
||||
## UDP Discovery
|
||||
|
||||
The server listens on UDP port `8134`. When it receives the text payload
|
||||
`hello`, it replies to the sender with:
|
||||
|
||||
```text
|
||||
crosspoint (on <hostname>);81
|
||||
```
|
||||
|
||||
The final field is the WebSocket upload port.
|
||||
|
||||
## Network Modes
|
||||
|
||||
The device can operate in two network modes:
|
||||
|
||||
### Station Mode (STA)
|
||||
- Device connects to an existing WiFi network
|
||||
- IP address assigned by router/DHCP
|
||||
- `mode` field in `/api/status` returns `"STA"`
|
||||
- `rssi` field shows signal strength
|
||||
|
||||
- Device joins an existing 2.4 GHz Wi-Fi network.
|
||||
- `crosspoint.local` is advertised with mDNS when available.
|
||||
- `/api/status` returns `"mode": "STA"` and RSSI in dBm.
|
||||
|
||||
### Access Point Mode (AP)
|
||||
- Device creates its own WiFi hotspot
|
||||
- Default IP is typically `192.168.4.1`
|
||||
- `mode` field in `/api/status` returns `"AP"`
|
||||
- `rssi` field returns `0`
|
||||
|
||||
---
|
||||
- Device creates an open hotspot named `CrossPoint-Reader`.
|
||||
- The device shows a Wi-Fi QR code and URL QR code.
|
||||
- The fallback IP is typically `192.168.4.1`.
|
||||
- `/api/status` returns `"mode": "AP"` and `"rssi": 0`.
|
||||
|
||||
## Notes
|
||||
### Calibre Wireless
|
||||
|
||||
- These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`).
|
||||
- All paths on the SD card start with `/`
|
||||
- Trailing slashes are automatically stripped (except for root `/`)
|
||||
- The webserver uses chunked transfer encoding for file listings
|
||||
Calibre Wireless starts the same web server in STA mode and displays setup
|
||||
instructions plus WebSocket upload progress on the device screen.
|
||||
|
||||
+98
-185
@@ -1,235 +1,148 @@
|
||||
# Web Server Guide
|
||||
|
||||
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload files from your computer or phone.
|
||||
This guide explains how to use CrossPoint Reader's built-in web server for file
|
||||
transfer, device settings, Wi-Fi/OPDS management, and SD-card font management.
|
||||
|
||||
## Overview
|
||||
|
||||
CrossPoint Reader includes a built-in web server that allows you to:
|
||||
The web server is available while the device is in **File Transfer** or
|
||||
**Calibre Wireless** mode. It can:
|
||||
|
||||
- Upload files wirelessly from any device on the same WiFi network
|
||||
- Browse and manage files on your device's SD card
|
||||
- Create folders to organize your library
|
||||
- Delete files and folders
|
||||
- Upload, download, rename, move, and delete files on the SD card
|
||||
- Create folders
|
||||
- Edit many device settings from a browser
|
||||
- Manage saved Wi-Fi networks and OPDS servers
|
||||
- Upload and delete `.cpfont` SD-card font families
|
||||
- Accept WebDAV clients and Calibre wireless uploads
|
||||
|
||||
## Prerequisites
|
||||
The server does not require authentication. Use it only on trusted private
|
||||
networks or in hotspot mode when you control who is connected.
|
||||
|
||||
- Your CrossPoint Reader device
|
||||
- A WiFi network
|
||||
- A computer, phone, or tablet connected to the **same WiFi network**
|
||||
## Starting File Transfer
|
||||
|
||||
---
|
||||
1. From the Home screen, select **File Transfer**.
|
||||
2. Choose one of the available modes:
|
||||
|
||||
## Step 1: Accessing the WiFi Screen
|
||||
| Mode | Use when |
|
||||
|------|----------|
|
||||
| **Join Network** | You want the reader to join an existing Wi-Fi network. |
|
||||
| **Calibre Wireless** | You want to receive books from the CrossPoint Calibre plugin workflow. |
|
||||
| **Create Hotspot** | You want the reader to create its own open Wi-Fi network. |
|
||||
|
||||
1. From the main menu or file browser, navigate to the **Settings** screen
|
||||
2. Select the **WiFi** option
|
||||
3. The device will automatically start scanning for available networks
|
||||
## Join Network Mode
|
||||
|
||||
---
|
||||
1. Select **Join Network**.
|
||||
2. Pick a 2.4 GHz Wi-Fi network from the scan results.
|
||||
3. Enter the password if prompted.
|
||||
4. Save credentials if you want the reader to reconnect automatically next time.
|
||||
|
||||
## Step 2: Connecting to WiFi
|
||||
After connection, the reader shows:
|
||||
|
||||
### Viewing Available Networks
|
||||
- The connected SSID
|
||||
- A QR code for the web URL
|
||||
- The direct IP URL, for example `http://192.168.1.102/`
|
||||
- The mDNS fallback URL, usually `http://crosspoint.local/`
|
||||
|
||||
Once the scan completes, you'll see a list of available WiFi networks with the following indicators:
|
||||
Use either URL from a phone, tablet, or computer on the same network.
|
||||
|
||||
- **Signal strength bars** (`||||`, `|||`, `||`, `|`) - Shows connection quality
|
||||
- **`*` symbol** - Indicates the network is password-protected (encrypted)
|
||||
- **`+` symbol** - Indicates you have previously saved credentials for this network
|
||||
## Create Hotspot Mode
|
||||
|
||||
<img src="./images/wifi/wifi_networks.jpeg" height="500">
|
||||
1. Select **Create Hotspot**.
|
||||
2. Connect your phone or computer to the open Wi-Fi network:
|
||||
|
||||
### Selecting a Network
|
||||
```text
|
||||
CrossPoint-Reader
|
||||
```
|
||||
|
||||
1. Use the **Left/Right** (or **Volume Up/Down**) buttons to navigate through the network list
|
||||
2. Press **Confirm** to select the highlighted network
|
||||
3. Open the URL shown on the reader. `http://crosspoint.local/` is preferred
|
||||
when supported; the fallback IP is typically `http://192.168.4.1/`.
|
||||
|
||||
### Entering Password (for encrypted networks)
|
||||
The reader displays one QR code for joining the hotspot and another QR code for
|
||||
opening the web interface.
|
||||
|
||||
If the network requires a password:
|
||||
## Calibre Wireless Mode
|
||||
|
||||
1. An on-screen keyboard will appear
|
||||
2. Use the navigation buttons to select characters
|
||||
3. Press **Confirm** to enter each character
|
||||
4. When complete, select the **Done** option on the keyboard
|
||||
Calibre Wireless starts the same web server in station mode, then displays setup
|
||||
instructions and upload progress on the reader. Use this mode with the
|
||||
CrossPoint Calibre plugin or other clients that speak the documented WebSocket
|
||||
upload protocol.
|
||||
|
||||
<img src="./images/wifi/wifi_password.jpeg" height="500">
|
||||
For Calibre OPDS browsing, add `/opds` to the catalog URL when configuring an
|
||||
OPDS server.
|
||||
|
||||
**Note:** If you've previously connected to this network, the saved password will be used automatically.
|
||||
## Web Interface
|
||||
|
||||
### Connection Process
|
||||
The browser UI has four primary pages.
|
||||
|
||||
The device will display "Connecting..." while establishing the connection. This typically takes 5-10 seconds.
|
||||
### Home
|
||||
|
||||
### Saving Credentials
|
||||
|
||||
If this is a new network, you'll be prompted to save the password:
|
||||
|
||||
- Select **Yes** to save credentials for automatic connection next time (NOTE: These are stored in plaintext on the device's SD card. Do not use this for sensitive networks.)
|
||||
- Select **No** to connect without saving
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Connection Success
|
||||
|
||||
Once connected, the screen will display:
|
||||
|
||||
- **Network name** (SSID)
|
||||
- **IP Address** (e.g., `192.168.1.102`)
|
||||
- **Web server URL** (e.g., `http://192.168.1.102/`)
|
||||
|
||||
<img src="./images/wifi/wifi_connected.jpeg" height="500">
|
||||
|
||||
**Important:** Make note of the IP address - you'll need this to access the web interface from your computer or phone.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Accessing the Web Interface
|
||||
|
||||
### From a Computer
|
||||
|
||||
1. Ensure your computer is connected to the **same WiFi network** as your CrossPoint Reader
|
||||
2. Open any web browser (Chrome is recommended)
|
||||
3. Type the IP address shown on your device into the browser's address bar
|
||||
- Example: `http://192.168.1.102/`
|
||||
4. Press Enter
|
||||
|
||||
### From a Phone or Tablet
|
||||
|
||||
1. Ensure your phone/tablet is connected to the **same WiFi network** as your CrossPoint Reader
|
||||
2. Open your mobile browser (Safari, Chrome, etc.)
|
||||
3. Type the IP address into the address bar
|
||||
- Example: `http://192.168.1.102/`
|
||||
4. Tap Go
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Using the Web Interface
|
||||
|
||||
### Home Page
|
||||
|
||||
The home page displays:
|
||||
|
||||
- Device status and version information
|
||||
- WiFi connection status
|
||||
- Current IP address
|
||||
- Available memory
|
||||
|
||||
Navigation links:
|
||||
|
||||
- **Home** - Returns to the status page
|
||||
- **File Manager** - Access file management features
|
||||
|
||||
<img src="./images/wifi/webserver_homepage.png" width="600">
|
||||
The Home page shows firmware status, network mode, IP address, device type,
|
||||
uptime, and free heap.
|
||||
|
||||
### File Manager
|
||||
|
||||
Click **File Manager** to access file management features.
|
||||
The File Manager page can:
|
||||
|
||||
#### Browsing Files
|
||||
- Browse SD-card folders
|
||||
- Upload files, using WebSocket upload when available and HTTP upload as a fallback
|
||||
- Create folders
|
||||
- Download files
|
||||
- Rename files
|
||||
- Move files into existing folders
|
||||
- Delete one or more selected files or empty folders
|
||||
|
||||
- The file manager displays all files and folders on your SD card
|
||||
- **Folders** are highlighted in yellow and indicated with a 📁 icon
|
||||
- **EPUB Files** are highlighted in green and indicated with a 📗 icon
|
||||
- **All Other Files** are not highlighted and indicated with a 📄 icon
|
||||
- Click on a folder name to navigate into it
|
||||
- Use the breadcrumb navigation at the top to go back to parent folders
|
||||
Existing files with the same name are overwritten by uploads. When EPUB files
|
||||
are overwritten, moved, renamed, or deleted through the web server, the matching
|
||||
book cache is cleared so stale metadata is not reused.
|
||||
|
||||
<img src="./images/wifi/webserver_files.png" width="600">
|
||||
### Settings
|
||||
|
||||
#### Uploading Files
|
||||
The Settings page exposes many firmware settings in the browser. It also has
|
||||
cards for:
|
||||
|
||||
1. Click the **📤 Upload** button in the top-right corner
|
||||
2. Click **Choose File** and select a file from your device
|
||||
3. Click **Upload**
|
||||
4. A progress bar will show the upload status
|
||||
5. The page will automatically refresh when the upload is complete
|
||||
- Saved Wi-Fi networks
|
||||
- OPDS servers
|
||||
|
||||
<img src="./images/wifi/webserver_upload.png" width="600">
|
||||
Passwords are accepted when adding or editing entries, but saved passwords are
|
||||
not returned by the API.
|
||||
|
||||
#### Creating Folders
|
||||
### Fonts
|
||||
|
||||
1. Click the **📁 New Folder** button in the top-right corner
|
||||
2. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||
3. Click **Create Folder**
|
||||
The Fonts page lists installed SD-card font families and lets you upload
|
||||
`.cpfont` files. Upload files from one font family at a time. The server validates
|
||||
the font family name, filename, and `.cpfont` magic bytes before accepting the
|
||||
upload.
|
||||
|
||||
This is useful for organizing your library by genre, author, series or file type.
|
||||
Installed fonts appear in **Settings > Reader > Font Family** after the font
|
||||
registry refreshes.
|
||||
|
||||
#### Deleting Files and Folders
|
||||
## Command Line Use
|
||||
|
||||
1. Click the **🗑️** (trash) icon next to any file or folder
|
||||
2. Confirm the deletion in the popup dialog
|
||||
3. Click **Delete** to permanently remove the item
|
||||
Power users can use `curl`, WebDAV clients, or WebSocket clients while the web
|
||||
server is running.
|
||||
|
||||
**Warning:** Deletion is permanent and cannot be undone!
|
||||
|
||||
**Note:** Folders must be empty before they can be deleted.
|
||||
|
||||
#### Moving Files
|
||||
|
||||
1. Click the **📂** (folder) icon next to any file
|
||||
2. Enter a folder name or select one from the dropdown
|
||||
3. Click **Move** to relocate the file
|
||||
|
||||
**Note:** Typing in a nonexistent folder name will result in the following error: "Failed to move: Destination not found"
|
||||
|
||||
#### Renaming Files
|
||||
|
||||
1. Click the **✏️** (pencil) icon next to any file
|
||||
2. Enter a file name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||
3. Click **Rename** to permanently rename the file
|
||||
|
||||
---
|
||||
|
||||
## Command Line File Management
|
||||
|
||||
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. Detailed documentation can be found [here](./webserver-endpoints.md).
|
||||
Endpoint details are documented in [webserver-endpoints.md](./webserver-endpoints.md).
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The web server runs on port 80 (standard HTTP)
|
||||
- **No authentication is required** - anyone on the same network can access the interface
|
||||
- The web server is only accessible while the WiFi screen shows "Connected"
|
||||
- The web server automatically stops when you exit the WiFi screen
|
||||
- For security, only use on trusted private networks
|
||||
- The HTTP server runs on port 80.
|
||||
- The WebSocket upload server runs on port 81.
|
||||
- There is no authentication.
|
||||
- Anyone on the same network can access the web interface while it is running.
|
||||
- The server stops when you exit File Transfer or Calibre Wireless mode.
|
||||
- Hotspot mode creates an open network for connectivity fallback; disconnect when done.
|
||||
|
||||
---
|
||||
## Tips
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
|
||||
- **Web Server Port:** 80 (HTTP)
|
||||
- **Maximum Upload Size:** Limited by available SD card space
|
||||
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
|
||||
---
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Organize with folders** - Create folders before uploading to keep your library organized
|
||||
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
|
||||
3. **Upload multiple files** - You can select and upload multiple files at once; the manager will queue them and refresh when the batch is finished
|
||||
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
|
||||
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
|
||||
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
|
||||
|
||||
---
|
||||
|
||||
## Exiting WiFi Mode
|
||||
|
||||
When you're finished uploading files:
|
||||
|
||||
1. Press the **Back** button on your CrossPoint Reader
|
||||
2. The web server will automatically stop
|
||||
3. WiFi will disconnect to conserve battery
|
||||
4. You'll return to the previous screen
|
||||
|
||||
Your uploaded files will be immediately available in the file browser!
|
||||
|
||||
---
|
||||
1. Use **Create Hotspot** when no trusted network is available.
|
||||
2. Prefer `crosspoint.local` when available, but keep the displayed IP address as a fallback.
|
||||
3. Move closer to the router if upload progress stalls in Join Network mode.
|
||||
4. Upload custom fonts through the Fonts page or copy them to `/.fonts/` or `/fonts/` on the SD card.
|
||||
5. Exit File Transfer mode when finished to conserve battery.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [User Guide](../USER_GUIDE.md) - General device operation
|
||||
- [Troubleshooting](./troubleshooting.md) - Troubleshooting
|
||||
- [README](../README.md) - Project overview and features
|
||||
- [User Guide](../USER_GUIDE.md)
|
||||
- [Webserver Endpoints](./webserver-endpoints.md)
|
||||
- [SD Card Fonts](./sd-card-fonts.md)
|
||||
- [Troubleshooting](./troubleshooting.md)
|
||||
|
||||
Submodule
+1
Submodule freeink-sdk added at fdd6105eb3
@@ -101,6 +101,9 @@ static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t
|
||||
}
|
||||
|
||||
int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const {
|
||||
if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) {
|
||||
return 0;
|
||||
}
|
||||
if (!data->kernMatrix) {
|
||||
return 0;
|
||||
}
|
||||
@@ -151,6 +154,21 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
|
||||
return cp;
|
||||
}
|
||||
|
||||
bool EpdFont::hasGlyph(const uint32_t cp) const {
|
||||
const int count = data->intervalCount;
|
||||
if (count > 0) {
|
||||
const EpdUnicodeInterval* intervals = data->intervals;
|
||||
const auto it =
|
||||
std::upper_bound(intervals, intervals + count, cp,
|
||||
[](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
|
||||
if (it != intervals && cp <= (it - 1)->last) return true;
|
||||
}
|
||||
if (data->glyphMissHandler) {
|
||||
return data->glyphMissHandler(data->glyphMissCtx, cp) != nullptr;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
||||
const int count = data->intervalCount;
|
||||
if (count == 0 && !data->glyphMissHandler) return nullptr;
|
||||
|
||||
@@ -12,6 +12,12 @@ class EpdFont {
|
||||
|
||||
const EpdGlyph* getGlyph(uint32_t cp) const;
|
||||
|
||||
/// True when the font actually provides `cp` (interval table or on-demand
|
||||
/// SD load) — unlike getGlyph(), never satisfied by the replacement glyph.
|
||||
/// Used by layout shaping to decide whether a substitution (ligature,
|
||||
/// Arabic presentation form) can really be drawn.
|
||||
bool hasGlyph(uint32_t cp) const;
|
||||
|
||||
/// Returns the kerning adjustment (4.4 fixed-point in pixels) between two codepoints.
|
||||
/// Returns 0 if no kerning data exists for the pair.
|
||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp) const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "EpdFontFamily.h"
|
||||
|
||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
||||
// Extract font style bits; render-time overlay bits do not affect font selection.
|
||||
const bool hasBold = (style & BOLD) != 0;
|
||||
const bool hasItalic = (style & ITALIC) != 0;
|
||||
|
||||
@@ -28,10 +28,16 @@ const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) co
|
||||
return getFont(style)->getGlyph(cp);
|
||||
}
|
||||
|
||||
bool EpdFontFamily::hasGlyph(const uint32_t cp, const Style style) const { return getFont(style)->hasGlyph(cp); }
|
||||
|
||||
int8_t EpdFontFamily::getKerning(const uint32_t leftCp, const uint32_t rightCp, const Style style) const {
|
||||
return getFont(style)->getKerning(leftCp, rightCp);
|
||||
}
|
||||
|
||||
uint32_t EpdFontFamily::getLigature(const uint32_t leftCp, const uint32_t rightCp, const Style style) const {
|
||||
return getFont(style)->getLigature(leftCp, rightCp);
|
||||
}
|
||||
|
||||
uint32_t EpdFontFamily::applyLigatures(const uint32_t cp, const char*& text, const Style style) const {
|
||||
return getFont(style)->applyLigatures(cp, text);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,21 @@
|
||||
|
||||
class EpdFontFamily {
|
||||
public:
|
||||
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 };
|
||||
// Bitmask of text style flags carried per-word through layout and serialized in page cache.
|
||||
// Bits 0-1 select the font variant (BOLD/ITALIC); bits 2-5 are decoration/positioning overlays
|
||||
// applied at render time without changing the underlying font. getFont() ignores all bits
|
||||
// above bit 1 so decorations compose freely with bold/italic (e.g. BOLD | UNDERLINE | SUP).
|
||||
enum Style : uint8_t {
|
||||
REGULAR = 0,
|
||||
BOLD = 1,
|
||||
ITALIC = 2,
|
||||
BOLD_ITALIC = 3,
|
||||
UNDERLINE = 4, // drawn as a line below baseline by TextBlock::render()
|
||||
STRIKETHROUGH = 8, // drawn as a line through midline by TextBlock::render()
|
||||
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
||||
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
|
||||
};
|
||||
static constexpr uint8_t TEXT_DECORATION_MASK = static_cast<uint8_t>(UNDERLINE | STRIKETHROUGH);
|
||||
|
||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||
const EpdFont* boldItalic = nullptr)
|
||||
@@ -12,8 +26,13 @@ class EpdFontFamily {
|
||||
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
|
||||
const EpdFontData* getData(Style style = REGULAR) const;
|
||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||
bool hasGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
||||
uint32_t getLigature(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
||||
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
|
||||
static constexpr bool hasTextDecoration(const Style style) {
|
||||
return (static_cast<uint8_t>(style) & TEXT_DECORATION_MASK) != 0;
|
||||
}
|
||||
|
||||
private:
|
||||
const EpdFont* regular;
|
||||
|
||||
@@ -33,12 +33,24 @@ void FontDecompressor::freePageBuffer() {
|
||||
}
|
||||
|
||||
void FontDecompressor::freeHotGroup() {
|
||||
hotGroup.clear();
|
||||
hotGroup.shrink_to_fit();
|
||||
free(hotGroup);
|
||||
hotGroup = nullptr;
|
||||
hotGroupCapacity = 0;
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
hotGlyphBuf.clear();
|
||||
hotGlyphBuf.shrink_to_fit();
|
||||
free(hotGlyphBuf);
|
||||
hotGlyphBuf = nullptr;
|
||||
hotGlyphBufCapacity = 0;
|
||||
}
|
||||
|
||||
bool FontDecompressor::ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed) {
|
||||
if (capacity >= needed) return true;
|
||||
// Grow-only, free-then-malloc: every caller fully rewrites the buffer after a grow, so the
|
||||
// old contents are dead -- freeing first gives the allocator its best shot on a tight heap.
|
||||
free(buf);
|
||||
buf = static_cast<uint8_t*>(malloc(needed)); // owned by FontDecompressor, freed in freeHotGroup()
|
||||
capacity = buf ? needed : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint32_t glyphIndex) {
|
||||
@@ -170,24 +182,20 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
||||
}
|
||||
|
||||
// Check if hot group already has this group decompressed — if not, decompress it
|
||||
if (!(!hotGroup.empty() && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
||||
if (!(hotGroup != nullptr && hotGroupFont == fontData && hotGroupIndex == groupIndex)) {
|
||||
stats.cacheMisses++;
|
||||
const EpdFontGroup& group = fontData->groups[groupIndex];
|
||||
|
||||
hotGroup.resize(group.uncompressedSize);
|
||||
if (hotGroup.empty()) {
|
||||
// ensureCapacity may free the buffer, so the cached-group identity dies with it either way.
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
if (!ensureCapacity(hotGroup, hotGroupCapacity, group.uncompressedSize)) {
|
||||
LOG_ERR("FDC", "Failed to allocate %u bytes for hot group %u", group.uncompressedSize, groupIndex);
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!decompressGroup(fontData, groupIndex, hotGroup.data(), group.uncompressedSize)) {
|
||||
hotGroup.clear();
|
||||
hotGroup.shrink_to_fit();
|
||||
hotGroupFont = nullptr;
|
||||
hotGroupIndex = UINT16_MAX;
|
||||
if (!decompressGroup(fontData, groupIndex, hotGroup, group.uncompressedSize)) {
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
@@ -200,18 +208,16 @@ const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const Ep
|
||||
}
|
||||
|
||||
// Compact just the requested glyph from byte-aligned data into scratch buffer
|
||||
if (glyph->dataLength > hotGlyphBuf.size()) {
|
||||
hotGlyphBuf.resize(glyph->dataLength);
|
||||
}
|
||||
if (hotGlyphBuf.empty()) {
|
||||
if (!ensureCapacity(hotGlyphBuf, hotGlyphBufCapacity, glyph->dataLength)) {
|
||||
LOG_ERR("FDC", "Failed to allocate %u bytes for glyph scratch", (unsigned)glyph->dataLength);
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint32_t alignedOff = getAlignedOffset(fontData, groupIndex, glyphIndex);
|
||||
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf.data(), glyph->width, glyph->height);
|
||||
compactSingleGlyph(&hotGroup[alignedOff], hotGlyphBuf, glyph->width, glyph->height);
|
||||
stats.getBitmapTimeUs += micros() - tStart;
|
||||
return hotGlyphBuf.data();
|
||||
return hotGlyphBuf;
|
||||
}
|
||||
|
||||
// --- Prewarm: pre-decompress glyph bitmaps for a page of text ---
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
#include <InflateReader.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "EpdFontData.h"
|
||||
|
||||
class FontDecompressor {
|
||||
@@ -67,13 +65,22 @@ class FontDecompressor {
|
||||
|
||||
// Hot group: last decompressed group (byte-aligned) for non-prewarmed fallback path.
|
||||
// Kept in byte-aligned format; individual glyphs are compacted on demand into hotGlyphBuf.
|
||||
// Nothrow high-water malloc buffers, NOT std::vector: getBitmap() runs on the render path,
|
||||
// and under -fno-exceptions a vector resize that hits OOM abort()s the firmware instead of
|
||||
// failing (field crash: hotGroup.resize() -> std::bad_alloc -> abort with ~11 KB free).
|
||||
// ensureCapacity() returns false on OOM so the caller can skip the glyph gracefully.
|
||||
const EpdFontData* hotGroupFont = nullptr;
|
||||
uint16_t hotGroupIndex = UINT16_MAX;
|
||||
std::vector<uint8_t> hotGroup;
|
||||
uint8_t* hotGroup = nullptr; // owned; freed in freeHotGroup()/dtor
|
||||
uint32_t hotGroupCapacity = 0;
|
||||
|
||||
// Scratch buffer for compacting a single glyph from the hot group.
|
||||
// Valid until the next getBitmap() call.
|
||||
std::vector<uint8_t> hotGlyphBuf;
|
||||
// Valid until the next getBitmap() call. Same ownership/OOM contract as hotGroup.
|
||||
uint8_t* hotGlyphBuf = nullptr;
|
||||
uint32_t hotGlyphBufCapacity = 0;
|
||||
|
||||
// Grow (never shrink) an owned buffer to at least `needed` bytes; false on OOM, buffer freed.
|
||||
static bool ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_t needed);
|
||||
|
||||
void freePageBuffer();
|
||||
void freeHotGroup();
|
||||
|
||||
+115
-49
@@ -115,6 +115,9 @@ void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
freeStyleMiniData(s);
|
||||
delete[] s.fullIntervals;
|
||||
s.fullIntervals = nullptr;
|
||||
delete[] s.bmpIntervals;
|
||||
s.bmpIntervals = nullptr;
|
||||
s.intervalsAreBmp16 = false;
|
||||
freeStyleKernLigatureData(s);
|
||||
s.present = false;
|
||||
}
|
||||
@@ -168,7 +171,7 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", filePath_, file)) {
|
||||
LOG_ERR("SDCF", "Failed to open .cpfont for kern/lig: %s", filePath_);
|
||||
return false;
|
||||
@@ -345,7 +348,7 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
||||
// Step 6: read the full matrix's rows for each used left class, keep only
|
||||
// columns for used right classes. One SD seek + one read per used left class;
|
||||
// a row is kernRightClassCount bytes (~200 for Literata).
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", filePath_, file)) {
|
||||
LOG_ERR("SDCF", "Failed to open .cpfont for mini kern: %s", filePath_);
|
||||
freeStyleMiniKern(s);
|
||||
@@ -425,7 +428,7 @@ bool SdCardFont::load(const char* path) {
|
||||
strncpy(filePath_, path, sizeof(filePath_) - 1);
|
||||
filePath_[sizeof(filePath_) - 1] = '\0';
|
||||
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", path, file)) {
|
||||
LOG_ERR("SDCF", "Failed to open .cpfont: %s", path);
|
||||
return false;
|
||||
@@ -516,59 +519,94 @@ bool SdCardFont::load(const char* path) {
|
||||
styleCount_ = styleCount;
|
||||
contentHash_ = hash;
|
||||
|
||||
// Load full intervals into RAM for each present style
|
||||
// Load full intervals into RAM for each present style. BMP-only fonts with
|
||||
// fewer than 65536 glyphs use a compact 6-byte interval table instead of the
|
||||
// on-disk 12-byte table; large sparse CJK subsets otherwise keep tens of KB
|
||||
// of always-resident heap just for lookup metadata.
|
||||
for (uint8_t i = 0; i < MAX_STYLES; i++) {
|
||||
auto& s = styles_[i];
|
||||
if (!s.present) continue;
|
||||
|
||||
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
|
||||
if (!s.fullIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.seekSet(s.intervalsFileOffset)) {
|
||||
LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
|
||||
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate interval contents before any later code (findGlobalGlyphIndex,
|
||||
// glyph reads) trusts them. A malformed file could otherwise drive
|
||||
// out-of-range glyph indices into bogus on-disk reads.
|
||||
{
|
||||
uint32_t expectedOffset = 0;
|
||||
uint32_t prevLast = 0;
|
||||
bool canUseBmp16 = s.header.glyphCount <= UINT16_MAX;
|
||||
uint32_t expectedOffset = 0;
|
||||
uint32_t prevLast = 0;
|
||||
EpdUnicodeInterval iv{};
|
||||
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
|
||||
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
|
||||
LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
if (iv.first > iv.last) {
|
||||
LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j,
|
||||
static_cast<unsigned long>(iv.first), static_cast<unsigned long>(iv.last));
|
||||
file.close();
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
const uint32_t span = iv.last - iv.first + 1;
|
||||
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
|
||||
const bool spanTooBig = (span > s.header.glyphCount);
|
||||
const bool offsetMismatch = (iv.offset != expectedOffset);
|
||||
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
|
||||
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
|
||||
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
|
||||
overlapsPrev, span, offsetMismatch, offsetOverruns);
|
||||
file.close();
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) {
|
||||
canUseBmp16 = false;
|
||||
}
|
||||
expectedOffset += span;
|
||||
prevLast = iv.last;
|
||||
}
|
||||
|
||||
if (!file.seekSet(s.intervalsFileOffset)) {
|
||||
LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (canUseBmp16) {
|
||||
s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount];
|
||||
if (!s.bmpIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
|
||||
const auto& iv = s.fullIntervals[j];
|
||||
if (iv.first > iv.last) {
|
||||
LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j,
|
||||
static_cast<unsigned long>(iv.first), static_cast<unsigned long>(iv.last));
|
||||
file.close();
|
||||
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
|
||||
LOG_ERR("SDCF", "Failed to read compact interval %u for style %u", j, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
const uint32_t span = iv.last - iv.first + 1;
|
||||
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
|
||||
const bool spanTooBig = (span > s.header.glyphCount);
|
||||
const bool offsetMismatch = (iv.offset != expectedOffset);
|
||||
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
|
||||
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
|
||||
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
|
||||
overlapsPrev, span, offsetMismatch, offsetOverruns);
|
||||
file.close();
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
expectedOffset += span;
|
||||
prevLast = iv.last;
|
||||
s.bmpIntervals[j] = {static_cast<uint16_t>(iv.first), static_cast<uint16_t>(iv.last),
|
||||
static_cast<uint16_t>(iv.offset)};
|
||||
}
|
||||
s.intervalsAreBmp16 = true;
|
||||
} else {
|
||||
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
|
||||
if (!s.fullIntervals) {
|
||||
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
|
||||
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
|
||||
freeAll();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,13 +641,15 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint)
|
||||
int right = static_cast<int>(s.header.intervalCount) - 1;
|
||||
while (left <= right) {
|
||||
int mid = left + (right - left) / 2;
|
||||
const auto& interval = s.fullIntervals[mid];
|
||||
if (codepoint < interval.first) {
|
||||
const uint32_t first = s.intervalsAreBmp16 ? s.bmpIntervals[mid].first : s.fullIntervals[mid].first;
|
||||
const uint32_t last = s.intervalsAreBmp16 ? s.bmpIntervals[mid].last : s.fullIntervals[mid].last;
|
||||
if (codepoint < first) {
|
||||
right = mid - 1;
|
||||
} else if (codepoint > interval.last) {
|
||||
} else if (codepoint > last) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
return static_cast<int32_t>(interval.offset + (codepoint - interval.first));
|
||||
const uint32_t offset = s.intervalsAreBmp16 ? s.bmpIntervals[mid].offset : s.fullIntervals[mid].offset;
|
||||
return static_cast<int32_t>(offset + (codepoint - first));
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
@@ -798,7 +838,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
std::sort(readOrder, readOrder + validCount,
|
||||
[&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; });
|
||||
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", filePath_, file)) {
|
||||
LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx);
|
||||
delete[] readOrder;
|
||||
@@ -1021,6 +1061,28 @@ void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sor
|
||||
advanceTableSize_[styleIdx] = k;
|
||||
}
|
||||
|
||||
bool SdCardFont::hasGlyphMeta(uint8_t styleIdx, uint32_t codepoint) const {
|
||||
styleIdx &= (MAX_STYLES - 1);
|
||||
const PerStyle& s = styles_[styleIdx];
|
||||
if (!loaded_ || !s.present) return false;
|
||||
return findGlobalGlyphIndex(s, codepoint) >= 0;
|
||||
}
|
||||
|
||||
uint16_t SdCardFont::ensureAdvance(uint8_t styleIdx, uint32_t codepoint) {
|
||||
styleIdx &= (MAX_STYLES - 1);
|
||||
if (!loaded_ || !styles_[styleIdx].present) return 0;
|
||||
const uint16_t cached = getAdvance(codepoint, styleIdx);
|
||||
if (cached != 0) return cached;
|
||||
// 0 is either "not in table" or a genuine zero-width glyph; only pay the
|
||||
// SD read when the resident interval table says the glyph exists.
|
||||
if (findGlobalGlyphIndex(styles_[styleIdx], codepoint) < 0) return 0;
|
||||
uint32_t cp = codepoint;
|
||||
fetchAdvancesForCodepoints(&cp, 1, static_cast<uint8_t>(1u << styleIdx));
|
||||
return getAdvance(codepoint, styleIdx);
|
||||
}
|
||||
|
||||
uint8_t SdCardFont::styleIdxFromMissCtx(void* ctx) { return static_cast<OverflowContext*>(ctx)->styleIdx; }
|
||||
|
||||
bool SdCardFont::hasAdvanceTable() const {
|
||||
for (uint8_t i = 0; i < MAX_STYLES; i++) {
|
||||
if (advanceTable_[i]) return true;
|
||||
@@ -1079,13 +1141,17 @@ int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCoun
|
||||
|
||||
uint32_t needCount = 0;
|
||||
uint32_t missedThisStyle = 0;
|
||||
const int32_t replacementIdx = findGlobalGlyphIndex(s, REPLACEMENT_GLYPH);
|
||||
for (uint32_t i = 0; i < cpCount; i++) {
|
||||
const uint32_t cp = codepoints[i];
|
||||
if (advanceTableLookup(si, cp, nullptr)) continue; // already cached
|
||||
int32_t idx = findGlobalGlyphIndex(s, cp);
|
||||
if (idx < 0) {
|
||||
missedThisStyle++;
|
||||
continue;
|
||||
if (replacementIdx < 0) {
|
||||
missedThisStyle++;
|
||||
continue;
|
||||
}
|
||||
idx = replacementIdx;
|
||||
}
|
||||
mappings[needCount].codepoint = cp;
|
||||
mappings[needCount].glyphIndex = idx;
|
||||
@@ -1100,7 +1166,7 @@ int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCoun
|
||||
[](const CpIdx& a, const CpIdx& b) { return a.glyphIndex < b.glyphIndex; });
|
||||
|
||||
// Open file once and read advanceX for each needed glyph.
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", filePath_, file)) {
|
||||
LOG_ERR("SDCF", "buildAdvanceTable: failed to open .cpfont for style %u", si);
|
||||
continue;
|
||||
@@ -1253,7 +1319,7 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) {
|
||||
|
||||
if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr;
|
||||
const auto& s = self->styles_[styleIdx];
|
||||
if (!s.fullIntervals) return nullptr;
|
||||
if (!s.fullIntervals && !s.bmpIntervals) return nullptr;
|
||||
|
||||
// Check overflow cache first (matching both codepoint and style)
|
||||
for (uint32_t i = 0; i < self->overflowCount_; i++) {
|
||||
@@ -1273,7 +1339,7 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) {
|
||||
bool wasAtCapacity = (self->overflowCount_ == OVERFLOW_CAPACITY);
|
||||
|
||||
// Read glyph metadata into temporary
|
||||
FsFile file;
|
||||
HalFile file;
|
||||
if (!Storage.openFileForRead("SDCF", self->filePath_, file)) {
|
||||
LOG_ERR("SDCF", "Overflow: failed to open .cpfont");
|
||||
return nullptr;
|
||||
|
||||
@@ -55,12 +55,29 @@ class SdCardFont {
|
||||
// Returns the 12.4 fixed-point advance, or 0 if not found.
|
||||
uint16_t getAdvance(uint32_t codepoint, uint8_t style) const;
|
||||
|
||||
// Layout-side metric access — the per-glyph path CpFontAdapter uses while
|
||||
// the FreeInkBook engine measures a chapter. Neither touches bitmap data:
|
||||
// going through getGlyph() would stream every occurrence's bitmap through
|
||||
// the 8-slot overflow ring (one SD read per character — a crawling build).
|
||||
//
|
||||
// hasGlyphMeta answers existence from the resident interval table (no I/O).
|
||||
bool hasGlyphMeta(uint8_t styleIdx, uint32_t codepoint) const;
|
||||
// ensureAdvance returns the 12.4 fixed-point advanceX, fetching the glyph
|
||||
// METADATA from SD once on a miss and merging it into the persistent
|
||||
// advance table (so each unique codepoint costs one small read per
|
||||
// session). Returns 0 when the style lacks the codepoint.
|
||||
uint16_t ensureAdvance(uint8_t styleIdx, uint32_t codepoint);
|
||||
|
||||
// Recover the style index from an EpdFontData::glyphMissCtx (see
|
||||
// fromMissCtx below for recovering the SdCardFont itself).
|
||||
static uint8_t styleIdxFromMissCtx(void* ctx);
|
||||
|
||||
// Returns true if advance table is populated for at least one style.
|
||||
bool hasAdvanceTable() const;
|
||||
|
||||
// Free mini data for all styles, restore stub EpdFontData.
|
||||
// Also clears the temporary advance table (built per layout pass) but
|
||||
// preserves the persistent advance cache (reused across passes).
|
||||
// Free mini data for all styles and restore stub EpdFontData.
|
||||
// Preserves the persistent advance cache so repeated layout passes can reuse
|
||||
// previously fetched metrics.
|
||||
void clearCache();
|
||||
|
||||
// Drop the persistent advance cache. Call when unloading the SD font or
|
||||
@@ -140,6 +157,14 @@ class SdCardFont {
|
||||
|
||||
// Full intervals loaded from file (kept in RAM for codepoint lookup)
|
||||
EpdUnicodeInterval* fullIntervals = nullptr;
|
||||
struct BmpInterval16 {
|
||||
uint16_t first;
|
||||
uint16_t last;
|
||||
uint16_t offset;
|
||||
} __attribute__((packed));
|
||||
static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact");
|
||||
BmpInterval16* bmpIntervals = nullptr;
|
||||
bool intervalsAreBmp16 = false;
|
||||
|
||||
// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
|
||||
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
|
||||
|
||||
@@ -34,19 +34,15 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender
|
||||
unloadAll(renderer);
|
||||
}
|
||||
|
||||
// Select by ordinal position: sort available sizes, then map the font size
|
||||
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the
|
||||
// family has fewer sizes than 4, clamp to the last available size.
|
||||
auto sizes = family.availableSizes();
|
||||
if (sizes.empty()) {
|
||||
// Select the physical point size closest to the built-in reader sizes. Some
|
||||
// CJK font packs only ship larger sizes, so ordinal selection can make
|
||||
// MEDIUM load 18pt+ and produce oversized pages on small devices.
|
||||
const SdCardFontFileInfo* selected = family.findClosestReaderSize(fontSizeEnum);
|
||||
if (!selected) {
|
||||
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t idx = fontSizeEnum;
|
||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
||||
const SdCardFontFileInfo* selected = family.findFile(sizes[idx]);
|
||||
|
||||
auto* font = new (std::nothrow) SdCardFont();
|
||||
if (!font) {
|
||||
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str());
|
||||
|
||||
@@ -15,10 +15,10 @@ class SdCardFontManager {
|
||||
SdCardFontManager(const SdCardFontManager&) = delete;
|
||||
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
|
||||
|
||||
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by
|
||||
// ordinal position in the family's sorted size list. Only one .cpfont file
|
||||
// is loaded; other sizes remain on disk. This keeps resident interval +
|
||||
// kern/ligature tables to one size's worth of memory.
|
||||
// Load the font file whose physical point size is closest to the reader
|
||||
// fontSizeEnum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18). Only one
|
||||
// .cpfont file is loaded; other sizes remain on disk. This keeps resident
|
||||
// interval + kern/ligature tables to one size's worth of memory.
|
||||
// Returns true on success.
|
||||
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
|
||||
|
||||
@@ -32,7 +32,7 @@ class SdCardFontManager {
|
||||
// Get name of currently loaded family (empty if none).
|
||||
const std::string& currentFamilyName() const { return loadedFamilyName_; };
|
||||
|
||||
// Point size that was actually loaded (closest match to targetPtSize).
|
||||
// Point size that was actually loaded.
|
||||
// 0 if nothing loaded.
|
||||
uint8_t currentPointSize() const { return loadedPointSize_; };
|
||||
|
||||
|
||||
@@ -15,6 +15,60 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t s
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const SdCardFontFileInfo* SdCardFontFamilyInfo::findClosestReaderSize(const uint8_t fontSizeEnum,
|
||||
const uint8_t style) const {
|
||||
if (files.empty()) return nullptr;
|
||||
|
||||
// Collect sizes matching the requested style, sorted ascending.
|
||||
std::vector<uint8_t> sizes;
|
||||
for (const auto& f : files) {
|
||||
if (f.style != style) continue;
|
||||
sizes.push_back(f.pointSize);
|
||||
}
|
||||
if (sizes.empty()) return nullptr;
|
||||
std::sort(sizes.begin(), sizes.end());
|
||||
|
||||
// When the family provides at least 4 sizes, use ordinal (index-based)
|
||||
// selection so custom-built font sets (e.g. 10/12/14/16) map SMALL to
|
||||
// the smallest file, not to a hardcoded 12pt target.
|
||||
if (sizes.size() >= 4) {
|
||||
uint8_t idx = fontSizeEnum;
|
||||
if (idx >= sizes.size()) idx = sizes.size() - 1;
|
||||
return findFile(sizes[idx], style);
|
||||
}
|
||||
|
||||
// Fewer sizes than enum slots (e.g. CJK packs with only 2-3 sizes):
|
||||
// fall back to closest-match against the built-in reader targets.
|
||||
uint8_t target = 14;
|
||||
switch (fontSizeEnum) {
|
||||
case 0:
|
||||
target = 12;
|
||||
break;
|
||||
case 2:
|
||||
target = 16;
|
||||
break;
|
||||
case 3:
|
||||
target = 18;
|
||||
break;
|
||||
case 1:
|
||||
default:
|
||||
target = 14;
|
||||
break;
|
||||
}
|
||||
|
||||
const SdCardFontFileInfo* best = nullptr;
|
||||
uint8_t bestDelta = 255;
|
||||
for (const auto& f : files) {
|
||||
if (f.style != style) continue;
|
||||
const uint8_t delta = f.pointSize > target ? f.pointSize - target : target - f.pointSize;
|
||||
if (!best || delta < bestDelta || (delta == bestDelta && f.pointSize < best->pointSize)) {
|
||||
best = &f;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
|
||||
for (const auto& f : files) {
|
||||
if (f.pointSize == size) return true;
|
||||
@@ -77,12 +131,12 @@ bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint
|
||||
}
|
||||
|
||||
void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) {
|
||||
FsFile dir = Storage.open(dirPath);
|
||||
HalFile dir = Storage.open(dirPath);
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
|
||||
char nameBuffer[128];
|
||||
while (true) {
|
||||
FsFile entry = dir.openNextFile();
|
||||
HalFile entry = dir.openNextFile();
|
||||
if (!entry) break;
|
||||
if (entry.isDirectory()) {
|
||||
entry.close();
|
||||
@@ -126,7 +180,7 @@ void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo
|
||||
// Skips families whose names already exist in `out` (de-duplicates between
|
||||
// the hidden and visible roots — first scan wins).
|
||||
void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector<SdCardFontFamilyInfo>& out) {
|
||||
FsFile root = Storage.open(rootPath);
|
||||
HalFile root = Storage.open(rootPath);
|
||||
if (!root) {
|
||||
LOG_DBG("SDREG", "Fonts directory not found: %s", rootPath);
|
||||
return;
|
||||
@@ -138,7 +192,7 @@ void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector<SdCardFontFa
|
||||
|
||||
char nameBuffer[128];
|
||||
while (true) {
|
||||
FsFile entry = root.openNextFile();
|
||||
HalFile entry = root.openNextFile();
|
||||
if (!entry) break;
|
||||
if (entry.isDirectory()) {
|
||||
entry.getName(nameBuffer, sizeof(nameBuffer));
|
||||
|
||||
@@ -18,6 +18,7 @@ struct SdCardFontFamilyInfo {
|
||||
std::vector<SdCardFontFileInfo> files;
|
||||
|
||||
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
|
||||
const SdCardFontFileInfo* findClosestReaderSize(uint8_t fontSizeEnum, uint8_t style = 0) const;
|
||||
bool hasSize(uint8_t size) const;
|
||||
std::vector<uint8_t> availableSizes() const;
|
||||
};
|
||||
|
||||
@@ -33,22 +33,6 @@
|
||||
#include <builtinFonts/notosans_18_bolditalic.h>
|
||||
#include <builtinFonts/notosans_18_italic.h>
|
||||
#include <builtinFonts/notosans_18_regular.h>
|
||||
#include <builtinFonts/opendyslexic_10_bold.h>
|
||||
#include <builtinFonts/opendyslexic_10_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_10_italic.h>
|
||||
#include <builtinFonts/opendyslexic_10_regular.h>
|
||||
#include <builtinFonts/opendyslexic_12_bold.h>
|
||||
#include <builtinFonts/opendyslexic_12_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_12_italic.h>
|
||||
#include <builtinFonts/opendyslexic_12_regular.h>
|
||||
#include <builtinFonts/opendyslexic_14_bold.h>
|
||||
#include <builtinFonts/opendyslexic_14_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_14_italic.h>
|
||||
#include <builtinFonts/opendyslexic_14_regular.h>
|
||||
#include <builtinFonts/opendyslexic_8_bold.h>
|
||||
#include <builtinFonts/opendyslexic_8_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_8_italic.h>
|
||||
#include <builtinFonts/opendyslexic_8_regular.h>
|
||||
#include <builtinFonts/ubuntu_10_bold.h>
|
||||
#include <builtinFonts/ubuntu_10_regular.h>
|
||||
#include <builtinFonts/ubuntu_12_bold.h>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
!NotoSerif/**
|
||||
!NotoSans/
|
||||
!NotoSans/**
|
||||
!NotoSansHebrew/
|
||||
!NotoSansHebrew/**
|
||||
!OpenDyslexic/
|
||||
!OpenDyslexic/**
|
||||
!Ubuntu/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/hebrew)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1558
-1039
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -80,42 +80,6 @@ ruby -rdigest -e 'puts [
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define OPENDYSLEXIC_8_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./opendyslexic_8_regular.h",
|
||||
"./opendyslexic_8_bold.h",
|
||||
"./opendyslexic_8_bolditalic.h",
|
||||
"./opendyslexic_8_italic.h",
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define OPENDYSLEXIC_10_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./opendyslexic_10_regular.h",
|
||||
"./opendyslexic_10_bold.h",
|
||||
"./opendyslexic_10_bolditalic.h",
|
||||
"./opendyslexic_10_italic.h",
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define OPENDYSLEXIC_12_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./opendyslexic_12_regular.h",
|
||||
"./opendyslexic_12_bold.h",
|
||||
"./opendyslexic_12_bolditalic.h",
|
||||
"./opendyslexic_12_italic.h",
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define OPENDYSLEXIC_14_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./opendyslexic_14_regular.h",
|
||||
"./opendyslexic_14_bold.h",
|
||||
"./opendyslexic_14_bolditalic.h",
|
||||
"./opendyslexic_14_italic.h",
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define UI_10_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./ubuntu_10_regular.h",
|
||||
|
||||
@@ -33,6 +33,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import socket
|
||||
import urllib.request
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -46,19 +47,47 @@ DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml"
|
||||
DEFAULT_OUTPUT = SCRIPT_DIR / "output"
|
||||
DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts"
|
||||
INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
|
||||
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path."""
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
||||
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
|
||||
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path.
|
||||
|
||||
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
|
||||
on a different mirror each request; a mirror may advertise an IPv6 address a
|
||||
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
|
||||
Retry on failure, forcing IPv4 resolution after the first attempt.
|
||||
"""
|
||||
if dest.exists():
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f" Downloading {dest.name}...")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except Exception as e:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
force_ipv4 = attempt > 1
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
break
|
||||
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
|
||||
last_err = e
|
||||
dest.unlink(missing_ok=True)
|
||||
if attempt < retries:
|
||||
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
|
||||
finally:
|
||||
if force_ipv4:
|
||||
socket.getaddrinfo = _orig_getaddrinfo
|
||||
else:
|
||||
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
|
||||
size_kb = dest.stat().st_size / 1024
|
||||
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
||||
return dest
|
||||
@@ -186,12 +215,14 @@ def build_family(
|
||||
# Multi-style mode
|
||||
for style_name, font_path in resolved_styles.items():
|
||||
cmd.extend([f"--{style_name}", str(font_path)])
|
||||
cmd.extend([f"--fallback-{style_name}", str(DEFAULT_FALLBACK_FONT)])
|
||||
else:
|
||||
# Single-style mode
|
||||
style_name = next(iter(resolved_styles))
|
||||
font_path = resolved_styles[style_name]
|
||||
cmd.append(str(font_path))
|
||||
cmd.extend(["--style", style_name])
|
||||
cmd.extend([f"--fallback-{style_name}", str(DEFAULT_FALLBACK_FONT)])
|
||||
|
||||
cmd.extend(["--intervals", intervals])
|
||||
cmd.extend(["--sizes", sizes])
|
||||
@@ -330,6 +361,15 @@ def main():
|
||||
print("ERROR: No families defined in config", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not DEFAULT_FALLBACK_FONT.exists() or not DEFAULT_FALLBACK_FONT.is_file():
|
||||
print(
|
||||
"ERROR: Missing default fallback font: "
|
||||
f"{DEFAULT_FALLBACK_FONT}\n"
|
||||
"This font is required for fallback glyphs in SD font builds.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Filter if --only specified
|
||||
if args.only:
|
||||
only_names = set(args.only.split(","))
|
||||
|
||||
@@ -7,7 +7,6 @@ cd "$(dirname "$0")"
|
||||
READER_FONT_STYLES=("Regular" "Italic" "Bold" "BoldItalic")
|
||||
NOTOSERIF_FONT_SIZES=(12 14 16 18)
|
||||
NOTOSANS_FONT_SIZES=(12 14 16 18)
|
||||
OPENDYSLEXIC_FONT_SIZES=(8 10 12 14)
|
||||
|
||||
for size in ${NOTOSERIF_FONT_SIZES[@]}; do
|
||||
for style in ${READER_FONT_STYLES[@]}; do
|
||||
@@ -29,16 +28,6 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
|
||||
done
|
||||
done
|
||||
|
||||
for size in ${OPENDYSLEXIC_FONT_SIZES[@]}; do
|
||||
for style in ${READER_FONT_STYLES[@]}; do
|
||||
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
|
||||
output_path="../builtinFonts/${font_name}.h"
|
||||
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
||||
echo "Generated $output_path"
|
||||
done
|
||||
done
|
||||
|
||||
UI_FONT_SIZES=(10 12)
|
||||
UI_FONT_STYLES=("Regular" "Bold")
|
||||
|
||||
@@ -46,13 +35,23 @@ for size in ${UI_FONT_SIZES[@]}; do
|
||||
for style in ${UI_FONT_STYLES[@]}; do
|
||||
font_name="ubuntu_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||
font_path="../builtinFonts/source/Ubuntu/Ubuntu-${style}.ttf"
|
||||
hebrew_path="../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-${style}.ttf"
|
||||
# Ubuntu lacks the Latin Extended Additional block (U+1EA0-U+1EF9) used for
|
||||
# Vietnamese tone marks. Append a Vietnamese-only Ubuntu cut so those glyphs
|
||||
# are filled from it while every glyph Ubuntu already has stays unchanged
|
||||
# (fontstack is ordered by descending priority).
|
||||
viet_path="../builtinFonts/source/Ubuntu/Ubuntu-Vietnamese-${style}.ttf"
|
||||
output_path="../builtinFonts/${font_name}.h"
|
||||
python fontconvert.py $font_name $size $font_path > $output_path
|
||||
python fontconvert.py $font_name $size $font_path $hebrew_path $viet_path \
|
||||
--additional-intervals 0x05D0,0x05EA > $output_path
|
||||
echo "Generated $output_path"
|
||||
done
|
||||
done
|
||||
|
||||
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
|
||||
python fontconvert.py notosans_8_regular 8 \
|
||||
../builtinFonts/source/NotoSans/NotoSans-Regular.ttf \
|
||||
../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-Regular.ttf \
|
||||
--additional-intervals 0x05D0,0x05EA > ../builtinFonts/notosans_8_regular.h
|
||||
|
||||
echo ""
|
||||
echo "Running compression verification..."
|
||||
|
||||
@@ -248,7 +248,7 @@ unmerged_intervals = sorted(intervals + add_ints)
|
||||
intervals = []
|
||||
unvalidated_intervals = []
|
||||
for i_start, i_end in unmerged_intervals:
|
||||
if len(unvalidated_intervals) > 0 and i_start + 1 <= unvalidated_intervals[-1][1]:
|
||||
if len(unvalidated_intervals) > 0 and i_start <= unvalidated_intervals[-1][1] + 1:
|
||||
unvalidated_intervals[-1] = (unvalidated_intervals[-1][0], max(unvalidated_intervals[-1][1], i_end))
|
||||
continue
|
||||
unvalidated_intervals.append((i_start, i_end))
|
||||
|
||||
@@ -40,9 +40,11 @@ INTERVAL_PRESETS = {
|
||||
"ascii": [(0x0020, 0x007E)],
|
||||
"latin1": [(0x0080, 0x00FF)],
|
||||
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
|
||||
(0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)],
|
||||
(0x02B0, 0x02FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||
(0xFB00, 0xFB06)],
|
||||
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
|
||||
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
|
||||
"hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)],
|
||||
"georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)],
|
||||
"armenian": [(0x0530, 0x058F)],
|
||||
"ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)],
|
||||
@@ -59,12 +61,14 @@ INTERVAL_PRESETS = {
|
||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF)],
|
||||
# Composite preset for English-language literary fiction including scifi/popsci.
|
||||
# Greek for physics terms, math operators, miscellaneous symbols (♪♫♬), dingbats.
|
||||
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
|
||||
# Greek for physics terms, math operators, geometric shapes, uncommon
|
||||
# dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats.
|
||||
"reading": [(0x0020, 0x024F), (0x02B0, 0x02FF), (0x0300, 0x036F), (0x0370, 0x03FF),
|
||||
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF),
|
||||
(0x2900, 0x29FF), (0x2E00, 0x2E7F), (0x3000, 0x303F),
|
||||
(0xFB00, 0xFB06)],
|
||||
# Matches the built-in font intervals from fontconvert.py exactly
|
||||
"builtin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F),
|
||||
@@ -515,7 +519,8 @@ def extract_ligatures_fonttools(font_path, codepoints):
|
||||
return pairs
|
||||
|
||||
|
||||
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False):
|
||||
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False,
|
||||
fallback_fontfile=None):
|
||||
"""Rasterize all glyphs for one font style. Returns StyleRasterData."""
|
||||
import freetype
|
||||
|
||||
@@ -528,6 +533,10 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
|
||||
# it before set_char_size() would waste work at the default size and risk
|
||||
# Invalid_Size_Handle on some fonts.
|
||||
face.set_char_size(size << 6, size << 6, 150, 150)
|
||||
fallback_face = None
|
||||
if fallback_fontfile:
|
||||
fallback_face = freetype.Face(fallback_fontfile)
|
||||
fallback_face.set_char_size(size << 6, size << 6, 150, 150)
|
||||
|
||||
load_flags = freetype.FT_LOAD_RENDER
|
||||
if force_autohint:
|
||||
@@ -538,6 +547,11 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
|
||||
if glyph_index > 0:
|
||||
face.load_glyph(glyph_index, load_flags)
|
||||
return face
|
||||
if fallback_face:
|
||||
fallback_glyph_index = fallback_face.get_char_index(code_point)
|
||||
if fallback_glyph_index > 0:
|
||||
fallback_face.load_glyph(fallback_glyph_index, load_flags)
|
||||
return fallback_face
|
||||
return None
|
||||
|
||||
# Validate intervals: remove codepoints not present in the font.
|
||||
@@ -549,7 +563,9 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
|
||||
for i_start, i_end in intervals:
|
||||
start = i_start
|
||||
for code_point in range(i_start, i_end + 1):
|
||||
if face.get_char_index(code_point) == 0:
|
||||
has_primary = face.get_char_index(code_point) != 0
|
||||
has_fallback = fallback_face and fallback_face.get_char_index(code_point) != 0
|
||||
if not has_primary and not has_fallback:
|
||||
if start < code_point:
|
||||
validated_intervals.append((start, code_point - 1))
|
||||
start = code_point + 1
|
||||
@@ -760,10 +776,11 @@ def style_sections_total_size(sections):
|
||||
# --- File writers ---
|
||||
|
||||
def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
|
||||
force_autohint=False):
|
||||
force_autohint=False, fallback_style_fonts=None):
|
||||
"""Generate a multi-style v4 .cpfont file.
|
||||
|
||||
style_fonts: dict of {style_id: fontfile_path} e.g. {0: "Regular.ttf", 2: "Italic.ttf"}
|
||||
fallback_style_fonts: optional dict of {style_id: fallback_fontfile_path}
|
||||
"""
|
||||
MAGIC = b"CPFONT\x00\x00"
|
||||
HEADER_SIZE = 32
|
||||
@@ -773,12 +790,15 @@ def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
|
||||
|
||||
# Rasterize each style
|
||||
raster_data = {} # style_id -> StyleRasterData
|
||||
fallback_style_fonts = fallback_style_fonts or {}
|
||||
for style_id in sorted(style_fonts.keys()):
|
||||
fontfile = style_fonts[style_id]
|
||||
fallback_fontfile = fallback_style_fonts.get(style_id)
|
||||
print(f" Rasterizing style {style_id}...", file=sys.stderr)
|
||||
raster_data[style_id] = rasterize_font_style(
|
||||
fontfile, size, intervals, style_id=style_id,
|
||||
force_autohint=force_autohint)
|
||||
force_autohint=force_autohint,
|
||||
fallback_fontfile=fallback_fontfile)
|
||||
|
||||
# Pack binary sections for each style
|
||||
packed_sections = {} # style_id -> tuple of section bytearrays
|
||||
@@ -889,6 +909,14 @@ def main():
|
||||
help="Font file for italic style.")
|
||||
parser.add_argument("--bolditalic", dest="font_bolditalic",
|
||||
help="Font file for bold-italic style.")
|
||||
parser.add_argument("--fallback-regular", dest="fallback_regular",
|
||||
help="Fallback font file for regular style.")
|
||||
parser.add_argument("--fallback-bold", dest="fallback_bold",
|
||||
help="Fallback font file for bold style.")
|
||||
parser.add_argument("--fallback-italic", dest="fallback_italic",
|
||||
help="Fallback font file for italic style.")
|
||||
parser.add_argument("--fallback-bolditalic", dest="fallback_bolditalic",
|
||||
help="Fallback font file for bold-italic style.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -910,6 +938,16 @@ def main():
|
||||
if args.font_bolditalic:
|
||||
style_fonts[3] = args.font_bolditalic
|
||||
|
||||
fallback_style_fonts = {}
|
||||
if args.fallback_regular:
|
||||
fallback_style_fonts[0] = args.fallback_regular
|
||||
if args.fallback_bold:
|
||||
fallback_style_fonts[1] = args.fallback_bold
|
||||
if args.fallback_italic:
|
||||
fallback_style_fonts[2] = args.fallback_italic
|
||||
if args.fallback_bolditalic:
|
||||
fallback_style_fonts[3] = args.fallback_bolditalic
|
||||
|
||||
is_multistyle = len(style_fonts) > 0
|
||||
fontfile = args.fontfile
|
||||
|
||||
@@ -977,7 +1015,8 @@ def main():
|
||||
print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr)
|
||||
total_size += generate_cpfont_multistyle(
|
||||
style_fonts, sz, intervals, output_path,
|
||||
force_autohint=args.force_autohint)
|
||||
force_autohint=args.force_autohint,
|
||||
fallback_style_fonts=fallback_style_fonts)
|
||||
print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr)
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,36 @@ families:
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 500}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
- name: Domitian
|
||||
description: "A humanist serif for literary reading (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Roman.otf"}
|
||||
bold: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Bold.otf"}
|
||||
italic: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Italic.otf"}
|
||||
bolditalic: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-BoldItalic.otf"}
|
||||
|
||||
- name: LibreBaskerville
|
||||
description: "A serif reimplementing the classic Baskerville (Latin)"
|
||||
intervals: latin-ext
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
|
||||
|
||||
- name: Vollkorn
|
||||
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
# ── Sans-serif ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: NotoSansExtended
|
||||
@@ -187,6 +217,18 @@ families:
|
||||
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"}
|
||||
|
||||
# ── Dyslexia ───────────────────────────────────────────────────────────────
|
||||
|
||||
- name: OpenDyslexic
|
||||
description: "Dyslexia-friendly font (Latin)"
|
||||
intervals: latin-ext
|
||||
sizes: [8, 10, 12, 14]
|
||||
styles:
|
||||
regular: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf"}
|
||||
bold: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf"}
|
||||
italic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf"}
|
||||
bolditalic: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf"}
|
||||
|
||||
# ── Accessibility ──────────────────────────────────────────────────────
|
||||
|
||||
- name: AtkinsonHyperlegibleNext
|
||||
|
||||
@@ -1,888 +0,0 @@
|
||||
#include "Epub.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
#include <PngToBmpConverter.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
#include "Epub/parsers/TocNcxParser.h"
|
||||
|
||||
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||
const auto containerPath = "META-INF/container.xml";
|
||||
size_t containerSize;
|
||||
|
||||
// Get file size without loading it all into heap
|
||||
if (!getItemSize(containerPath, &containerSize)) {
|
||||
LOG_ERR("EBP", "Could not find or size META-INF/container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
ContainerParser containerParser(containerSize);
|
||||
|
||||
if (!containerParser.setup()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stream read (reusing your existing stream logic)
|
||||
if (!readItemContentsToStream(containerPath, containerParser, 512)) {
|
||||
LOG_ERR("EBP", "Could not read META-INF/container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the result
|
||||
if (containerParser.fullPath.empty()) {
|
||||
LOG_ERR("EBP", "Could not find valid rootfile in container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
*contentOpfFile = std::move(containerParser.fullPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
||||
std::string contentOpfFilePath;
|
||||
if (!findContentOpfFile(&contentOpfFilePath)) {
|
||||
LOG_ERR("EBP", "Could not find content.opf in zip");
|
||||
return false;
|
||||
}
|
||||
|
||||
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
|
||||
|
||||
LOG_DBG("EBP", "Parsing content.opf: %s", contentOpfFilePath.c_str());
|
||||
|
||||
size_t contentOpfSize;
|
||||
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
|
||||
LOG_ERR("EBP", "Could not get size of content.opf");
|
||||
return false;
|
||||
}
|
||||
|
||||
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
||||
if (!opfParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup content.opf parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
|
||||
LOG_ERR("EBP", "Could not read content.opf");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Grab data from opfParser into epub
|
||||
bookMetadata.title = opfParser.title;
|
||||
bookMetadata.author = opfParser.author;
|
||||
bookMetadata.language = opfParser.language;
|
||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||
|
||||
// Guide-based cover fallback: if no cover found via metadata/properties,
|
||||
// try extracting the image reference from the guide's cover page XHTML
|
||||
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
|
||||
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
|
||||
size_t coverPageSize;
|
||||
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
|
||||
if (coverPageData) {
|
||||
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
|
||||
free(coverPageData);
|
||||
|
||||
// Determine base path of the cover page for resolving relative image references
|
||||
std::string coverPageBase;
|
||||
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
|
||||
}
|
||||
|
||||
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
|
||||
std::string imageRef;
|
||||
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
|
||||
auto pos = coverPageHtml.find(pattern);
|
||||
while (pos != std::string::npos) {
|
||||
pos += strlen(pattern);
|
||||
const auto endPos = coverPageHtml.find('"', pos);
|
||||
if (endPos != std::string::npos) {
|
||||
const auto ref = std::string_view{coverPageHtml}.substr(pos, endPos - pos);
|
||||
// Check if it's an image file
|
||||
if (FsHelpers::hasPngExtension(ref) || FsHelpers::hasJpgExtension(ref) || FsHelpers::hasGifExtension(ref)) {
|
||||
imageRef = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos = coverPageHtml.find(pattern, pos);
|
||||
}
|
||||
if (!imageRef.empty()) break;
|
||||
}
|
||||
|
||||
if (!imageRef.empty()) {
|
||||
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
||||
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
||||
|
||||
if (!opfParser.tocNcxPath.empty()) {
|
||||
tocNcxItem = opfParser.tocNcxPath;
|
||||
}
|
||||
|
||||
if (!opfParser.tocNavPath.empty()) {
|
||||
tocNavItem = opfParser.tocNavPath;
|
||||
}
|
||||
|
||||
if (!opfParser.cssFiles.empty()) {
|
||||
cssFiles = opfParser.cssFiles;
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Successfully parsed content.opf");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::parseTocNcxFile() const {
|
||||
// the ncx file should have been specified in the content.opf file
|
||||
if (tocNcxItem.empty()) {
|
||||
LOG_DBG("EBP", "No ncx file specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
|
||||
|
||||
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
|
||||
FsFile tempNcxFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
tempNcxFile.close();
|
||||
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
return false;
|
||||
}
|
||||
const auto ncxSize = tempNcxFile.size();
|
||||
|
||||
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
||||
|
||||
if (!ncxParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup toc ncx parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||
if (!ncxBuffer) {
|
||||
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
while (tempNcxFile.available()) {
|
||||
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
|
||||
if (readSize == 0) break;
|
||||
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
||||
|
||||
if (processedSize != readSize) {
|
||||
LOG_ERR("EBP", "Could not process all toc ncx data");
|
||||
free(ncxBuffer);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
free(ncxBuffer);
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tempNcxFile.close();
|
||||
Storage.remove(tmpNcxPath.c_str());
|
||||
|
||||
LOG_DBG("EBP", "Parsed TOC items");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::parseTocNavFile() const {
|
||||
// the nav file should have been specified in the content.opf file (EPUB 3)
|
||||
if (tocNavItem.empty()) {
|
||||
LOG_DBG("EBP", "No nav file specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
|
||||
|
||||
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
||||
FsFile tempNavFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
tempNavFile.close();
|
||||
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||
return false;
|
||||
}
|
||||
const auto navSize = tempNavFile.size();
|
||||
|
||||
// Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf
|
||||
// and the HTMLX nav file will have hrefs relative to itself
|
||||
const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1);
|
||||
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
||||
|
||||
if (!navParser.setup()) {
|
||||
LOG_ERR("EBP", "Could not setup toc nav parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||
if (!navBuffer) {
|
||||
LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
while (tempNavFile.available()) {
|
||||
const auto readSize = tempNavFile.read(navBuffer, 1024);
|
||||
const auto processedSize = navParser.write(navBuffer, readSize);
|
||||
|
||||
if (processedSize != readSize) {
|
||||
LOG_ERR("EBP", "Could not process all toc nav data");
|
||||
free(navBuffer);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
free(navBuffer);
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tempNavFile.close();
|
||||
Storage.remove(tmpNavPath.c_str());
|
||||
|
||||
LOG_DBG("EBP", "Parsed TOC nav items");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Epub::parseCssFiles() const {
|
||||
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
||||
// Larger files risk memory exhaustion on ESP32
|
||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
||||
// Minimum heap required before attempting CSS parsing
|
||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
||||
|
||||
if (cssFiles.empty()) {
|
||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
||||
|
||||
// See if we have a cached version of the CSS rules
|
||||
if (cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
||||
return;
|
||||
}
|
||||
|
||||
// No cache yet - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
||||
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
||||
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check CSS file size before decompressing - skip files that are too large
|
||||
size_t cssFileSize = 0;
|
||||
if (getItemSize(cssPath, &cssFileSize)) {
|
||||
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
||||
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
||||
cssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CSS file to temp location
|
||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||
FsFile tempCssFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not create temp CSS file");
|
||||
continue;
|
||||
}
|
||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
// Explicitly close() file before reopening for reading
|
||||
tempCssFile.close();
|
||||
|
||||
// Parse the CSS file
|
||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
cssParser->loadFromStream(tempCssFile);
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
}
|
||||
|
||||
// Save to cache for next time
|
||||
if (!cssParser->saveToCache()) {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
cssParser->clear();
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
}
|
||||
|
||||
// load in the meta data for the epub file
|
||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
||||
|
||||
// Initialize spine/TOC cache
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||
cssParser.reset(new CssParser(cachePath));
|
||||
|
||||
// Try to load existing cache first
|
||||
if (bookMetadataCache->load()) {
|
||||
if (!skipLoadingCss) {
|
||||
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
|
||||
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
||||
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
||||
cssParser->deleteCache();
|
||||
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||
}
|
||||
parseCssFiles();
|
||||
// Invalidate section caches so they are rebuilt with the new CSS
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
}
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we didn't load from cache above and we aren't allowed to build, fail now
|
||||
if (!buildIfMissing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache doesn't exist or is invalid, build it
|
||||
LOG_DBG("EBP", "Cache not found, building spine/TOC cache");
|
||||
setupCacheDir();
|
||||
|
||||
const uint32_t indexingStart = millis();
|
||||
|
||||
// Begin building cache - stream entries to disk immediately
|
||||
if (!bookMetadataCache->beginWrite()) {
|
||||
LOG_ERR("EBP", "Could not begin writing cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
// OPF Pass
|
||||
const uint32_t opfStart = millis();
|
||||
BookMetadataCache::BookMetadata bookMetadata;
|
||||
if (!bookMetadataCache->beginContentOpfPass()) {
|
||||
LOG_ERR("EBP", "Could not begin writing content.opf pass");
|
||||
return false;
|
||||
}
|
||||
if (!parseContentOpf(bookMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf");
|
||||
return false;
|
||||
}
|
||||
if (!bookMetadataCache->endContentOpfPass()) {
|
||||
LOG_ERR("EBP", "Could not end writing content.opf pass");
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
|
||||
|
||||
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
||||
const uint32_t tocStart = millis();
|
||||
if (!bookMetadataCache->beginTocPass()) {
|
||||
LOG_ERR("EBP", "Could not begin writing toc pass");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool tocParsed = false;
|
||||
|
||||
// Try EPUB 3 nav document first (preferred)
|
||||
if (!tocNavItem.empty()) {
|
||||
LOG_DBG("EBP", "Attempting to parse EPUB 3 nav document");
|
||||
tocParsed = parseTocNavFile();
|
||||
}
|
||||
|
||||
// Fall back to NCX if nav parsing failed or wasn't available
|
||||
if (!tocParsed && !tocNcxItem.empty()) {
|
||||
LOG_DBG("EBP", "Falling back to NCX TOC");
|
||||
tocParsed = parseTocNcxFile();
|
||||
}
|
||||
|
||||
if (!tocParsed) {
|
||||
LOG_ERR("EBP", "Warning: Could not parse any TOC format");
|
||||
// Continue anyway - book will work without TOC
|
||||
}
|
||||
|
||||
if (!bookMetadataCache->endTocPass()) {
|
||||
LOG_ERR("EBP", "Could not end writing toc pass");
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
|
||||
|
||||
// Close the cache files
|
||||
if (!bookMetadataCache->endWrite()) {
|
||||
LOG_ERR("EBP", "Could not end writing cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build final book.bin
|
||||
const uint32_t buildStart = millis();
|
||||
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
|
||||
LOG_ERR("EBP", "Could not update mappings and sizes");
|
||||
return false;
|
||||
}
|
||||
LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
|
||||
LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
|
||||
|
||||
if (!bookMetadataCache->cleanupTmpFiles()) {
|
||||
LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
|
||||
}
|
||||
|
||||
// Reload the cache from disk so it's in the correct state
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
if (!bookMetadataCache->load()) {
|
||||
LOG_ERR("EBP", "Failed to reload cache after writing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!skipLoadingCss) {
|
||||
// Parse CSS files after cache reload
|
||||
parseCssFiles();
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::clearCache() const {
|
||||
if (!Storage.exists(cachePath.c_str())) {
|
||||
LOG_DBG("EPB", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Storage.removeDir(cachePath.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to clear cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("EPB", "Cache cleared successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Epub::setupCacheDir() const {
|
||||
if (Storage.exists(cachePath.c_str())) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage.mkdir(cachePath.c_str());
|
||||
}
|
||||
|
||||
const std::string& Epub::getCachePath() const { return cachePath; }
|
||||
|
||||
const std::string& Epub::getPath() const { return filepath; }
|
||||
|
||||
const std::string& Epub::getTitle() const {
|
||||
static std::string blank;
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return blank;
|
||||
}
|
||||
|
||||
return bookMetadataCache->coreMetadata.title;
|
||||
}
|
||||
|
||||
const std::string& Epub::getAuthor() const {
|
||||
static std::string blank;
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return blank;
|
||||
}
|
||||
|
||||
return bookMetadataCache->coreMetadata.author;
|
||||
}
|
||||
|
||||
const std::string& Epub::getLanguage() const {
|
||||
static std::string blank;
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return blank;
|
||||
}
|
||||
|
||||
return bookMetadataCache->coreMetadata.language;
|
||||
}
|
||||
|
||||
std::string Epub::getCoverBmpPath(bool cropped) const {
|
||||
const auto coverFileName = std::string("cover") + (cropped ? "_crop" : "");
|
||||
return cachePath + "/" + coverFileName + ".bmp";
|
||||
}
|
||||
|
||||
bool Epub::generateCoverBmp(bool cropped) const {
|
||||
// Already generated, return true
|
||||
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_ERR("EBP", "Cannot generate cover BMP, cache not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
LOG_ERR("EBP", "No known cover image");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile 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;
|
||||
}
|
||||
|
||||
FsFile coverBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
return false;
|
||||
}
|
||||
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
|
||||
// Explicitly close() files before calling Storage.remove()
|
||||
coverJpg.close();
|
||||
coverBmp.close();
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
}
|
||||
|
||||
if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile 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;
|
||||
}
|
||||
|
||||
FsFile coverBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
return false;
|
||||
}
|
||||
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
|
||||
// Explicitly close() files before calling Storage.remove()
|
||||
coverPng.close();
|
||||
coverBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
}
|
||||
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Epub::generateThumbBmp(int height) const {
|
||||
// Already generated, return true
|
||||
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
LOG_DBG("EBP", "No known cover image for thumbnail");
|
||||
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile 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;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
return false;
|
||||
}
|
||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
||||
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
// Explicitly close() files before calling Storage.remove()
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile 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;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
return false;
|
||||
}
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success =
|
||||
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
||||
// Explicitly close() files before calling Storage.remove()
|
||||
coverPng.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
}
|
||||
|
||||
// Write an empty bmp file to avoid generation attempts in the future
|
||||
FsFile thumbBmp;
|
||||
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
|
||||
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
||||
if (!content) {
|
||||
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
||||
}
|
||||
|
||||
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
return ZipFile(filepath).getInflatedFileSize(path.c_str(), size);
|
||||
}
|
||||
|
||||
int Epub::getSpineItemsCount() const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return 0;
|
||||
}
|
||||
return bookMetadataCache->getSpineCount();
|
||||
}
|
||||
|
||||
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
|
||||
|
||||
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_ERR("EBP", "getSpineItem called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
|
||||
LOG_ERR("EBP", "getSpineItem index:%d is out of range", spineIndex);
|
||||
return bookMetadataCache->getSpineEntry(0);
|
||||
}
|
||||
|
||||
return bookMetadataCache->getSpineEntry(spineIndex);
|
||||
}
|
||||
|
||||
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_DBG("EBP", "getTocItem called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
||||
LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
|
||||
return {};
|
||||
}
|
||||
|
||||
return bookMetadataCache->getTocEntry(tocIndex);
|
||||
}
|
||||
|
||||
int Epub::getTocItemsCount() const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return bookMetadataCache->getTocCount();
|
||||
}
|
||||
|
||||
// work out the section index for a toc index
|
||||
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_ERR("EBP", "getSpineIndexForTocIndex called but cache not loaded");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
||||
LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
||||
if (spineIndex < 0) {
|
||||
LOG_DBG("EBP", "Section not found for TOC index %d", tocIndex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return spineIndex;
|
||||
}
|
||||
|
||||
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
|
||||
|
||||
size_t Epub::getBookSize() const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
|
||||
return 0;
|
||||
}
|
||||
return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
|
||||
}
|
||||
|
||||
int Epub::getSpineIndexForTextReference() const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
LOG_ERR("EBP", "getSpineIndexForTextReference called but cache not loaded");
|
||||
return 0;
|
||||
}
|
||||
LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
|
||||
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
||||
|
||||
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
||||
// there was no textReference in epub, so we return 0 (the first chapter)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// loop through spine items to get the correct index matching the text href
|
||||
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
||||
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
||||
LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
|
||||
i);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// This should not happen, as we checked for empty textReferenceHref earlier
|
||||
LOG_DBG("EBP", "Section not found for text reference");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate progress in book (returns 0.0-1.0)
|
||||
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
|
||||
const size_t bookSize = getBookSize();
|
||||
if (bookSize == 0) {
|
||||
return 0.0f;
|
||||
}
|
||||
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
||||
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
|
||||
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
|
||||
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
||||
return totalProgress / static_cast<float>(bookSize);
|
||||
}
|
||||
|
||||
int Epub::resolveHrefToSpineIndex(const std::string& href) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1;
|
||||
|
||||
// Extract filename (remove #anchor)
|
||||
std::string target = href;
|
||||
size_t hashPos = target.find('#');
|
||||
if (hashPos != std::string::npos) target = target.substr(0, hashPos);
|
||||
|
||||
// Same-file reference (anchor-only)
|
||||
if (target.empty()) return -1;
|
||||
|
||||
// Extract just the filename for comparison
|
||||
size_t targetSlash = target.find_last_of('/');
|
||||
std::string targetFilename = (targetSlash != std::string::npos) ? target.substr(targetSlash + 1) : target;
|
||||
|
||||
for (int i = 0; i < getSpineItemsCount(); i++) {
|
||||
const auto& spineHref = getSpineItem(i).href;
|
||||
// Try exact match first
|
||||
if (spineHref == target) return i;
|
||||
// Then filename-only match
|
||||
size_t spineSlash = spineHref.find_last_of('/');
|
||||
std::string spineFilename = (spineSlash != std::string::npos) ? spineHref.substr(spineSlash + 1) : spineHref;
|
||||
if (spineFilename == targetFilename) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Print.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub/BookMetadataCache.h"
|
||||
#include "Epub/css/CssParser.h"
|
||||
|
||||
class ZipFile;
|
||||
|
||||
class Epub {
|
||||
// the ncx file (EPUB 2)
|
||||
std::string tocNcxItem;
|
||||
// the nav file (EPUB 3)
|
||||
std::string tocNavItem;
|
||||
// where is the EPUBfile?
|
||||
std::string filepath;
|
||||
// the base path for items in the EPUB file
|
||||
std::string contentBasePath;
|
||||
// Uniq cache key based on filepath
|
||||
std::string cachePath;
|
||||
// Spine and TOC cache
|
||||
std::unique_ptr<BookMetadataCache> bookMetadataCache;
|
||||
// CSS parser for styling
|
||||
std::unique_ptr<CssParser> cssParser;
|
||||
// CSS files
|
||||
std::vector<std::string> cssFiles;
|
||||
|
||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||
bool parseTocNcxFile() const;
|
||||
bool parseTocNavFile() const;
|
||||
void parseCssFiles() const;
|
||||
|
||||
public:
|
||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||
// create a cache key based on the filepath
|
||||
cachePath = cacheDir + "/epub_" + std::to_string(std::hash<std::string>{}(this->filepath));
|
||||
}
|
||||
~Epub() = default;
|
||||
std::string& getBasePath() { return contentBasePath; }
|
||||
bool load(bool buildIfMissing = true, bool skipLoadingCss = false);
|
||||
bool clearCache() const;
|
||||
void setupCacheDir() const;
|
||||
const std::string& getCachePath() const;
|
||||
const std::string& getPath() const;
|
||||
const std::string& getTitle() const;
|
||||
const std::string& getAuthor() const;
|
||||
const std::string& getLanguage() const;
|
||||
std::string getCoverBmpPath(bool cropped = false) const;
|
||||
bool generateCoverBmp(bool cropped = false) const;
|
||||
std::string getThumbBmpPath() const;
|
||||
std::string getThumbBmpPath(int height) const;
|
||||
bool generateThumbBmp(int height) const;
|
||||
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||
bool trailingNullByte = false) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
bool getItemSize(const std::string& itemHref, size_t* size) const;
|
||||
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
|
||||
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
|
||||
int getSpineItemsCount() const;
|
||||
int getTocItemsCount() const;
|
||||
int getSpineIndexForTocIndex(int tocIndex) const;
|
||||
int getTocIndexForSpineIndex(int spineIndex) const;
|
||||
size_t getCumulativeSpineItemSize(int spineIndex) const;
|
||||
int getSpineIndexForTextReference() const;
|
||||
|
||||
size_t getBookSize() const;
|
||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||
CssParser* getCssParser() const { return cssParser.get(); }
|
||||
int resolveHrefToSpineIndex(const std::string& href) const;
|
||||
};
|
||||
@@ -1,457 +0,0 @@
|
||||
#include "BookMetadataCache.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <deque>
|
||||
|
||||
#include "FsHelpers.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t BOOK_CACHE_VERSION = 5;
|
||||
constexpr char bookBinFile[] = "/book.bin";
|
||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
||||
} // namespace
|
||||
|
||||
/* ============= WRITING / BUILDING FUNCTIONS ================ */
|
||||
|
||||
bool BookMetadataCache::beginWrite() {
|
||||
buildMode = true;
|
||||
spineCount = 0;
|
||||
tocCount = 0;
|
||||
LOG_DBG("BMC", "Entering write mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::beginContentOpfPass() {
|
||||
LOG_DBG("BMC", "Beginning content opf pass");
|
||||
|
||||
// Open spine file for writing
|
||||
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
|
||||
}
|
||||
|
||||
bool BookMetadataCache::endContentOpfPass() {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
spineFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::beginTocPass() {
|
||||
LOG_DBG("BMC", "Beginning toc pass");
|
||||
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
return false;
|
||||
}
|
||||
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
spineFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (spineCount >= LARGE_SPINE_THRESHOLD) {
|
||||
spineHrefIndex.clear();
|
||||
spineHrefIndex.resize(spineCount);
|
||||
spineFile.seek(0);
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
auto entry = readSpineEntry(spineFile);
|
||||
SpineHrefIndexEntry idx;
|
||||
idx.hrefHash = fnvHash64(entry.href);
|
||||
idx.hrefLen = static_cast<uint16_t>(entry.href.size());
|
||||
idx.spineIndex = static_cast<int16_t>(i);
|
||||
spineHrefIndex[i] = idx;
|
||||
}
|
||||
std::sort(spineHrefIndex.begin(), spineHrefIndex.end(),
|
||||
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
|
||||
return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen);
|
||||
});
|
||||
spineFile.seek(0);
|
||||
useSpineHrefIndex = true;
|
||||
LOG_DBG("BMC", "Using fast index for %d spine items", spineCount);
|
||||
} else {
|
||||
useSpineHrefIndex = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::endTocPass() {
|
||||
// Explicit close() required: member variables persist beyond function scope
|
||||
tocFile.close();
|
||||
spineFile.close();
|
||||
|
||||
spineHrefIndex.clear();
|
||||
spineHrefIndex.shrink_to_fit();
|
||||
useSpineHrefIndex = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::endWrite() {
|
||||
if (!buildMode) {
|
||||
LOG_DBG("BMC", "endWrite called but not in build mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
buildMode = false;
|
||||
LOG_DBG("BMC", "Wrote %d spine, %d TOC entries", spineCount, tocCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
|
||||
// Open all three files, writing to meta, reading from spine and toc
|
||||
if (!Storage.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
bookFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
// Explicit close() required: member variables persist beyond function scope
|
||||
bookFile.close();
|
||||
spineFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr uint32_t headerASize =
|
||||
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
|
||||
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() +
|
||||
metadata.coverItemHref.size() + metadata.textReferenceHref.size() +
|
||||
sizeof(uint32_t) * 5;
|
||||
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
|
||||
const uint32_t lutOffset = headerASize + metadataSize;
|
||||
|
||||
// Header A
|
||||
serialization::writePod(bookFile, BOOK_CACHE_VERSION);
|
||||
serialization::writePod(bookFile, lutOffset);
|
||||
serialization::writePod(bookFile, spineCount);
|
||||
serialization::writePod(bookFile, tocCount);
|
||||
// Metadata
|
||||
serialization::writeString(bookFile, metadata.title);
|
||||
serialization::writeString(bookFile, metadata.author);
|
||||
serialization::writeString(bookFile, metadata.language);
|
||||
serialization::writeString(bookFile, metadata.coverItemHref);
|
||||
serialization::writeString(bookFile, metadata.textReferenceHref);
|
||||
|
||||
// Loop through spine entries, writing LUT positions
|
||||
spineFile.seek(0);
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
uint32_t pos = spineFile.position();
|
||||
auto spineEntry = readSpineEntry(spineFile);
|
||||
serialization::writePod(bookFile, pos + lutOffset + lutSize);
|
||||
}
|
||||
|
||||
// Loop through toc entries, writing LUT positions
|
||||
tocFile.seek(0);
|
||||
for (int i = 0; i < tocCount; i++) {
|
||||
uint32_t pos = tocFile.position();
|
||||
auto tocEntry = readTocEntry(tocFile);
|
||||
serialization::writePod(bookFile, pos + lutOffset + lutSize + static_cast<uint32_t>(spineFile.position()));
|
||||
}
|
||||
|
||||
// LUTs complete
|
||||
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
|
||||
|
||||
// Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m))
|
||||
std::deque<int16_t> spineToTocIndex(spineCount, -1);
|
||||
tocFile.seek(0);
|
||||
for (int j = 0; j < tocCount; j++) {
|
||||
auto tocEntry = readTocEntry(tocFile);
|
||||
if (tocEntry.spineIndex >= 0 && tocEntry.spineIndex < spineCount) {
|
||||
if (spineToTocIndex[tocEntry.spineIndex] == -1) {
|
||||
spineToTocIndex[tocEntry.spineIndex] = static_cast<int16_t>(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ZipFile zip(epubPath);
|
||||
// Pre-open zip file to speed up size calculations
|
||||
if (!zip.open()) {
|
||||
LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
|
||||
// Explicit close() required: member variables persist beyond function scope
|
||||
bookFile.close();
|
||||
spineFile.close();
|
||||
tocFile.close();
|
||||
return false;
|
||||
}
|
||||
// NOTE: We intentionally skip calling loadAllFileStatSlims() here.
|
||||
// For large EPUBs (2000+ chapters), pre-loading all ZIP central directory entries
|
||||
// into memory causes OOM crashes on ESP32-C3's limited ~380KB RAM.
|
||||
// Instead, for large books we use a one-pass batch lookup that scans the ZIP
|
||||
// central directory once and matches against spine targets using hash comparison.
|
||||
// This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion.
|
||||
// See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134
|
||||
|
||||
std::deque<uint32_t> spineSizes;
|
||||
bool useBatchSizes = false;
|
||||
|
||||
if (spineCount >= LARGE_SPINE_THRESHOLD) {
|
||||
LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
|
||||
|
||||
std::deque<ZipFile::SizeTarget> targets;
|
||||
targets.resize(spineCount);
|
||||
|
||||
spineFile.seek(0);
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
auto entry = readSpineEntry(spineFile);
|
||||
std::string path = FsHelpers::normalisePath(entry.href);
|
||||
|
||||
ZipFile::SizeTarget t;
|
||||
t.hash = ZipFile::fnvHash64(path.c_str(), path.size());
|
||||
t.len = static_cast<uint16_t>(path.size());
|
||||
t.index = static_cast<uint16_t>(i);
|
||||
targets[i] = t;
|
||||
}
|
||||
|
||||
std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) {
|
||||
return a.hash < b.hash || (a.hash == b.hash && a.len < b.len);
|
||||
});
|
||||
|
||||
spineSizes.resize(spineCount, 0);
|
||||
int matched = zip.fillUncompressedSizes(targets, spineSizes);
|
||||
LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount);
|
||||
|
||||
targets.clear();
|
||||
targets.shrink_to_fit();
|
||||
|
||||
useBatchSizes = true;
|
||||
}
|
||||
|
||||
uint32_t cumSize = 0;
|
||||
spineFile.seek(0);
|
||||
int lastSpineTocIndex = -1;
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
auto spineEntry = readSpineEntry(spineFile);
|
||||
|
||||
spineEntry.tocIndex = spineToTocIndex[i];
|
||||
|
||||
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
|
||||
// Logging here is for debugging
|
||||
if (spineEntry.tocIndex == -1) {
|
||||
LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i,
|
||||
spineEntry.href.c_str());
|
||||
spineEntry.tocIndex = lastSpineTocIndex;
|
||||
}
|
||||
lastSpineTocIndex = spineEntry.tocIndex;
|
||||
|
||||
size_t itemSize = 0;
|
||||
if (useBatchSizes) {
|
||||
itemSize = spineSizes[i];
|
||||
if (itemSize == 0) {
|
||||
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
cumSize += itemSize;
|
||||
spineEntry.cumulativeSize = cumSize;
|
||||
|
||||
// Write out spine data to book.bin
|
||||
writeSpineEntry(bookFile, spineEntry);
|
||||
}
|
||||
// Close opened zip file
|
||||
zip.close();
|
||||
|
||||
// Loop through toc entries from toc file writing to book.bin
|
||||
tocFile.seek(0);
|
||||
for (int i = 0; i < tocCount; i++) {
|
||||
auto tocEntry = readTocEntry(tocFile);
|
||||
writeTocEntry(bookFile, tocEntry);
|
||||
}
|
||||
|
||||
// Explicit close() required: member variables persist beyond function scope
|
||||
bookFile.close();
|
||||
spineFile.close();
|
||||
tocFile.close();
|
||||
|
||||
LOG_DBG("BMC", "Successfully built book.bin");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::cleanupTmpFiles() const {
|
||||
const auto spineBinFile = cachePath + tmpSpineBinFile;
|
||||
if (Storage.exists(spineBinFile.c_str())) {
|
||||
Storage.remove(spineBinFile.c_str());
|
||||
}
|
||||
const auto tocBinFile = cachePath + tmpTocBinFile;
|
||||
if (Storage.exists(tocBinFile.c_str())) {
|
||||
Storage.remove(tocBinFile.c_str());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t BookMetadataCache::writeSpineEntry(FsFile& file, const SpineEntry& entry) const {
|
||||
const uint32_t pos = file.position();
|
||||
serialization::writeString(file, entry.href);
|
||||
serialization::writePod(file, entry.cumulativeSize);
|
||||
serialization::writePod(file, entry.tocIndex);
|
||||
return pos;
|
||||
}
|
||||
|
||||
uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) const {
|
||||
const uint32_t pos = file.position();
|
||||
serialization::writeString(file, entry.title);
|
||||
serialization::writeString(file, entry.href);
|
||||
serialization::writeString(file, entry.anchor);
|
||||
serialization::writePod(file, entry.level);
|
||||
serialization::writePod(file, entry.spineIndex);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called
|
||||
// this is because in this function we're marking positions of the items
|
||||
void BookMetadataCache::createSpineEntry(const std::string& href) {
|
||||
if (!buildMode || !spineFile) {
|
||||
LOG_DBG("BMC", "createSpineEntry called but not in build mode");
|
||||
return;
|
||||
}
|
||||
|
||||
const SpineEntry entry(href, 0, -1);
|
||||
writeSpineEntry(spineFile, entry);
|
||||
spineCount++;
|
||||
}
|
||||
|
||||
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
|
||||
const uint8_t level) {
|
||||
if (!buildMode || !tocFile || !spineFile) {
|
||||
LOG_DBG("BMC", "createTocEntry called but not in build mode");
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t spineIndex = -1;
|
||||
|
||||
if (useSpineHrefIndex) {
|
||||
uint64_t targetHash = fnvHash64(href);
|
||||
uint16_t targetLen = static_cast<uint16_t>(href.size());
|
||||
|
||||
auto it =
|
||||
std::lower_bound(spineHrefIndex.begin(), spineHrefIndex.end(), SpineHrefIndexEntry{targetHash, targetLen, 0},
|
||||
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
|
||||
return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen);
|
||||
});
|
||||
|
||||
while (it != spineHrefIndex.end() && it->hrefHash == targetHash && it->hrefLen == targetLen) {
|
||||
spineIndex = it->spineIndex;
|
||||
break;
|
||||
}
|
||||
|
||||
if (spineIndex == -1) {
|
||||
LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
|
||||
}
|
||||
} else {
|
||||
spineFile.seek(0);
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
auto spineEntry = readSpineEntry(spineFile);
|
||||
if (spineEntry.href == href) {
|
||||
spineIndex = static_cast<int16_t>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (spineIndex == -1) {
|
||||
LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
const TocEntry entry(title, href, anchor, level, spineIndex);
|
||||
writeTocEntry(tocFile, entry);
|
||||
tocCount++;
|
||||
}
|
||||
|
||||
/* ============= READING / LOADING FUNCTIONS ================ */
|
||||
|
||||
bool BookMetadataCache::load() {
|
||||
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(bookFile, version);
|
||||
if (version != BOOK_CACHE_VERSION) {
|
||||
LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
bookFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
serialization::readPod(bookFile, lutOffset);
|
||||
serialization::readPod(bookFile, spineCount);
|
||||
serialization::readPod(bookFile, tocCount);
|
||||
|
||||
serialization::readString(bookFile, coreMetadata.title);
|
||||
serialization::readString(bookFile, coreMetadata.author);
|
||||
serialization::readString(bookFile, coreMetadata.language);
|
||||
serialization::readString(bookFile, coreMetadata.coverItemHref);
|
||||
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
||||
|
||||
loaded = true;
|
||||
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
|
||||
if (!loaded) {
|
||||
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (index < 0 || index >= static_cast<int>(spineCount)) {
|
||||
LOG_ERR("BMC", "getSpineEntry index %d out of range", index);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Seek to spine LUT item, read from LUT and get out data
|
||||
bookFile.seek(lutOffset + sizeof(uint32_t) * index);
|
||||
uint32_t spineEntryPos;
|
||||
serialization::readPod(bookFile, spineEntryPos);
|
||||
bookFile.seek(spineEntryPos);
|
||||
return readSpineEntry(bookFile);
|
||||
}
|
||||
|
||||
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
||||
if (!loaded) {
|
||||
LOG_ERR("BMC", "getTocEntry called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (index < 0 || index >= static_cast<int>(tocCount)) {
|
||||
LOG_ERR("BMC", "getTocEntry index %d out of range", index);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Seek to TOC LUT item, read from LUT and get out data
|
||||
bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index);
|
||||
uint32_t tocEntryPos;
|
||||
serialization::readPod(bookFile, tocEntryPos);
|
||||
bookFile.seek(tocEntryPos);
|
||||
return readTocEntry(bookFile);
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(FsFile& file) const {
|
||||
SpineEntry entry;
|
||||
serialization::readString(file, entry.href);
|
||||
serialization::readPod(file, entry.cumulativeSize);
|
||||
serialization::readPod(file, entry.tocIndex);
|
||||
return entry;
|
||||
}
|
||||
|
||||
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(FsFile& file) const {
|
||||
TocEntry entry;
|
||||
serialization::readString(file, entry.title);
|
||||
serialization::readString(file, entry.href);
|
||||
serialization::readString(file, entry.anchor);
|
||||
serialization::readPod(file, entry.level);
|
||||
serialization::readPod(file, entry.spineIndex);
|
||||
return entry;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
|
||||
class BookMetadataCache {
|
||||
public:
|
||||
struct BookMetadata {
|
||||
std::string title;
|
||||
std::string author;
|
||||
std::string language;
|
||||
std::string coverItemHref;
|
||||
std::string textReferenceHref;
|
||||
};
|
||||
|
||||
struct SpineEntry {
|
||||
std::string href;
|
||||
uint32_t cumulativeSize;
|
||||
int16_t tocIndex;
|
||||
|
||||
SpineEntry() : cumulativeSize(0), tocIndex(-1) {}
|
||||
SpineEntry(std::string href, const uint32_t cumulativeSize, const int16_t tocIndex)
|
||||
: href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {}
|
||||
};
|
||||
|
||||
struct TocEntry {
|
||||
std::string title;
|
||||
std::string href;
|
||||
std::string anchor;
|
||||
uint8_t level;
|
||||
int16_t spineIndex;
|
||||
|
||||
TocEntry() : level(0), spineIndex(-1) {}
|
||||
TocEntry(std::string title, std::string href, std::string anchor, const uint8_t level, const int16_t spineIndex)
|
||||
: title(std::move(title)),
|
||||
href(std::move(href)),
|
||||
anchor(std::move(anchor)),
|
||||
level(level),
|
||||
spineIndex(spineIndex) {}
|
||||
};
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
uint32_t lutOffset;
|
||||
uint16_t spineCount;
|
||||
uint16_t tocCount;
|
||||
bool loaded;
|
||||
bool buildMode;
|
||||
|
||||
FsFile bookFile;
|
||||
// Temp file handles during build
|
||||
FsFile spineFile;
|
||||
FsFile tocFile;
|
||||
|
||||
// Index for fast href→spineIndex lookup (used only for large EPUBs)
|
||||
struct SpineHrefIndexEntry {
|
||||
uint64_t hrefHash; // FNV-1a 64-bit hash
|
||||
uint16_t hrefLen; // length for collision reduction
|
||||
int16_t spineIndex;
|
||||
};
|
||||
std::deque<SpineHrefIndexEntry> spineHrefIndex;
|
||||
bool useSpineHrefIndex = false;
|
||||
|
||||
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;
|
||||
|
||||
// FNV-1a 64-bit hash function
|
||||
static uint64_t fnvHash64(const std::string& s) {
|
||||
uint64_t hash = 14695981039346656037ull;
|
||||
for (char c : s) {
|
||||
hash ^= static_cast<uint8_t>(c);
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
uint32_t writeSpineEntry(FsFile& file, const SpineEntry& entry) const;
|
||||
uint32_t writeTocEntry(FsFile& file, const TocEntry& entry) const;
|
||||
SpineEntry readSpineEntry(FsFile& file) const;
|
||||
TocEntry readTocEntry(FsFile& file) const;
|
||||
|
||||
public:
|
||||
BookMetadata coreMetadata;
|
||||
|
||||
explicit BookMetadataCache(std::string cachePath)
|
||||
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
|
||||
~BookMetadataCache() = default;
|
||||
|
||||
// Building phase (stream to disk immediately)
|
||||
bool beginWrite();
|
||||
bool beginContentOpfPass();
|
||||
void createSpineEntry(const std::string& href);
|
||||
bool endContentOpfPass();
|
||||
bool beginTocPass();
|
||||
void createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level);
|
||||
bool endTocPass();
|
||||
bool endWrite();
|
||||
bool cleanupTmpFiles() const;
|
||||
|
||||
// Post-processing to update mappings and sizes
|
||||
bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata);
|
||||
|
||||
// Reading phase (read mode)
|
||||
bool load();
|
||||
SpineEntry getSpineEntry(int index);
|
||||
TocEntry getTocEntry(int index);
|
||||
int getSpineCount() const { return spineCount; }
|
||||
int getTocCount() const { return tocCount; }
|
||||
bool isLoaded() const { return loaded; }
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "Page.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
|
||||
bool PageLine::serialize(FsFile& file) {
|
||||
serialization::writePod(file, xPos);
|
||||
serialization::writePod(file, yPos);
|
||||
|
||||
// serialize TextBlock pointed to by PageLine
|
||||
return block->serialize(file);
|
||||
}
|
||||
|
||||
std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
||||
int16_t xPos;
|
||||
int16_t yPos;
|
||||
serialization::readPod(file, xPos);
|
||||
serialization::readPod(file, yPos);
|
||||
|
||||
auto tb = TextBlock::deserialize(file);
|
||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||
}
|
||||
|
||||
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
// Images don't use fontId or text rendering
|
||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
||||
}
|
||||
|
||||
bool PageImage::serialize(FsFile& file) {
|
||||
serialization::writePod(file, xPos);
|
||||
serialization::writePod(file, yPos);
|
||||
|
||||
// serialize ImageBlock
|
||||
return imageBlock->serialize(file);
|
||||
}
|
||||
|
||||
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||
int16_t xPos;
|
||||
int16_t yPos;
|
||||
serialization::readPod(file, xPos);
|
||||
serialization::readPod(file, yPos);
|
||||
|
||||
auto ib = ImageBlock::deserialize(file);
|
||||
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||
}
|
||||
|
||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
|
||||
bool Page::serialize(FsFile& file) const {
|
||||
const uint16_t count = elements.size();
|
||||
serialization::writePod(file, count);
|
||||
|
||||
for (const auto& el : elements) {
|
||||
// Use getTag() method to determine type
|
||||
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
||||
|
||||
if (!el->serialize(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize footnotes (clamp to MAX_FOOTNOTES_PER_PAGE to match addFootnote/deserialize limits)
|
||||
const uint16_t fnCount = std::min<uint16_t>(footnotes.size(), MAX_FOOTNOTES_PER_PAGE);
|
||||
serialization::writePod(file, fnCount);
|
||||
for (uint16_t i = 0; i < fnCount; i++) {
|
||||
const auto& fn = footnotes[i];
|
||||
if (file.write(fn.number, sizeof(fn.number)) != sizeof(fn.number) ||
|
||||
file.write(fn.href, sizeof(fn.href)) != sizeof(fn.href)) {
|
||||
LOG_ERR("PGE", "Failed to write footnote");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
||||
auto page = std::unique_ptr<Page>(new Page());
|
||||
|
||||
uint16_t count;
|
||||
serialization::readPod(file, count);
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint8_t tag;
|
||||
serialization::readPod(file, tag);
|
||||
|
||||
if (tag == TAG_PageLine) {
|
||||
auto pl = PageLine::deserialize(file);
|
||||
page->elements.push_back(std::move(pl));
|
||||
} else if (tag == TAG_PageImage) {
|
||||
auto pi = PageImage::deserialize(file);
|
||||
page->elements.push_back(std::move(pi));
|
||||
} else {
|
||||
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize footnotes
|
||||
uint16_t fnCount;
|
||||
serialization::readPod(file, fnCount);
|
||||
if (fnCount > MAX_FOOTNOTES_PER_PAGE) {
|
||||
LOG_ERR("PGE", "Invalid footnote count %u", fnCount);
|
||||
return nullptr;
|
||||
}
|
||||
page->footnotes.resize(fnCount);
|
||||
for (uint16_t i = 0; i < fnCount; i++) {
|
||||
auto& entry = page->footnotes[i];
|
||||
if (file.read(entry.number, sizeof(entry.number)) != sizeof(entry.number) ||
|
||||
file.read(entry.href, sizeof(entry.href)) != sizeof(entry.href)) {
|
||||
LOG_ERR("PGE", "Failed to read footnote %u", i);
|
||||
return nullptr;
|
||||
}
|
||||
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
#pragma once
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "FootnoteEntry.h"
|
||||
#include "blocks/ImageBlock.h"
|
||||
#include "blocks/TextBlock.h"
|
||||
|
||||
enum PageElementTag : uint8_t {
|
||||
TAG_PageLine = 1,
|
||||
TAG_PageImage = 2, // New tag
|
||||
};
|
||||
|
||||
// represents something that has been added to a page
|
||||
class PageElement {
|
||||
public:
|
||||
int16_t xPos;
|
||||
int16_t yPos;
|
||||
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
|
||||
virtual ~PageElement() = default;
|
||||
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
||||
virtual bool serialize(FsFile& file) = 0;
|
||||
virtual PageElementTag getTag() const = 0; // Add type identification
|
||||
};
|
||||
|
||||
// a line from a block element
|
||||
class PageLine final : public PageElement {
|
||||
std::shared_ptr<TextBlock> block;
|
||||
|
||||
public:
|
||||
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
|
||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||
const std::shared_ptr<TextBlock>& getBlock() const { return block; }
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageLine; }
|
||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||
};
|
||||
|
||||
// New PageImage class
|
||||
class PageImage final : public PageElement {
|
||||
std::shared_ptr<ImageBlock> imageBlock;
|
||||
|
||||
public:
|
||||
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
|
||||
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||
const ImageBlock& getImageBlock() const { return *imageBlock; }
|
||||
};
|
||||
|
||||
class Page {
|
||||
public:
|
||||
// the list of block index and line numbers on this page
|
||||
std::vector<std::shared_ptr<PageElement>> elements;
|
||||
std::vector<FootnoteEntry> footnotes;
|
||||
static constexpr uint16_t MAX_FOOTNOTES_PER_PAGE = 16;
|
||||
|
||||
void addFootnote(const char* number, const char* href) {
|
||||
if (footnotes.size() >= MAX_FOOTNOTES_PER_PAGE) return; // Cap per-page footnotes
|
||||
FootnoteEntry entry;
|
||||
strncpy(entry.number, number, sizeof(entry.number) - 1);
|
||||
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||
strncpy(entry.href, href, sizeof(entry.href) - 1);
|
||||
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||
footnotes.push_back(entry);
|
||||
}
|
||||
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
bool serialize(FsFile& file) const;
|
||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||
|
||||
// Check if page contains any images (used to force full refresh)
|
||||
bool hasImages() const {
|
||||
return std::any_of(elements.begin(), elements.end(),
|
||||
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
|
||||
}
|
||||
|
||||
// Get bounding box of all images on the page (union of image rects)
|
||||
// Returns false if no images. Coordinates are relative to page origin.
|
||||
bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const {
|
||||
bool found = false;
|
||||
int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN;
|
||||
for (const auto& el : elements) {
|
||||
if (el->getTag() == TAG_PageImage) {
|
||||
const auto& img = static_cast<const PageImage&>(*el);
|
||||
int16_t x = img.xPos;
|
||||
int16_t y = img.yPos;
|
||||
int16_t right = x + img.getImageBlock().getWidth();
|
||||
int16_t bottom = y + img.getImageBlock().getHeight();
|
||||
minX = std::min(minX, x);
|
||||
minY = std::min(minY, y);
|
||||
maxX = std::max(maxX, right);
|
||||
maxY = std::max(maxY, bottom);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
outX = minX;
|
||||
outY = minY;
|
||||
outW = maxX - minX;
|
||||
outH = maxY - minY;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
};
|
||||
@@ -1,777 +0,0 @@
|
||||
#include "ParsedText.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
#include "hyphenation/Hyphenator.h"
|
||||
|
||||
constexpr int MAX_COST = std::numeric_limits<int>::max();
|
||||
|
||||
namespace {
|
||||
|
||||
// Soft hyphen byte pattern used throughout EPUBs (UTF-8 for U+00AD).
|
||||
constexpr char SOFT_HYPHEN_UTF8[] = "\xC2\xAD";
|
||||
constexpr size_t SOFT_HYPHEN_BYTES = 2;
|
||||
|
||||
// Returns the first rendered codepoint of a word (skipping leading soft hyphens).
|
||||
uint32_t firstCodepoint(const std::string& word) {
|
||||
const auto* ptr = reinterpret_cast<const unsigned char*>(word.c_str());
|
||||
while (true) {
|
||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
if (cp == 0) return 0;
|
||||
if (cp != 0x00AD) return cp; // skip soft hyphens
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the last codepoint of a word by scanning backward for the start of the last UTF-8 sequence.
|
||||
uint32_t lastCodepoint(const std::string& word) {
|
||||
if (word.empty()) return 0;
|
||||
// UTF-8 continuation bytes start with 10xxxxxx; scan backward to find the leading byte.
|
||||
size_t i = word.size() - 1;
|
||||
while (i > 0 && (static_cast<uint8_t>(word[i]) & 0xC0) == 0x80) {
|
||||
--i;
|
||||
}
|
||||
const auto* ptr = reinterpret_cast<const unsigned char*>(word.c_str() + i);
|
||||
return utf8NextCodepoint(&ptr);
|
||||
}
|
||||
|
||||
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
|
||||
|
||||
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
|
||||
void stripSoftHyphensInPlace(std::string& word) {
|
||||
size_t pos = 0;
|
||||
while ((pos = word.find(SOFT_HYPHEN_UTF8, pos)) != std::string::npos) {
|
||||
word.erase(pos, SOFT_HYPHEN_BYTES);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
||||
// Uses advance width (sum of glyph advances + kerning) rather than bounding box width so that italic glyph overhangs
|
||||
// don't inflate inter-word spacing.
|
||||
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
||||
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
||||
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
|
||||
return renderer.getSpaceWidth(fontId, style);
|
||||
}
|
||||
const bool hasSoftHyphen = containsSoftHyphen(word);
|
||||
if (!hasSoftHyphen && !appendHyphen) {
|
||||
return renderer.getTextAdvanceX(fontId, word.c_str(), style);
|
||||
}
|
||||
|
||||
std::string sanitized = word;
|
||||
if (hasSoftHyphen) {
|
||||
stripSoftHyphensInPlace(sanitized);
|
||||
}
|
||||
if (appendHyphen) {
|
||||
sanitized.push_back('-');
|
||||
}
|
||||
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
|
||||
}
|
||||
|
||||
// Checks if a UTF-8 codepoint should be counted as part of a word for Focus Reading
|
||||
bool isWordCharacter(uint32_t cp) {
|
||||
// ASCII range (Catches 95%+ of characters immediately)
|
||||
if (cp < 128) {
|
||||
// Bitwise trick: (cp | 0x20) converts uppercase ASCII to lowercase.
|
||||
// This checks for A-Z and a-z mathematically, avoiding memory lookups and <cctype>
|
||||
return ((cp | 0x20) >= 'a' && (cp | 0x20) <= 'z') || cp == '\'';
|
||||
}
|
||||
|
||||
// General Punctuation Block, Currency, Math, Arrows, & Symbols (0x2000 - 0x2BFF)
|
||||
if (cp >= 0x2000 && cp <= 0x2BFF) {
|
||||
// Explicitly allow smart quotes, reject all other general punctuation (em-dashes, etc.)
|
||||
return cp == 0x2018 || cp == 0x2019;
|
||||
}
|
||||
|
||||
// Latin-1 Punctuation Block (0x00A1 - 0x00BF)
|
||||
if (cp >= 0x00A1 && cp <= 0x00BF) {
|
||||
// Allow ordinal indicators and micro sign, reject the rest (¡, ¿, «, », etc.)
|
||||
return cp == 0x00AA || cp == 0x00B5 || cp == 0x00BA;
|
||||
}
|
||||
|
||||
// Rejects Two-em dash, Three-em dash, Double oblique hyphen, etc.
|
||||
if (cp >= 0x2E00 && cp <= 0x2E7F) return false;
|
||||
|
||||
// Rejects Modifier Minus (0x02D7), Small Hyphen (0xFE63), and Fullwidth Hyphen (0xFF0D)
|
||||
if (cp == 0x02D7 || cp == 0xFE63 || cp == 0xFF0D) return false;
|
||||
// Assume all other Unicode ranges (accented letters, Cyrillic, Greek, etc.) are valid
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
|
||||
const bool attachToPrevious) {
|
||||
if (word.empty()) return;
|
||||
|
||||
EpdFontFamily::Style baseStyle = fontStyle;
|
||||
if (underline) {
|
||||
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
|
||||
// Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
|
||||
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
|
||||
words.push_back(std::move(word));
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(attachToPrevious);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- FOCUS READING LOGIC BELOW ---
|
||||
|
||||
// Pre-reserve capacity to prevent mid-word heap reallocations.
|
||||
size_t maxPossibleNewTokens = word.length();
|
||||
size_t requiredSize = words.size() + maxPossibleNewTokens;
|
||||
|
||||
if (words.capacity() < requiredSize) {
|
||||
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
|
||||
size_t newCapacity = words.capacity() * 2;
|
||||
|
||||
// Ensure the doubled capacity is actually enough for this specific word
|
||||
if (newCapacity < requiredSize) {
|
||||
newCapacity = requiredSize;
|
||||
}
|
||||
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
|
||||
if (newCapacity < 16) {
|
||||
newCapacity = 16;
|
||||
}
|
||||
|
||||
words.reserve(newCapacity);
|
||||
wordStyles.reserve(newCapacity);
|
||||
wordContinues.reserve(newCapacity);
|
||||
wordIsFocusSuffix.reserve(newCapacity);
|
||||
}
|
||||
|
||||
// Lambda helper to process and push individual sub-segments of the string
|
||||
// Use std::string_view to avoid heap allocations when slicing
|
||||
auto processSegment = [&](std::string_view segment, bool isWord, bool attach) {
|
||||
if (!isWord) {
|
||||
// Punctuation and Numbers stay regular
|
||||
words.emplace_back(segment);
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(attach);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
} else {
|
||||
size_t charCount = 0;
|
||||
const unsigned char* countPtr = reinterpret_cast<const unsigned char*>(segment.data());
|
||||
const unsigned char* countEnd = countPtr + segment.length();
|
||||
|
||||
while (countPtr < countEnd) {
|
||||
utf8NextCodepoint(&countPtr);
|
||||
charCount++;
|
||||
}
|
||||
|
||||
// Target 45% for 1-bold at 4 chars and 3-bold at 7 chars with floor truncation
|
||||
constexpr size_t FOCUS_READING_PERCENT = 45;
|
||||
size_t targetBoldChars = (charCount * FOCUS_READING_PERCENT) / 100;
|
||||
targetBoldChars = std::clamp<size_t>(targetBoldChars, 1, 9);
|
||||
|
||||
if (targetBoldChars >= charCount) {
|
||||
// Whole segment is bold - no suffix split needed
|
||||
words.emplace_back(segment);
|
||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||
wordContinues.push_back(attach);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
} else {
|
||||
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
|
||||
for (size_t i = 0; i < targetBoldChars; ++i) {
|
||||
utf8NextCodepoint(&countPtr);
|
||||
}
|
||||
size_t splitByteOffset = countPtr - reinterpret_cast<const unsigned char*>(segment.data());
|
||||
|
||||
// Bold prefix
|
||||
words.emplace_back(segment.substr(0, splitByteOffset));
|
||||
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
|
||||
wordContinues.push_back(attach);
|
||||
wordIsFocusSuffix.push_back(false);
|
||||
|
||||
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
|
||||
words.emplace_back(segment.substr(splitByteOffset));
|
||||
wordStyles.push_back(baseStyle);
|
||||
wordContinues.push_back(true);
|
||||
wordIsFocusSuffix.push_back(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tokenize the string by alternating states (Word vs. Non-Word)
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(word.c_str());
|
||||
const unsigned char* end = ptr + word.length();
|
||||
|
||||
const unsigned char* segmentStart = ptr;
|
||||
uint32_t firstCp = utf8NextCodepoint(&ptr); // Consume the first char to determine initial state
|
||||
bool inWordSegment = isWordCharacter(firstCp);
|
||||
|
||||
bool isFirstSegment = true;
|
||||
|
||||
while (ptr < end) {
|
||||
const unsigned char* currentCpStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
bool isWordChar = isWordCharacter(cp);
|
||||
|
||||
// Whenever the character type flips, slice off the segment we just completed and process it
|
||||
if (isWordChar != inWordSegment) {
|
||||
size_t segmentLen = currentCpStart - segmentStart;
|
||||
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
|
||||
|
||||
// Only the very first segment inherits the original attachToPrevious flag.
|
||||
// Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
|
||||
|
||||
// Setup for the next segment
|
||||
segmentStart = currentCpStart;
|
||||
inWordSegment = isWordChar;
|
||||
isFirstSegment = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the final remaining segment
|
||||
size_t segmentLen = end - segmentStart;
|
||||
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
|
||||
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
|
||||
}
|
||||
// Consumes data to minimize memory usage
|
||||
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||
const bool includeLastLine) {
|
||||
if (words.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply fixed transforms before any per-line layout work.
|
||||
applyParagraphIndent();
|
||||
|
||||
// Ensure SD card font glyph metrics are loaded before measuring word widths.
|
||||
// For flash-based fonts isSdCardFont() returns false and this block is skipped
|
||||
// entirely — no heap allocation. For SD card fonts this reads glyph metadata
|
||||
// (advanceX only, no bitmaps) for all unique codepoints in this paragraph so
|
||||
// that calculateWordWidths() can measure text without on-demand SD I/O.
|
||||
if (renderer.isSdCardFont(fontId)) {
|
||||
// Style mask: only ask the SD font to load advances for styles actually
|
||||
// used in this paragraph. Style index is the low two bits (regular/bold/
|
||||
// italic/bold-italic); the underline bit is irrelevant to advance metrics.
|
||||
uint8_t styleMask = 0;
|
||||
for (auto s : wordStyles) {
|
||||
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(s) & 0x03));
|
||||
}
|
||||
if (styleMask == 0) styleMask = 0x01; // defensive: regular only
|
||||
renderer.ensureSdCardFontReady(fontId, words, hyphenationEnabled, styleMask);
|
||||
}
|
||||
|
||||
const int pageWidth = viewportWidth;
|
||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
if (hyphenationEnabled) {
|
||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
|
||||
} else {
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
|
||||
}
|
||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
|
||||
}
|
||||
|
||||
// Remove consumed words so size() reflects only remaining words
|
||||
if (lineCount > 0) {
|
||||
const size_t consumed = lineBreakIndices[lineCount - 1];
|
||||
words.erase(words.begin(), words.begin() + consumed);
|
||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
||||
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
||||
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
std::vector<uint16_t> wordWidths;
|
||||
wordWidths.reserve(words.size());
|
||||
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||
}
|
||||
|
||||
return wordWidths;
|
||||
}
|
||||
|
||||
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
|
||||
if (words.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Calculate first line indent (only for left/justified text).
|
||||
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
|
||||
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
|
||||
// it is structural (positions the bullet/marker), not decorative.
|
||||
const int firstLineIndent =
|
||||
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
// Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation.
|
||||
for (size_t i = 0; i < wordWidths.size(); ++i) {
|
||||
// First word needs to fit in reduced width if there's an indent
|
||||
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
while (wordWidths[i] > effectiveWidth) {
|
||||
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const size_t totalWordCount = words.size();
|
||||
|
||||
// DP table to store the minimum badness (cost) of lines starting at index i
|
||||
std::vector<int> dp(totalWordCount);
|
||||
// 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i'
|
||||
std::vector<size_t> ans(totalWordCount);
|
||||
|
||||
// Base Case
|
||||
dp[totalWordCount - 1] = 0;
|
||||
ans[totalWordCount - 1] = totalWordCount - 1;
|
||||
|
||||
for (int i = totalWordCount - 2; i >= 0; --i) {
|
||||
int currlen = 0;
|
||||
dp[i] = MAX_COST;
|
||||
|
||||
// First line has reduced width due to text-indent
|
||||
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
|
||||
for (size_t j = i; j < totalWordCount; ++j) {
|
||||
// Add space before word j, unless it's the first word on the line or a continuation
|
||||
int gap = 0;
|
||||
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
|
||||
gap =
|
||||
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
|
||||
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
|
||||
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
|
||||
gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
|
||||
}
|
||||
currlen += wordWidths[j] + gap;
|
||||
|
||||
if (currlen > effectivePageWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Cannot break after word j if the next word attaches to it (continuation group)
|
||||
if (j + 1 < totalWordCount && continuesVec[j + 1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int cost;
|
||||
if (j == totalWordCount - 1) {
|
||||
cost = 0; // Last line
|
||||
} else {
|
||||
const int remainingSpace = effectivePageWidth - currlen;
|
||||
// Use long long for the square to prevent overflow
|
||||
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
|
||||
|
||||
if (cost_ll > MAX_COST) {
|
||||
cost = MAX_COST;
|
||||
} else {
|
||||
cost = static_cast<int>(cost_ll);
|
||||
}
|
||||
}
|
||||
|
||||
if (cost < dp[i]) {
|
||||
dp[i] = cost;
|
||||
ans[i] = j; // j is the index of the last word in this optimal line
|
||||
}
|
||||
}
|
||||
|
||||
// Handle oversized word: if no valid configuration found, force single-word line
|
||||
// This prevents cascade failure where one oversized word breaks all preceding words
|
||||
if (dp[i] == MAX_COST) {
|
||||
ans[i] = i; // Just this word on its own line
|
||||
// Inherit cost from next word to allow subsequent words to find valid configurations
|
||||
if (i + 1 < static_cast<int>(totalWordCount)) {
|
||||
dp[i] = dp[i + 1];
|
||||
} else {
|
||||
dp[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stores the index of the word that starts the next line (last_word_index + 1)
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
size_t currentWordIndex = 0;
|
||||
|
||||
while (currentWordIndex < totalWordCount) {
|
||||
size_t nextBreakIndex = ans[currentWordIndex] + 1;
|
||||
|
||||
// Safety check: prevent infinite loop if nextBreakIndex doesn't advance
|
||||
if (nextBreakIndex <= currentWordIndex) {
|
||||
// Force advance by at least one word to avoid infinite loop
|
||||
nextBreakIndex = currentWordIndex + 1;
|
||||
}
|
||||
|
||||
lineBreakIndices.push_back(nextBreakIndex);
|
||||
currentWordIndex = nextBreakIndex;
|
||||
}
|
||||
|
||||
return lineBreakIndices;
|
||||
}
|
||||
|
||||
void ParsedText::applyParagraphIndent() {
|
||||
if (extraParagraphSpacing || words.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockStyle.textIndentDefined) {
|
||||
// CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace
|
||||
// The actual indent positioning is handled in extractLine()
|
||||
} else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) {
|
||||
// No CSS text-indent defined - use EmSpace fallback for visual indent
|
||||
words.front().insert(0, "\xe2\x80\x83");
|
||||
}
|
||||
}
|
||||
|
||||
// Builds break indices while opportunistically splitting the word that would overflow the current line.
|
||||
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||
const int pageWidth, std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec) {
|
||||
// Calculate first line indent (only for left/justified text).
|
||||
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
|
||||
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
|
||||
// it is structural (positions the bullet/marker), not decorative.
|
||||
const int firstLineIndent =
|
||||
blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
size_t currentIndex = 0;
|
||||
bool isFirstLine = true;
|
||||
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
const size_t lineStart = currentIndex;
|
||||
int lineWidth = 0;
|
||||
|
||||
// First line has reduced width due to text-indent
|
||||
const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth;
|
||||
|
||||
// Consume as many words as possible for current line, splitting when prefixes fit
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
const bool isFirstWord = currentIndex == lineStart;
|
||||
int spacing = 0;
|
||||
if (!isFirstWord && !continuesVec[currentIndex]) {
|
||||
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
|
||||
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
|
||||
} else if (!isFirstWord && continuesVec[currentIndex]) {
|
||||
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
|
||||
spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]),
|
||||
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
|
||||
}
|
||||
const int candidateWidth = spacing + wordWidths[currentIndex];
|
||||
|
||||
// Word fits on current line
|
||||
if (lineWidth + candidateWidth <= effectivePageWidth) {
|
||||
lineWidth += candidateWidth;
|
||||
++currentIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word would overflow — try to split based on hyphenation points
|
||||
const int availableWidth = effectivePageWidth - lineWidth - spacing;
|
||||
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
||||
|
||||
if (availableWidth > 0 &&
|
||||
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
|
||||
// Prefix now fits; append it to this line and move to next line
|
||||
lineWidth += spacing + wordWidths[currentIndex];
|
||||
++currentIndex;
|
||||
break;
|
||||
}
|
||||
|
||||
// Could not split: force at least one word per line to avoid infinite loop
|
||||
if (currentIndex == lineStart) {
|
||||
lineWidth += candidateWidth;
|
||||
++currentIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't break before a continuation word (e.g., orphaned "?" after "question").
|
||||
// Backtrack to the start of the continuation group so the whole group moves to the next line.
|
||||
while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) {
|
||||
--currentIndex;
|
||||
}
|
||||
|
||||
lineBreakIndices.push_back(currentIndex);
|
||||
isFirstLine = false;
|
||||
}
|
||||
|
||||
return lineBreakIndices;
|
||||
}
|
||||
|
||||
// Splits words[wordIndex] into prefix (adding a hyphen only when needed) and remainder when a legal breakpoint fits the
|
||||
// available width.
|
||||
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
||||
const int fontId, std::vector<uint16_t>& wordWidths,
|
||||
const bool allowFallbackBreaks) {
|
||||
// Guard against invalid indices or zero available width before attempting to split.
|
||||
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string& word = words[wordIndex];
|
||||
const auto style = wordStyles[wordIndex];
|
||||
|
||||
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
||||
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
||||
if (breakInfos.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t chosenOffset = 0;
|
||||
int chosenWidth = -1;
|
||||
bool chosenNeedsHyphen = true;
|
||||
|
||||
// Iterate over each legal breakpoint and retain the widest prefix that still fits.
|
||||
for (const auto& info : breakInfos) {
|
||||
const size_t offset = info.byteOffset;
|
||||
if (offset == 0 || offset >= word.size()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool needsHyphen = info.requiresInsertedHyphen;
|
||||
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen);
|
||||
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) {
|
||||
continue; // Skip if too wide or not an improvement
|
||||
}
|
||||
|
||||
chosenWidth = prefixWidth;
|
||||
chosenOffset = offset;
|
||||
chosenNeedsHyphen = needsHyphen;
|
||||
}
|
||||
|
||||
if (chosenWidth < 0) {
|
||||
// No hyphenation point produced a prefix that fits in the remaining space.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split the word at the selected breakpoint and append a hyphen if required.
|
||||
std::string remainder = word.substr(chosenOffset);
|
||||
words[wordIndex].resize(chosenOffset);
|
||||
if (chosenNeedsHyphen) {
|
||||
words[wordIndex].push_back('-');
|
||||
}
|
||||
|
||||
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
||||
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
|
||||
// The hyphen remainder is not a focus suffix - it starts fresh on the next line.
|
||||
wordIsFocusSuffix.insert(wordIsFocusSuffix.begin() + wordIndex + 1, false);
|
||||
|
||||
// Continuation flag handling after splitting a word into prefix + remainder.
|
||||
//
|
||||
// The prefix keeps the original word's continuation flag so that no-break-space groups
|
||||
// stay linked. The remainder always gets continues=false because it starts on the next
|
||||
// line and is not attached to the prefix.
|
||||
//
|
||||
// Example: "200 Quadratkilometer" produces tokens:
|
||||
// [0] "200" continues=false
|
||||
// [1] " " continues=true
|
||||
// [2] "Quadratkilometer" continues=true <-- the word being split
|
||||
//
|
||||
// After splitting "Quadratkilometer" at "Quadrat-" / "kilometer":
|
||||
// [0] "200" continues=false
|
||||
// [1] " " continues=true
|
||||
// [2] "Quadrat-" continues=true (KEPT — still attached to the no-break group)
|
||||
// [3] "kilometer" continues=false (NEW — starts fresh on the next line)
|
||||
//
|
||||
// This lets the backtracking loop keep the entire prefix group ("200 Quadrat-") on one
|
||||
// line, while "kilometer" moves to the next line.
|
||||
// wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment.
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
||||
|
||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
|
||||
wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||
const GfxRenderer& renderer, const int fontId) {
|
||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
|
||||
const size_t lineWordCount = lineBreak - lastBreakAt;
|
||||
|
||||
// Calculate first line indent (only for left/justified text).
|
||||
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on.
|
||||
// Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
|
||||
// it is structural (positions the bullet/marker), not decorative.
|
||||
const bool isFirstLine = breakIndex == 0;
|
||||
const int firstLineIndent =
|
||||
isFirstLine && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
// Calculate total word width for this line, count actual word gaps,
|
||||
// and accumulate total natural gap widths (including space kerning adjustments).
|
||||
int lineWordWidthSum = 0;
|
||||
size_t actualGapCount = 0;
|
||||
int totalNaturalGaps = 0;
|
||||
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
||||
// Count gaps: each word after the first creates a gap, unless it's a continuation
|
||||
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
|
||||
actualGapCount++;
|
||||
totalNaturalGaps +=
|
||||
renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
|
||||
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
|
||||
} else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) {
|
||||
// Non-breaking space tokens (" " with continues=true) are visible, stretchable spaces —
|
||||
// count them as justifiable gaps so justifyExtra is distributed to them too.
|
||||
if (words[lastBreakAt + wordIdx] == " ") {
|
||||
actualGapCount++;
|
||||
}
|
||||
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
|
||||
totalNaturalGaps +=
|
||||
renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
|
||||
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate spacing (account for indent reducing effective page width on first line)
|
||||
const int effectivePageWidth = pageWidth - firstLineIndent;
|
||||
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
||||
|
||||
// For justified text, compute per-gap extra to distribute remaining space evenly
|
||||
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||
const int justifyExtra = (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1)
|
||||
? spareSpace / static_cast<int>(actualGapCount)
|
||||
: 0;
|
||||
|
||||
// Calculate initial x position (first line starts at indent for left/justified text;
|
||||
// may be negative for hanging indents, e.g. margin-left:3em; text-indent:-1em).
|
||||
auto xpos = static_cast<int16_t>(firstLineIndent);
|
||||
if (blockStyle.alignment == CssTextAlign::Right) {
|
||||
xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||
} else if (blockStyle.alignment == CssTextAlign::Center) {
|
||||
xpos = (effectivePageWidth - lineWordWidthSum - totalNaturalGaps) / 2;
|
||||
}
|
||||
|
||||
// Pre-calculate X positions for words
|
||||
// Continuation words attach to the previous word with no space before them
|
||||
std::vector<int16_t> lineXPos;
|
||||
lineXPos.reserve(lineWordCount);
|
||||
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
lineXPos.push_back(xpos);
|
||||
|
||||
const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1];
|
||||
if (nextIsContinuation) {
|
||||
int advance = wordWidths[lastBreakAt + wordIdx];
|
||||
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
|
||||
advance +=
|
||||
renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
|
||||
firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]);
|
||||
// Non-breaking space tokens are stretchable — expand them during justification like normal spaces.
|
||||
if (words[lastBreakAt + wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
|
||||
blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
|
||||
advance += justifyExtra;
|
||||
}
|
||||
xpos += advance;
|
||||
} else {
|
||||
int gap = 0;
|
||||
if (wordIdx + 1 < lineWordCount) {
|
||||
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
|
||||
firstCodepoint(words[lastBreakAt + wordIdx + 1]),
|
||||
wordStyles[lastBreakAt + wordIdx]);
|
||||
}
|
||||
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
|
||||
gap += justifyExtra;
|
||||
}
|
||||
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
|
||||
}
|
||||
}
|
||||
|
||||
// Build line data by moving from the original vectors using index range
|
||||
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
|
||||
std::make_move_iterator(words.begin() + lineBreak));
|
||||
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
|
||||
|
||||
for (auto& word : lineWords) {
|
||||
if (containsSoftHyphen(word)) {
|
||||
stripSoftHyphensInPlace(word);
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path: when no word on this line was split for focus reading, skip the merge work
|
||||
// entirely and pass empty boundary/suffixX vectors. TextBlock pays zero per-word RAM cost
|
||||
// for these annotations when the vectors are empty.
|
||||
bool lineHasFocusSplit = false;
|
||||
for (size_t i = 0; i < lineWordCount; i++) {
|
||||
if (wordIsFocusSuffix[lastBreakAt + i]) {
|
||||
lineHasFocusSplit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lineHasFocusSplit) {
|
||||
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
|
||||
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: merge focus suffix tokens back into their preceding word entry so each
|
||||
// original word occupies one TextBlock slot. Splits are recorded as per-word annotations
|
||||
// applied at render time, cutting the token count significantly when the feature is active.
|
||||
std::vector<std::string> outWords;
|
||||
std::vector<int16_t> outXPos;
|
||||
std::vector<EpdFontFamily::Style> outStyles;
|
||||
std::vector<uint8_t> outBoundaries;
|
||||
std::vector<uint16_t> outSuffixX;
|
||||
outWords.reserve(lineWordCount);
|
||||
outXPos.reserve(lineWordCount);
|
||||
outStyles.reserve(lineWordCount);
|
||||
outBoundaries.reserve(lineWordCount);
|
||||
outSuffixX.reserve(lineWordCount);
|
||||
|
||||
for (size_t i = 0; i < lineWordCount; i++) {
|
||||
if (wordIsFocusSuffix[lastBreakAt + i] && !outWords.empty()) {
|
||||
// Focus suffix: merge string into the preceding bold-prefix entry.
|
||||
outWords.back() += lineWords[i];
|
||||
} else {
|
||||
// Normal word: check for a following focus suffix to record the byte boundary.
|
||||
uint8_t boundary = 0;
|
||||
uint16_t suffixX = 0;
|
||||
if (i + 1 < lineWordCount && wordIsFocusSuffix[lastBreakAt + i + 1]) {
|
||||
boundary = static_cast<uint8_t>(std::min(lineWords[i].size(), size_t{255}));
|
||||
// Suffix x offset = layout-time advance of the bold prefix, already known from xpos table.
|
||||
suffixX = static_cast<uint16_t>(lineXPos[i + 1] - lineXPos[i]);
|
||||
}
|
||||
outWords.push_back(std::move(lineWords[i]));
|
||||
outXPos.push_back(lineXPos[i]);
|
||||
// For focus entries with a suffix, strip BOLD from the stored style.
|
||||
// Render re-applies it to the prefix portion only, via the boundary field.
|
||||
const EpdFontFamily::Style storedStyle =
|
||||
boundary > 0 ? static_cast<EpdFontFamily::Style>(lineWordStyles[i] & ~EpdFontFamily::BOLD)
|
||||
: lineWordStyles[i];
|
||||
outStyles.push_back(storedStyle);
|
||||
outBoundaries.push_back(boundary);
|
||||
outSuffixX.push_back(suffixX);
|
||||
}
|
||||
}
|
||||
|
||||
processLine(std::make_shared<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
|
||||
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "blocks/BlockStyle.h"
|
||||
#include "blocks/TextBlock.h"
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
class ParsedText {
|
||||
std::vector<std::string> words;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
bool hyphenationEnabled;
|
||||
bool focusReadingEnabled;
|
||||
|
||||
void applyParagraphIndent();
|
||||
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
|
||||
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
|
||||
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
|
||||
int fontId);
|
||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||
|
||||
public:
|
||||
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
|
||||
const bool focusReadingEnabled = false, const BlockStyle& blockStyle = BlockStyle())
|
||||
: blockStyle(blockStyle),
|
||||
extraParagraphSpacing(extraParagraphSpacing),
|
||||
hyphenationEnabled(hyphenationEnabled),
|
||||
focusReadingEnabled(focusReadingEnabled) {}
|
||||
~ParsedText() = default;
|
||||
|
||||
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
BlockStyle& getBlockStyle() { return blockStyle; }
|
||||
size_t size() const { return words.size(); }
|
||||
bool isEmpty() const { return words.empty(); }
|
||||
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
|
||||
bool includeLastLine = true);
|
||||
};
|
||||
@@ -1,466 +0,0 @@
|
||||
#include "Section.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "Epub/css/CssParser.h"
|
||||
#include "Page.h"
|
||||
#include "hyphenation/Hyphenator.h"
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 23;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t);
|
||||
|
||||
struct PageLutEntry {
|
||||
uint32_t fileOffset;
|
||||
uint16_t paragraphIndex;
|
||||
uint16_t listItemIndex;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
if (!file) {
|
||||
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t position = file.position();
|
||||
if (!page->serialize(file)) {
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
||||
return 0;
|
||||
}
|
||||
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||
|
||||
pageCount++;
|
||||
return position;
|
||||
}
|
||||
|
||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const bool embeddedStyle, const uint8_t imageRendering,
|
||||
const bool focusReadingEnabled) {
|
||||
if (!file) {
|
||||
LOG_DBG("SCT", "File not open for writing header");
|
||||
return;
|
||||
}
|
||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
serialization::writePod(file, lineCompression);
|
||||
serialization::writePod(file, extraParagraphSpacing);
|
||||
serialization::writePod(file, paragraphAlignment);
|
||||
serialization::writePod(file, viewportWidth);
|
||||
serialization::writePod(file, viewportHeight);
|
||||
serialization::writePod(file, hyphenationEnabled);
|
||||
serialization::writePod(file, embeddedStyle);
|
||||
serialization::writePod(file, imageRendering);
|
||||
serialization::writePod(file, focusReadingEnabled);
|
||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for paragraph LUT offset (patched later)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for li LUT offset (patched later)
|
||||
}
|
||||
|
||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering, const bool focusReadingEnabled) {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match parameters
|
||||
{
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
|
||||
int fileFontId;
|
||||
uint16_t fileViewportWidth, fileViewportHeight;
|
||||
float fileLineCompression;
|
||||
bool fileExtraParagraphSpacing;
|
||||
uint8_t fileParagraphAlignment;
|
||||
bool fileHyphenationEnabled;
|
||||
bool fileEmbeddedStyle;
|
||||
uint8_t fileImageRendering;
|
||||
bool fileFocusReadingEnabled;
|
||||
serialization::readPod(file, fileFontId);
|
||||
serialization::readPod(file, fileLineCompression);
|
||||
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||
serialization::readPod(file, fileParagraphAlignment);
|
||||
serialization::readPod(file, fileViewportWidth);
|
||||
serialization::readPod(file, fileViewportHeight);
|
||||
serialization::readPod(file, fileHyphenationEnabled);
|
||||
serialization::readPod(file, fileEmbeddedStyle);
|
||||
serialization::readPod(file, fileImageRendering);
|
||||
serialization::readPod(file, fileFocusReadingEnabled);
|
||||
|
||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
||||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
|
||||
imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) {
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
serialization::readPod(file, pageCount);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||
bool Section::clearCache() const {
|
||||
if (!Storage.exists(filePath.c_str())) {
|
||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Storage.remove(filePath.c_str())) {
|
||||
LOG_ERR("SCT", "Failed to clear cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("SCT", "Cache cleared successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const uint8_t imageRendering, const bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn) {
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
{
|
||||
const auto sectionsDir = epub->getCachePath() + "/sections";
|
||||
Storage.mkdir(sectionsDir.c_str());
|
||||
}
|
||||
|
||||
// Retry logic for SD card timing issues
|
||||
bool success = false;
|
||||
uint32_t fileSize = 0;
|
||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||
if (attempt > 0) {
|
||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||
delay(50); // Brief delay before retry
|
||||
}
|
||||
|
||||
// Remove any incomplete file from previous attempt before retrying
|
||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
}
|
||||
|
||||
FsFile tmpHtml;
|
||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
continue;
|
||||
}
|
||||
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||
fileSize = tmpHtml.size();
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
tmpHtml.close();
|
||||
|
||||
// If streaming failed, remove the incomplete file immediately
|
||||
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||
|
||||
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
||||
std::vector<PageLutEntry> lut = {};
|
||||
|
||||
// Derive the content base directory and image cache path prefix for the parser
|
||||
size_t lastSlash = localPath.find_last_of('/');
|
||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||
|
||||
CssParser* cssParser = nullptr;
|
||||
if (embeddedStyle) {
|
||||
cssParser = epub->getCssParser();
|
||||
if (cssParser) {
|
||||
if (!cssParser->loadFromCache()) {
|
||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||
},
|
||||
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
if (!success) {
|
||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t lutOffset = file.position();
|
||||
bool hasFailedLutRecords = false;
|
||||
// Write LUT
|
||||
for (const auto& entry : lut) {
|
||||
if (entry.fileOffset == 0) {
|
||||
hasFailedLutRecords = true;
|
||||
break;
|
||||
}
|
||||
serialization::writePod(file, entry.fileOffset);
|
||||
}
|
||||
|
||||
if (hasFailedLutRecords) {
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
// Explicitly close() file before calling Storage.remove()
|
||||
file.close();
|
||||
Storage.remove(filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
||||
const uint32_t anchorMapOffset = file.position();
|
||||
const auto& anchors = visitor.getAnchors();
|
||||
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
||||
for (const auto& [anchor, page] : anchors) {
|
||||
serialization::writeString(file, anchor);
|
||||
serialization::writePod(file, page);
|
||||
}
|
||||
|
||||
const uint32_t paragraphLutOffset = file.position();
|
||||
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
||||
for (const auto& entry : lut) {
|
||||
serialization::writePod(file, entry.paragraphIndex);
|
||||
}
|
||||
|
||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
||||
for (const auto& entry : lut) {
|
||||
serialization::writePod(file, entry.listItemIndex);
|
||||
}
|
||||
|
||||
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
||||
serialization::writePod(file, pageCount);
|
||||
serialization::writePod(file, lutOffset);
|
||||
serialization::writePod(file, anchorMapOffset);
|
||||
serialization::writePod(file, paragraphLutOffset);
|
||||
serialization::writePod(file, liLutFileOffset);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
if (cssParser) {
|
||||
cssParser->clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||
uint32_t lutOffset;
|
||||
serialization::readPod(file, lutOffset);
|
||||
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
||||
uint32_t pagePos;
|
||||
serialization::readPod(file, pagePos);
|
||||
file.seek(pagePos);
|
||||
|
||||
auto page = Page::deserialize(file);
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
file.close();
|
||||
return page;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t fileSize = f.size();
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 3);
|
||||
uint32_t anchorMapOffset;
|
||||
serialization::readPod(f, anchorMapOffset);
|
||||
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(anchorMapOffset);
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
std::string key;
|
||||
uint16_t page;
|
||||
serialization::readString(f, key);
|
||||
serialization::readPod(f, page);
|
||||
if (key == anchor) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex) const {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t fileSize = f.size();
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
||||
uint32_t paragraphLutOffset;
|
||||
serialization::readPod(f, paragraphLutOffset);
|
||||
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(paragraphLutOffset);
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
if (count == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t);
|
||||
if (lutEnd > fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
uint16_t resultPage = count - 1;
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint16_t pagePIdx;
|
||||
serialization::readPod(f, pagePIdx);
|
||||
if (pagePIdx >= pIndex) {
|
||||
resultPage = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return resultPage;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) const {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t fileSize = f.size();
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
||||
uint32_t paragraphLutOffset;
|
||||
serialization::readPod(f, paragraphLutOffset);
|
||||
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(paragraphLutOffset);
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
if (count == 0 || page >= count) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t);
|
||||
if (entryEnd > fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t));
|
||||
uint16_t pIdx;
|
||||
serialization::readPod(f, pIdx);
|
||||
return pIdx;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::getPageForListItemIndex(const uint16_t liIndex) const {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t fileSize = f.size();
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t));
|
||||
uint32_t liLutOffset;
|
||||
serialization::readPod(f, liLutOffset);
|
||||
if (liLutOffset == 0 || liLutOffset >= fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The li LUT shares count with the paragraph LUT; read count from paragraphLutOffset
|
||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
||||
uint32_t paragraphLutOffset;
|
||||
serialization::readPod(f, paragraphLutOffset);
|
||||
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(paragraphLutOffset);
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
if (count == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const uint32_t lutEnd = liLutOffset + count * sizeof(uint16_t);
|
||||
if (lutEnd > fileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
f.seek(liLutOffset);
|
||||
uint16_t resultPage = count - 1;
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint16_t pageLiIdx;
|
||||
serialization::readPod(f, pageLiIdx);
|
||||
if (pageLiIdx >= liIndex) {
|
||||
resultPage = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return resultPage;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "Epub.h"
|
||||
|
||||
class Page;
|
||||
class GfxRenderer;
|
||||
|
||||
class Section {
|
||||
std::shared_ptr<Epub> epub;
|
||||
const int spineIndex;
|
||||
GfxRenderer& renderer;
|
||||
std::string filePath;
|
||||
FsFile file;
|
||||
|
||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
public:
|
||||
uint16_t pageCount = 0;
|
||||
int currentPage = 0;
|
||||
|
||||
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
: epub(epub),
|
||||
spineIndex(spineIndex),
|
||||
renderer(renderer),
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
~Section() = default;
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled);
|
||||
bool clearCache() const;
|
||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
uint8_t imageRendering, bool focusReadingEnabled,
|
||||
const std::function<void()>& popupFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
|
||||
// Look up the page number for an anchor id from the section cache file.
|
||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||
|
||||
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
||||
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
||||
|
||||
// Look up the page number for a running list-item index from the li LUT.
|
||||
std::optional<uint16_t> getPageForListItemIndex(uint16_t liIndex) const;
|
||||
|
||||
// Look up the synthetic paragraph index for the given rendered page.
|
||||
std::optional<uint16_t> getParagraphIndexForPage(uint16_t page) const;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
|
||||
|
||||
// a block of content in the html - either a paragraph or an image
|
||||
class Block {
|
||||
public:
|
||||
virtual ~Block() = default;
|
||||
|
||||
virtual BlockType getType() = 0;
|
||||
virtual bool isEmpty() = 0;
|
||||
virtual void finish() {}
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
#include "Epub/css/CssStyle.h"
|
||||
|
||||
/**
|
||||
* BlockStyle - Block-level styling properties
|
||||
*/
|
||||
struct BlockStyle {
|
||||
// Upper bound (in em) for any single side's horizontal margin or padding.
|
||||
// Some EPUBs apply huge em-based insets to chapter-opener classes; without a
|
||||
// cap, effectiveWidth collapses to 1-2 words per line and justification dumps
|
||||
// the remaining space into a single gap.
|
||||
static constexpr float MAX_HORIZONTAL_INSET_EM = 2.0f;
|
||||
|
||||
CssTextAlign alignment = CssTextAlign::Justify;
|
||||
|
||||
// Spacing (in pixels)
|
||||
int16_t marginTop = 0;
|
||||
int16_t marginBottom = 0;
|
||||
int16_t marginLeft = 0;
|
||||
int16_t marginRight = 0;
|
||||
int16_t paddingTop = 0; // treated same as margin for rendering
|
||||
int16_t paddingBottom = 0; // treated same as margin for rendering
|
||||
int16_t paddingLeft = 0; // treated same as margin for rendering
|
||||
int16_t paddingRight = 0; // treated same as margin for rendering
|
||||
int16_t textIndent = 0;
|
||||
bool textIndentDefined = false; // true if text-indent was explicitly set in CSS
|
||||
bool textAlignDefined = false; // true if text-align was explicitly set in CSS
|
||||
|
||||
// Combined insets (margin + padding)
|
||||
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
||||
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
||||
[[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); }
|
||||
[[nodiscard]] int16_t topInset() const { return marginTop + paddingTop; }
|
||||
[[nodiscard]] int16_t bottomInset() const { return marginBottom + paddingBottom; }
|
||||
|
||||
// Return a copy with bottom margins/padding zeroed out.
|
||||
[[nodiscard]] BlockStyle withoutBottom() const {
|
||||
BlockStyle result = *this;
|
||||
result.marginBottom = 0;
|
||||
result.paddingBottom = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return a copy with bottom margins/padding collapsed (max) with the source's.
|
||||
// Uses CSS margin collapsing: adjacent parent-child margins resolve to the larger value.
|
||||
[[nodiscard]] BlockStyle addBottom(const BlockStyle& source) const {
|
||||
BlockStyle result = *this;
|
||||
result.marginBottom = std::max(marginBottom, source.marginBottom);
|
||||
result.paddingBottom = static_cast<int16_t>(paddingBottom + source.paddingBottom);
|
||||
return result;
|
||||
}
|
||||
|
||||
enum class CombineAxis : uint8_t {
|
||||
Horizontal = 1, // margins left/right, padding left/right, text-align, text-indent
|
||||
Vertical = 2, // margins top/bottom, padding top/bottom
|
||||
};
|
||||
|
||||
// Combine this style's properties with a child style along the specified axis.
|
||||
// Properties on the other axis are kept from the child unchanged.
|
||||
[[nodiscard]] BlockStyle getCombinedBlockStyle(const BlockStyle& child, CombineAxis axis) const {
|
||||
BlockStyle result = child;
|
||||
|
||||
if (axis == CombineAxis::Horizontal) {
|
||||
result.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft);
|
||||
result.marginRight = static_cast<int16_t>(child.marginRight + marginRight);
|
||||
result.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft);
|
||||
result.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight);
|
||||
if (!child.textIndentDefined && textIndentDefined) {
|
||||
result.textIndent = textIndent;
|
||||
result.textIndentDefined = true;
|
||||
}
|
||||
if (!child.textAlignDefined && textAlignDefined) {
|
||||
result.alignment = alignment;
|
||||
result.textAlignDefined = true;
|
||||
}
|
||||
} else {
|
||||
result.marginTop = std::max(child.marginTop, marginTop);
|
||||
result.marginBottom = std::max(child.marginBottom, marginBottom);
|
||||
result.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop);
|
||||
result.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
|
||||
// emSize is the current font line height, used for em/rem unit conversion
|
||||
// paragraphAlignment is the user's paragraphAlignment setting preference
|
||||
static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment,
|
||||
const uint16_t viewportWidth = 0) {
|
||||
BlockStyle blockStyle;
|
||||
const float vw = viewportWidth;
|
||||
const auto maxHorizontalInsetPx = static_cast<int16_t>(emSize * MAX_HORIZONTAL_INSET_EM);
|
||||
// Resolve all CssLength values to pixels using the current font's em size and viewport width
|
||||
blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize, vw);
|
||||
blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize, vw);
|
||||
blockStyle.marginLeft = std::min(cssStyle.marginLeft.toPixelsInt16(emSize, vw), maxHorizontalInsetPx);
|
||||
blockStyle.marginRight = std::min(cssStyle.marginRight.toPixelsInt16(emSize, vw), maxHorizontalInsetPx);
|
||||
|
||||
blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize, vw);
|
||||
blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize, vw);
|
||||
blockStyle.paddingLeft = std::min(cssStyle.paddingLeft.toPixelsInt16(emSize, vw), maxHorizontalInsetPx);
|
||||
blockStyle.paddingRight = std::min(cssStyle.paddingRight.toPixelsInt16(emSize, vw), maxHorizontalInsetPx);
|
||||
|
||||
// For textIndent: if it's a percentage we can't resolve (no viewport width),
|
||||
// leave textIndentDefined=false so the EmSpace fallback in applyParagraphIndent() is used
|
||||
if (cssStyle.hasTextIndent() && cssStyle.textIndent.isResolvable(vw)) {
|
||||
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize, vw);
|
||||
blockStyle.textIndentDefined = true;
|
||||
}
|
||||
blockStyle.textAlignDefined = cssStyle.hasTextAlign();
|
||||
// User setting overrides CSS, unless "Book's Style" alignment setting is selected
|
||||
if (paragraphAlignment == CssTextAlign::None) {
|
||||
blockStyle.alignment = blockStyle.textAlignDefined ? cssStyle.textAlign : CssTextAlign::Justify;
|
||||
} else {
|
||||
blockStyle.alignment = paragraphAlignment;
|
||||
}
|
||||
return blockStyle;
|
||||
}
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
#include "ImageBlock.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "../converters/DirectPixelWriter.h"
|
||||
#include "../converters/ImageDecoderFactory.h"
|
||||
|
||||
// Cache file format:
|
||||
// - uint16_t width
|
||||
// - uint16_t height
|
||||
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), width(width), height(height) {}
|
||||
|
||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||
|
||||
namespace {
|
||||
|
||||
std::string getCachePath(const std::string& imagePath) {
|
||||
// Replace extension with .pxc (pixel cache)
|
||||
size_t dotPos = imagePath.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
return imagePath.substr(0, dotPos) + ".pxc";
|
||||
}
|
||||
return imagePath + ".pxc";
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
int expectedHeight) {
|
||||
FsFile cacheFile;
|
||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t cachedWidth, cachedHeight;
|
||||
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
||||
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||
if (widthDiff > 1 || heightDiff > 1) {
|
||||
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
|
||||
expectedHeight);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use cached dimensions for rendering (they're the actual decoded size)
|
||||
expectedWidth = cachedWidth;
|
||||
expectedHeight = cachedHeight;
|
||||
|
||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// Read and render row by row to minimize memory usage
|
||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||
if (!rowBuffer) {
|
||||
LOG_ERR("IMG", "Failed to allocate row buffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
DirectPixelWriter pw;
|
||||
pw.init(renderer);
|
||||
|
||||
for (int row = 0; row < cachedHeight; row++) {
|
||||
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||
LOG_ERR("IMG", "Cache read error at row %d", row);
|
||||
free(rowBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
const int destY = y + row;
|
||||
pw.beginRow(destY);
|
||||
for (int col = 0; col < cachedWidth; col++) {
|
||||
const int byteIdx = col >> 2; // col / 4
|
||||
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
|
||||
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||
|
||||
pw.writePixel(x + col, pixelValue);
|
||||
}
|
||||
}
|
||||
|
||||
free(rowBuffer);
|
||||
LOG_DBG("IMG", "Cache render complete");
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
|
||||
// Bounds check render position using logical screen dimensions
|
||||
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
||||
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
|
||||
screenHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to render from cache first
|
||||
std::string cachePath = getCachePath(imagePath);
|
||||
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||
return; // Successfully rendered from cache
|
||||
}
|
||||
|
||||
// No cache - need to decode the image
|
||||
// Check if image file exists
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("IMG", imagePath, file)) {
|
||||
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
size_t fileSize = file.size();
|
||||
file.close();
|
||||
|
||||
if (fileSize == 0) {
|
||||
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
|
||||
|
||||
RenderConfig config;
|
||||
config.x = x;
|
||||
config.y = y;
|
||||
config.maxWidth = width;
|
||||
config.maxHeight = height;
|
||||
config.useGrayscale = true;
|
||||
config.useDithering = true;
|
||||
config.performanceMode = false;
|
||||
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
||||
config.cachePath = cachePath; // Enable caching during decode
|
||||
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||
if (!decoder) {
|
||||
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
|
||||
|
||||
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
||||
if (!success) {
|
||||
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("IMG", "Decode successful");
|
||||
}
|
||||
|
||||
bool ImageBlock::serialize(FsFile& file) {
|
||||
serialization::writeString(file, imagePath);
|
||||
serialization::writePod(file, width);
|
||||
serialization::writePod(file, height);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
|
||||
std::string path;
|
||||
serialization::readString(file, path);
|
||||
int16_t w, h;
|
||||
serialization::readPod(file, w);
|
||||
serialization::readPod(file, h);
|
||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#pragma once
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "Block.h"
|
||||
|
||||
class ImageBlock final : public Block {
|
||||
public:
|
||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||
~ImageBlock() override = default;
|
||||
|
||||
const std::string& getImagePath() const { return imagePath; }
|
||||
int16_t getWidth() const { return width; }
|
||||
int16_t getHeight() const { return height; }
|
||||
|
||||
bool imageExists() const;
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
void render(GfxRenderer& renderer, const int x, const int y);
|
||||
bool serialize(FsFile& file);
|
||||
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
||||
|
||||
private:
|
||||
std::string imagePath;
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
#include "TextBlock.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||
// Focus annotations are optional: empty vectors mean no word in this block has a split.
|
||||
// When present, they must be sized in lockstep with words[].
|
||||
const bool hasFocus = !wordFocusBoundary.empty();
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
||||
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
||||
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(),
|
||||
(uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size());
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
const int wordX = wordXpos[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
|
||||
|
||||
if (boundary > 0) {
|
||||
// Focus split: draw bold prefix, then the regular suffix at a pre-computed x offset.
|
||||
// The bold prefix is bounded to 9 codepoints by the clamp on targetBoldChars in
|
||||
// ParsedText::addWord; 9 UTF-8 codepoints occupy at most 9 * 4 = 36 bytes, +1 for null = 37.
|
||||
// suffixX is computed at cache-creation time to avoid font metric lookups at render time.
|
||||
static constexpr size_t MAX_FOCUS_PREFIX_BYTES = 9 * 4 + 1;
|
||||
char boldBuf[40];
|
||||
static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
|
||||
"boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
|
||||
const auto boldStyle = static_cast<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
|
||||
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
|
||||
memcpy(boldBuf, words[i].c_str(), boldLen);
|
||||
boldBuf[boldLen] = '\0';
|
||||
renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle);
|
||||
const int suffixX = wordX + wordFocusSuffixX[i];
|
||||
renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle);
|
||||
} else {
|
||||
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
|
||||
}
|
||||
|
||||
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||
const std::string& w = words[i];
|
||||
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
||||
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
||||
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
||||
|
||||
int startX = wordX;
|
||||
int underlineWidth = fullWordWidth;
|
||||
|
||||
// if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line
|
||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||
const char* visiblePtr = w.c_str() + 3;
|
||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
||||
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
||||
startX = wordX + prefixWidth;
|
||||
underlineWidth = visibleWidth;
|
||||
}
|
||||
|
||||
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TextBlock::serialize(FsFile& file) const {
|
||||
// Focus annotations are optional; vectors are either empty (no splits in this block)
|
||||
// or sized in lockstep with words[].
|
||||
const bool hasFocus = !wordFocusBoundary.empty();
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
|
||||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
|
||||
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
|
||||
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
|
||||
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
|
||||
static_cast<uint32_t>(wordFocusSuffixX.size()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Word data
|
||||
serialization::writePod(file, static_cast<uint16_t>(words.size()));
|
||||
for (const auto& w : words) serialization::writeString(file, w);
|
||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
||||
// Focus block: 1-byte presence flag, followed by per-word vectors only when present.
|
||||
// Saves 3 bytes/word when focus reading is disabled or no word on this line was split.
|
||||
serialization::writePod(file, static_cast<uint8_t>(hasFocus ? 1 : 0));
|
||||
if (hasFocus) {
|
||||
for (auto b : wordFocusBoundary) serialization::writePod(file, b);
|
||||
for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx);
|
||||
}
|
||||
|
||||
// Style (alignment + margins/padding/indent)
|
||||
serialization::writePod(file, blockStyle.alignment);
|
||||
serialization::writePod(file, blockStyle.textAlignDefined);
|
||||
serialization::writePod(file, blockStyle.marginTop);
|
||||
serialization::writePod(file, blockStyle.marginBottom);
|
||||
serialization::writePod(file, blockStyle.marginLeft);
|
||||
serialization::writePod(file, blockStyle.marginRight);
|
||||
serialization::writePod(file, blockStyle.paddingTop);
|
||||
serialization::writePod(file, blockStyle.paddingBottom);
|
||||
serialization::writePod(file, blockStyle.paddingLeft);
|
||||
serialization::writePod(file, blockStyle.paddingRight);
|
||||
serialization::writePod(file, blockStyle.textIndent);
|
||||
serialization::writePod(file, blockStyle.textIndentDefined);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
uint16_t wc;
|
||||
std::vector<std::string> words;
|
||||
std::vector<int16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<uint8_t> wordFocusBoundary;
|
||||
std::vector<uint16_t> wordFocusSuffixX;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
// Word count
|
||||
serialization::readPod(file, wc);
|
||||
|
||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||
if (wc > 10000) {
|
||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Word data
|
||||
words.resize(wc);
|
||||
wordXpos.resize(wc);
|
||||
wordStyles.resize(wc);
|
||||
for (auto& w : words) serialization::readString(file, w);
|
||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
||||
// Focus block: presence flag, then vectors only if present. Empty vectors when absent
|
||||
// signal "no splits in this block" to render() (zero per-word RAM cost).
|
||||
uint8_t hasFocus;
|
||||
serialization::readPod(file, hasFocus);
|
||||
if (hasFocus) {
|
||||
wordFocusBoundary.resize(wc);
|
||||
wordFocusSuffixX.resize(wc);
|
||||
for (auto& b : wordFocusBoundary) serialization::readPod(file, b);
|
||||
for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx);
|
||||
}
|
||||
|
||||
// Style (alignment + margins/padding/indent)
|
||||
serialization::readPod(file, blockStyle.alignment);
|
||||
serialization::readPod(file, blockStyle.textAlignDefined);
|
||||
serialization::readPod(file, blockStyle.marginTop);
|
||||
serialization::readPod(file, blockStyle.marginBottom);
|
||||
serialization::readPod(file, blockStyle.marginLeft);
|
||||
serialization::readPod(file, blockStyle.marginRight);
|
||||
serialization::readPod(file, blockStyle.paddingTop);
|
||||
serialization::readPod(file, blockStyle.paddingBottom);
|
||||
serialization::readPod(file, blockStyle.paddingLeft);
|
||||
serialization::readPod(file, blockStyle.paddingRight);
|
||||
serialization::readPod(file, blockStyle.textIndent);
|
||||
serialization::readPod(file, blockStyle.textIndentDefined);
|
||||
|
||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
|
||||
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
|
||||
blockStyle));
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#pragma once
|
||||
#include <EpdFontFamily.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Block.h"
|
||||
#include "BlockStyle.h"
|
||||
|
||||
// Represents a line of text on a page
|
||||
class TextBlock final : public Block {
|
||||
private:
|
||||
std::vector<std::string> words;
|
||||
std::vector<int16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
// Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold,
|
||||
// the remainder in the base style. 0 means no split (whole word uses wordStyles[i]).
|
||||
// N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by
|
||||
// FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t.
|
||||
// Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost
|
||||
// when focus reading is disabled, or on lines that happen to contain no splittable words).
|
||||
std::vector<uint8_t> wordFocusBoundary;
|
||||
// Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0.
|
||||
// Eliminates getTextAdvanceX from the render path. 0 when boundary == 0.
|
||||
// Empty in lockstep with wordFocusBoundary.
|
||||
std::vector<uint16_t> wordFocusSuffixX;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
public:
|
||||
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
|
||||
std::vector<EpdFontFamily::Style> word_styles, std::vector<uint8_t> focus_boundary,
|
||||
std::vector<uint16_t> focus_suffix_x, const BlockStyle& blockStyle = BlockStyle())
|
||||
: words(std::move(words)),
|
||||
wordXpos(std::move(word_xpos)),
|
||||
wordStyles(std::move(word_styles)),
|
||||
wordFocusBoundary(std::move(focus_boundary)),
|
||||
wordFocusSuffixX(std::move(focus_suffix_x)),
|
||||
blockStyle(blockStyle) {}
|
||||
~TextBlock() override = default;
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||
const std::vector<std::string>& getWords() const { return words; }
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
size_t wordCount() const { return words.size(); }
|
||||
// given a renderer works out where to break the words into lines
|
||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||
BlockType getType() override { return TEXT_BLOCK; }
|
||||
bool serialize(FsFile& file) const;
|
||||
static std::unique_ptr<TextBlock> deserialize(FsFile& file);
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalDisplay.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Direct framebuffer writer that eliminates per-pixel overhead from the image
|
||||
// rendering hot path. Pre-computes orientation transform as linear coefficients
|
||||
// and caches render-mode state so the inner loop is: one multiply, one add,
|
||||
// one shift, and one AND per pixel — no branches, no method calls.
|
||||
//
|
||||
// Caller is responsible for ensuring (outX, outY) are within screen bounds.
|
||||
// ImageBlock::render() already validates this before entering the pixel loop,
|
||||
// and the JPEG/PNG callbacks pre-clamp destination ranges to screen bounds.
|
||||
struct DirectPixelWriter {
|
||||
uint8_t* fb;
|
||||
GfxRenderer::RenderMode mode;
|
||||
uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99)
|
||||
|
||||
// Orientation is collapsed into a linear transform:
|
||||
// phyX = phyXBase + x * phyXStepX + y * phyXStepY
|
||||
// phyY = phyYBase + x * phyYStepX + y * phyYStepY
|
||||
int phyXBase, phyYBase;
|
||||
int phyXStepX, phyYStepX; // per logical-X step
|
||||
int phyXStepY, phyYStepY; // per logical-Y step
|
||||
|
||||
// Row-precomputed: the Y-dependent portion of the physical coords
|
||||
int rowPhyXBase, rowPhyYBase;
|
||||
|
||||
void init(GfxRenderer& renderer) {
|
||||
fb = renderer.getFrameBuffer();
|
||||
mode = renderer.getRenderMode();
|
||||
displayWidthBytes = renderer.getDisplayWidthBytes();
|
||||
|
||||
const int phyW = renderer.getDisplayWidth();
|
||||
const int phyH = renderer.getDisplayHeight();
|
||||
|
||||
switch (renderer.getOrientation()) {
|
||||
case GfxRenderer::Portrait:
|
||||
// phyX = y, phyY = (phyH-1) - x
|
||||
phyXBase = 0;
|
||||
phyYBase = phyH - 1;
|
||||
phyXStepX = 0;
|
||||
phyYStepX = -1;
|
||||
phyXStepY = 1;
|
||||
phyYStepY = 0;
|
||||
break;
|
||||
case GfxRenderer::LandscapeClockwise:
|
||||
// phyX = (phyW-1) - x, phyY = (phyH-1) - y
|
||||
phyXBase = phyW - 1;
|
||||
phyYBase = phyH - 1;
|
||||
phyXStepX = -1;
|
||||
phyYStepX = 0;
|
||||
phyXStepY = 0;
|
||||
phyYStepY = -1;
|
||||
break;
|
||||
case GfxRenderer::PortraitInverted:
|
||||
// phyX = (phyW-1) - y, phyY = x
|
||||
phyXBase = phyW - 1;
|
||||
phyYBase = 0;
|
||||
phyXStepX = 0;
|
||||
phyYStepX = 1;
|
||||
phyXStepY = -1;
|
||||
phyYStepY = 0;
|
||||
break;
|
||||
case GfxRenderer::LandscapeCounterClockwise:
|
||||
// phyX = x, phyY = y
|
||||
phyXBase = 0;
|
||||
phyYBase = 0;
|
||||
phyXStepX = 1;
|
||||
phyYStepX = 0;
|
||||
phyXStepY = 0;
|
||||
phyYStepY = 1;
|
||||
break;
|
||||
default:
|
||||
// Fallback to LandscapeCounterClockwise (identity transform)
|
||||
phyXBase = 0;
|
||||
phyYBase = 0;
|
||||
phyXStepX = 1;
|
||||
phyYStepX = 0;
|
||||
phyXStepY = 0;
|
||||
phyYStepY = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Call once per row before the column loop.
|
||||
// Pre-computes the Y-dependent portion so writePixel() only needs the X part.
|
||||
inline void beginRow(int logicalY) {
|
||||
rowPhyXBase = phyXBase + logicalY * phyXStepY;
|
||||
rowPhyYBase = phyYBase + logicalY * phyYStepY;
|
||||
}
|
||||
|
||||
// Write a single 2-bit dithered pixel value to the framebuffer.
|
||||
// Must be called after beginRow() for the current row.
|
||||
// No bounds checking — caller guarantees coordinates are valid.
|
||||
inline void writePixel(int logicalX, uint8_t pixelValue) const {
|
||||
// Determine whether to draw based on render mode
|
||||
bool draw;
|
||||
bool state;
|
||||
switch (mode) {
|
||||
case GfxRenderer::BW:
|
||||
draw = (pixelValue < 3);
|
||||
state = true;
|
||||
break;
|
||||
case GfxRenderer::GRAYSCALE_MSB:
|
||||
draw = (pixelValue == 1 || pixelValue == 2);
|
||||
state = false;
|
||||
break;
|
||||
case GfxRenderer::GRAYSCALE_LSB:
|
||||
draw = (pixelValue == 1);
|
||||
state = false;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draw) return;
|
||||
|
||||
const int phyX = rowPhyXBase + logicalX * phyXStepX;
|
||||
const int phyY = rowPhyYBase + logicalX * phyYStepX;
|
||||
|
||||
const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3);
|
||||
const uint8_t bitMask = 1 << (7 - (phyX & 7));
|
||||
|
||||
if (state) {
|
||||
fb[byteIndex] &= ~bitMask; // Clear bit (draw black)
|
||||
} else {
|
||||
fb[byteIndex] |= bitMask; // Set bit (draw white)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Direct cache writer that eliminates per-pixel overhead from PixelCache::setPixel().
|
||||
// Pre-computes row pointer so the inner loop is just byte index + bit manipulation.
|
||||
//
|
||||
// Caller guarantees coordinates are within cache bounds.
|
||||
struct DirectCacheWriter {
|
||||
uint8_t* buffer;
|
||||
int bytesPerRow;
|
||||
int originX;
|
||||
uint8_t* rowPtr; // Pre-computed for current row
|
||||
|
||||
void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheOriginX) {
|
||||
buffer = cacheBuffer;
|
||||
bytesPerRow = cacheBytesPerRow;
|
||||
originX = cacheOriginX;
|
||||
rowPtr = nullptr;
|
||||
}
|
||||
|
||||
// Call once per row before the column loop.
|
||||
inline void beginRow(int screenY, int cacheOriginY) { rowPtr = buffer + (screenY - cacheOriginY) * bytesPerRow; }
|
||||
|
||||
// Write a 2-bit pixel value. No bounds checking.
|
||||
inline void writePixel(int screenX, uint8_t value) const {
|
||||
const int localX = screenX - originX;
|
||||
const int byteIdx = localX >> 2; // localX / 4
|
||||
const int bitShift = 6 - (localX & 3) * 2; // MSB first: pixel 0 at bits 6-7
|
||||
rowPtr[byteIdx] = (rowPtr[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// 4x4 Bayer matrix for ordered dithering
|
||||
inline const uint8_t bayer4x4[4][4] = {
|
||||
{0, 8, 2, 10},
|
||||
{12, 4, 14, 6},
|
||||
{3, 11, 1, 9},
|
||||
{15, 7, 13, 5},
|
||||
};
|
||||
|
||||
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||
// Stateless - works correctly with any pixel processing order
|
||||
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||
int bayer = bayer4x4[y & 3][x & 3];
|
||||
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
|
||||
|
||||
int adjusted = gray + dither;
|
||||
if (adjusted < 0) adjusted = 0;
|
||||
if (adjusted > 255) adjusted = 255;
|
||||
|
||||
if (adjusted < 64) return 0;
|
||||
if (adjusted < 128) return 1;
|
||||
if (adjusted < 192) return 2;
|
||||
return 3;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "ImageDecoderFactory.h"
|
||||
|
||||
#include <Logging.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
||||
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
||||
|
||||
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
||||
std::string ext = imagePath;
|
||||
size_t dotPos = ext.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
ext = ext.substr(dotPos);
|
||||
for (auto& c : ext) {
|
||||
c = tolower(c);
|
||||
}
|
||||
} else {
|
||||
ext = "";
|
||||
}
|
||||
|
||||
if (JpegToFramebufferConverter::supportsFormat(ext)) {
|
||||
if (!jpegDecoder) {
|
||||
jpegDecoder.reset(new JpegToFramebufferConverter());
|
||||
}
|
||||
return jpegDecoder.get();
|
||||
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
|
||||
if (!pngDecoder) {
|
||||
pngDecoder.reset(new PngToFramebufferConverter());
|
||||
}
|
||||
return pngDecoder.get();
|
||||
}
|
||||
|
||||
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
|
||||
@@ -1,20 +0,0 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class JpegToFramebufferConverter;
|
||||
class PngToFramebufferConverter;
|
||||
|
||||
class ImageDecoderFactory {
|
||||
public:
|
||||
// Returns non-owning pointer - factory owns the decoder lifetime
|
||||
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
||||
static bool isFormatSupported(const std::string& imagePath);
|
||||
|
||||
private:
|
||||
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
||||
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "ImageToFramebufferDecoder.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);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#pragma once
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
struct ImageDimensions {
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
};
|
||||
|
||||
struct RenderConfig {
|
||||
int x, y;
|
||||
int maxWidth, maxHeight;
|
||||
bool useGrayscale = true;
|
||||
bool useDithering = true;
|
||||
bool performanceMode = false;
|
||||
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
||||
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||
};
|
||||
|
||||
class ImageToFramebufferDecoder {
|
||||
public:
|
||||
virtual ~ImageToFramebufferDecoder() = default;
|
||||
|
||||
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
|
||||
|
||||
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
|
||||
|
||||
virtual const char* getFormatName() const = 0;
|
||||
|
||||
protected:
|
||||
// Size validation helpers
|
||||
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
||||
|
||||
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||
};
|
||||
@@ -1,512 +0,0 @@
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JPEGDEC.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
|
||||
#include "DirectPixelWriter.h"
|
||||
#include "DitherUtils.h"
|
||||
#include "PixelCache.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Context struct passed through JPEGDEC callbacks to avoid global mutable state.
|
||||
// The draw callback receives this via pDraw->pUser (set by setUserPointer()).
|
||||
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by jpegOpen()).
|
||||
struct JpegContext {
|
||||
GfxRenderer* renderer{nullptr};
|
||||
const RenderConfig* config{nullptr};
|
||||
int screenWidth{0};
|
||||
int screenHeight{0};
|
||||
|
||||
// Source dimensions after JPEGDEC's built-in scaling
|
||||
int scaledSrcWidth{0};
|
||||
int scaledSrcHeight{0};
|
||||
|
||||
// Final output dimensions
|
||||
int dstWidth{0};
|
||||
int dstHeight{0};
|
||||
|
||||
// Fine scale in 16.16 fixed-point (ESP32-C3 has no FPU).
|
||||
// X and Y axes use separate scale factors: the aspect ratio of the output (dstWidth/dstHeight)
|
||||
// may differ from the source (srcWidth/srcHeight) due to integer rounding of displayHeight.
|
||||
// Using a single (X-based) scale for both axes causes the wrong srcRow to be skipped
|
||||
// during nearest-neighbor downscaling, potentially losing critical image content.
|
||||
int32_t fineScaleFPX{1 << 16}; // X: src -> dst column mapping
|
||||
int32_t invScaleFPX{1 << 16}; // X: dst -> src column mapping
|
||||
int32_t fineScaleFPY{1 << 16}; // Y: src -> dst row mapping
|
||||
int32_t invScaleFPY{1 << 16}; // Y: dst -> src row mapping
|
||||
|
||||
PixelCache cache;
|
||||
bool caching{false};
|
||||
};
|
||||
|
||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||
// avoiding the need for global file state.
|
||||
void* jpegOpen(const char* filename, int32_t* size) {
|
||||
FsFile* f = new FsFile();
|
||||
if (!Storage.openFileForRead("JPG", std::string(filename), *f)) {
|
||||
delete f;
|
||||
return nullptr;
|
||||
}
|
||||
*size = f->size();
|
||||
return f;
|
||||
}
|
||||
|
||||
void jpegClose(void* handle) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
||||
if (f) {
|
||||
f->close();
|
||||
delete f;
|
||||
}
|
||||
}
|
||||
|
||||
// JPEGDEC tracks file position via pFile->iPos internally (e.g. JPEGGetMoreData
|
||||
// checks iPos < iSize to decide whether more data is available). The callbacks
|
||||
// MUST maintain iPos to match the actual file position, otherwise progressive
|
||||
// JPEGs with large headers fail during parsing.
|
||||
int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return 0;
|
||||
int32_t bytesRead = f->read(pBuf, len);
|
||||
if (bytesRead < 0) return 0;
|
||||
pFile->iPos += bytesRead;
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return -1;
|
||||
if (!f->seek(pos)) return -1;
|
||||
pFile->iPos = pos;
|
||||
return pos;
|
||||
}
|
||||
|
||||
// JPEGDEC object is ~17 KB due to internal decode buffers.
|
||||
// Heap-allocate on demand so memory is only used during active decode.
|
||||
constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024;
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024;
|
||||
|
||||
// Choose JPEGDEC's built-in scale factor for coarse downscaling.
|
||||
// Returns the scale denominator (1, 2, 4, or 8) and sets jpegScaleOption.
|
||||
int chooseJpegScale(float targetScale, int& jpegScaleOption) {
|
||||
if (targetScale <= 0.125f) {
|
||||
jpegScaleOption = JPEG_SCALE_EIGHTH;
|
||||
return 8;
|
||||
}
|
||||
if (targetScale <= 0.25f) {
|
||||
jpegScaleOption = JPEG_SCALE_QUARTER;
|
||||
return 4;
|
||||
}
|
||||
if (targetScale <= 0.5f) {
|
||||
jpegScaleOption = JPEG_SCALE_HALF;
|
||||
return 2;
|
||||
}
|
||||
jpegScaleOption = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fixed-point 16.16 arithmetic avoids software float emulation on ESP32-C3 (no FPU).
|
||||
constexpr int FP_SHIFT = 16;
|
||||
constexpr int32_t FP_ONE = 1 << FP_SHIFT;
|
||||
constexpr int32_t FP_MASK = FP_ONE - 1;
|
||||
|
||||
int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
|
||||
if (!ctx || !ctx->config || !ctx->renderer) return 0;
|
||||
|
||||
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
|
||||
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
|
||||
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
|
||||
const int stride = pDraw->iWidth;
|
||||
const int validW = pDraw->iWidthUsed;
|
||||
const int blockH = pDraw->iHeight;
|
||||
|
||||
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
|
||||
|
||||
const bool useDithering = ctx->config->useDithering;
|
||||
const bool caching = ctx->caching;
|
||||
const int32_t fineScaleFPX = ctx->fineScaleFPX;
|
||||
const int32_t invScaleFPX = ctx->invScaleFPX;
|
||||
const int32_t fineScaleFPY = ctx->fineScaleFPY;
|
||||
const int32_t invScaleFPY = ctx->invScaleFPY;
|
||||
GfxRenderer& renderer = *ctx->renderer;
|
||||
const int cfgX = ctx->config->x;
|
||||
const int cfgY = ctx->config->y;
|
||||
const int blockX = pDraw->x;
|
||||
const int blockY = pDraw->y;
|
||||
|
||||
// Determine destination pixel range covered by this source block
|
||||
const int srcYEnd = blockY + blockH;
|
||||
const int srcXEnd = blockX + validW;
|
||||
|
||||
int dstYStart = (int)((int64_t)blockY * fineScaleFPY >> FP_SHIFT);
|
||||
int dstYEnd = (srcYEnd >= ctx->scaledSrcHeight) ? ctx->dstHeight : (int)((int64_t)srcYEnd * fineScaleFPY >> FP_SHIFT);
|
||||
int dstXStart = (int)((int64_t)blockX * fineScaleFPX >> FP_SHIFT);
|
||||
int dstXEnd = (srcXEnd >= ctx->scaledSrcWidth) ? ctx->dstWidth : (int)((int64_t)srcXEnd * fineScaleFPX >> FP_SHIFT);
|
||||
|
||||
// Pre-clamp destination ranges to screen bounds (eliminates per-pixel screen checks)
|
||||
int clampYMax = ctx->dstHeight;
|
||||
if (ctx->screenHeight - cfgY < clampYMax) clampYMax = ctx->screenHeight - cfgY;
|
||||
if (dstYStart < -cfgY) dstYStart = -cfgY;
|
||||
if (dstYEnd > clampYMax) dstYEnd = clampYMax;
|
||||
|
||||
int clampXMax = ctx->dstWidth;
|
||||
if (ctx->screenWidth - cfgX < clampXMax) clampXMax = ctx->screenWidth - cfgX;
|
||||
if (dstXStart < -cfgX) dstXStart = -cfgX;
|
||||
if (dstXEnd > clampXMax) dstXEnd = clampXMax;
|
||||
|
||||
if (dstYStart >= dstYEnd || dstXStart >= dstXEnd) return 1;
|
||||
|
||||
// Pre-compute orientation and render-mode state once per callback invocation
|
||||
DirectPixelWriter pw;
|
||||
pw.init(renderer);
|
||||
|
||||
DirectCacheWriter cw;
|
||||
if (caching) {
|
||||
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
|
||||
}
|
||||
|
||||
// === 1:1 fast path: no scaling math ===
|
||||
if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) {
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||
const uint8_t* row = &pixels[(dstY - blockY) * stride];
|
||||
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
uint8_t gray = row[dstX - blockX];
|
||||
uint8_t dithered;
|
||||
if (useDithering) {
|
||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
dithered = gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
}
|
||||
pw.writePixel(outX, dithered);
|
||||
if (caching) cw.writePixel(outX, dithered);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// === Bilinear interpolation (upscale: fineScale > 1.0) ===
|
||||
// Smooths block boundaries that would otherwise create visible banding
|
||||
// on progressive JPEG DC-only decode (1/8 resolution upscaled to target).
|
||||
if (fineScaleFPX > FP_ONE && fineScaleFPY > FP_ONE) {
|
||||
// Pre-compute safe X range where lx0 and lx0+1 are both in [0, validW-1].
|
||||
// Only the left/right edge pixels (typically 0-2 and 1-8 respectively) need clamping.
|
||||
int safeXStart = (int)(((int64_t)blockX * fineScaleFPX + FP_MASK) >> FP_SHIFT);
|
||||
int safeXEnd = (int)((int64_t)(blockX + validW - 1) * fineScaleFPX >> FP_SHIFT);
|
||||
if (safeXStart < dstXStart) safeXStart = dstXStart;
|
||||
if (safeXEnd > dstXEnd) safeXEnd = dstXEnd;
|
||||
if (safeXStart > safeXEnd) safeXEnd = safeXStart;
|
||||
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||
const int32_t srcFyFP = dstY * invScaleFPY;
|
||||
const int32_t fy = srcFyFP & FP_MASK;
|
||||
const int32_t fyInv = FP_ONE - fy;
|
||||
int ly0 = (srcFyFP >> FP_SHIFT) - blockY;
|
||||
int ly1 = ly0 + 1;
|
||||
if (ly0 < 0) ly0 = 0;
|
||||
if (ly0 >= blockH) ly0 = blockH - 1;
|
||||
if (ly1 >= blockH) ly1 = blockH - 1;
|
||||
|
||||
const uint8_t* row0 = &pixels[ly0 * stride];
|
||||
const uint8_t* row1 = &pixels[ly1 * stride];
|
||||
|
||||
// Left edge (with X boundary clamping)
|
||||
for (int dstX = dstXStart; dstX < safeXStart; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
int lx1 = lx0 + 1;
|
||||
if (lx0 < 0) lx0 = 0;
|
||||
if (lx1 < 0) lx1 = 0;
|
||||
if (lx0 >= validW) lx0 = validW - 1;
|
||||
if (lx1 >= validW) lx1 = validW - 1;
|
||||
|
||||
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
|
||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||
|
||||
uint8_t dithered;
|
||||
if (useDithering) {
|
||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
dithered = gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
}
|
||||
pw.writePixel(outX, dithered);
|
||||
if (caching) cw.writePixel(outX, dithered);
|
||||
}
|
||||
|
||||
// Interior (no X boundary checks — lx0 and lx0+1 guaranteed in bounds)
|
||||
for (int dstX = safeXStart; dstX < safeXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
const int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
|
||||
int top = ((int)row0[lx0] * fxInv + (int)row0[lx0 + 1] * fx) >> FP_SHIFT;
|
||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
|
||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||
|
||||
uint8_t dithered;
|
||||
if (useDithering) {
|
||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
dithered = gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
}
|
||||
pw.writePixel(outX, dithered);
|
||||
if (caching) cw.writePixel(outX, dithered);
|
||||
}
|
||||
|
||||
// Right edge (with X boundary clamping)
|
||||
for (int dstX = safeXEnd; dstX < dstXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
const int32_t fx = srcFxFP & FP_MASK;
|
||||
const int32_t fxInv = FP_ONE - fx;
|
||||
int lx0 = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
int lx1 = lx0 + 1;
|
||||
if (lx0 >= validW) lx0 = validW - 1;
|
||||
if (lx1 >= validW) lx1 = validW - 1;
|
||||
|
||||
int top = ((int)row0[lx0] * fxInv + (int)row0[lx1] * fx) >> FP_SHIFT;
|
||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||
|
||||
uint8_t dithered;
|
||||
if (useDithering) {
|
||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
dithered = gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
}
|
||||
pw.writePixel(outX, dithered);
|
||||
if (caching) cw.writePixel(outX, dithered);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
pw.beginRow(outY);
|
||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||
const int32_t srcFyFP = dstY * invScaleFPY;
|
||||
int ly = (srcFyFP >> FP_SHIFT) - blockY;
|
||||
if (ly < 0) ly = 0;
|
||||
if (ly >= blockH) ly = blockH - 1;
|
||||
const uint8_t* row = &pixels[ly * stride];
|
||||
|
||||
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
||||
const int outX = cfgX + dstX;
|
||||
const int32_t srcFxFP = dstX * invScaleFPX;
|
||||
int lx = (srcFxFP >> FP_SHIFT) - blockX;
|
||||
if (lx < 0) lx = 0;
|
||||
if (lx >= validW) lx = validW - 1;
|
||||
uint8_t gray = row[lx];
|
||||
|
||||
uint8_t dithered;
|
||||
if (useDithering) {
|
||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
dithered = gray / 85;
|
||||
if (dithered > 3) dithered = 3;
|
||||
}
|
||||
pw.writePixel(outX, dithered);
|
||||
if (caching) cw.writePixel(outX, dithered);
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
|
||||
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
|
||||
return false;
|
||||
}
|
||||
|
||||
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
|
||||
if (!jpeg) {
|
||||
LOG_ERR("JPG", "Failed to allocate JPEG decoder for dimensions");
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, nullptr);
|
||||
if (rc != 1) {
|
||||
LOG_ERR("JPG", "Failed to open JPEG for dimensions (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = jpeg->getWidth();
|
||||
out.height = jpeg->getHeight();
|
||||
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
|
||||
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
|
||||
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_JPEG) {
|
||||
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_JPEG);
|
||||
return false;
|
||||
}
|
||||
|
||||
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
|
||||
if (!jpeg) {
|
||||
LOG_ERR("JPG", "Failed to allocate JPEG decoder");
|
||||
return false;
|
||||
}
|
||||
|
||||
JpegContext ctx;
|
||||
ctx.renderer = &renderer;
|
||||
ctx.config = &config;
|
||||
ctx.screenWidth = renderer.getScreenWidth();
|
||||
ctx.screenHeight = renderer.getScreenHeight();
|
||||
|
||||
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback);
|
||||
if (rc != 1) {
|
||||
LOG_ERR("JPG", "Failed to open JPEG (err=%d): %s", jpeg->getLastError(), imagePath.c_str());
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
int srcWidth = jpeg->getWidth();
|
||||
int srcHeight = jpeg->getHeight();
|
||||
|
||||
if (srcWidth <= 0 || srcHeight <= 0) {
|
||||
LOG_ERR("JPG", "Invalid JPEG dimensions: %dx%d", srcWidth, srcHeight);
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateImageDimensions(srcWidth, srcHeight, "JPEG")) {
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isProgressive = jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE;
|
||||
if (isProgressive) {
|
||||
LOG_INF("JPG", "Progressive JPEG detected - decoding DC coefficients only (lower quality)");
|
||||
}
|
||||
|
||||
// Calculate overall target scale
|
||||
float targetScale;
|
||||
int destWidth, destHeight;
|
||||
|
||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||
destWidth = config.maxWidth;
|
||||
destHeight = config.maxHeight;
|
||||
targetScale = (float)destWidth / srcWidth;
|
||||
} else {
|
||||
float scaleX = (config.maxWidth > 0 && srcWidth > config.maxWidth) ? (float)config.maxWidth / srcWidth : 1.0f;
|
||||
float scaleY = (config.maxHeight > 0 && srcHeight > config.maxHeight) ? (float)config.maxHeight / srcHeight : 1.0f;
|
||||
targetScale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (targetScale > 1.0f) targetScale = 1.0f;
|
||||
|
||||
destWidth = (int)(srcWidth * targetScale);
|
||||
destHeight = (int)(srcHeight * targetScale);
|
||||
}
|
||||
|
||||
// Choose JPEGDEC built-in scaling for coarse downscaling.
|
||||
// Progressive JPEGs: JPEGDEC forces JPEG_SCALE_EIGHTH internally (DC-only
|
||||
// decode produces 1/8 resolution). We must match this to avoid the if/else
|
||||
// priority chain in DecodeJPEG selecting a different scale.
|
||||
int jpegScaleOption;
|
||||
int jpegScaleDenom;
|
||||
if (isProgressive) {
|
||||
jpegScaleOption = JPEG_SCALE_EIGHTH;
|
||||
jpegScaleDenom = 8;
|
||||
} else {
|
||||
jpegScaleDenom = chooseJpegScale(targetScale, jpegScaleOption);
|
||||
}
|
||||
|
||||
if (destWidth <= 0 || destHeight <= 0) {
|
||||
LOG_ERR("JPG", "Degenerate output dimensions %dx%d for %s, skipping render", destWidth, destHeight,
|
||||
imagePath.c_str());
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.scaledSrcWidth = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom;
|
||||
ctx.scaledSrcHeight = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom;
|
||||
ctx.dstWidth = destWidth;
|
||||
ctx.dstHeight = destHeight;
|
||||
ctx.fineScaleFPX = (int32_t)((int64_t)destWidth * FP_ONE / ctx.scaledSrcWidth);
|
||||
ctx.invScaleFPX = (int32_t)((int64_t)ctx.scaledSrcWidth * FP_ONE / destWidth);
|
||||
ctx.fineScaleFPY = (int32_t)((int64_t)destHeight * FP_ONE / ctx.scaledSrcHeight);
|
||||
ctx.invScaleFPY = (int32_t)((int64_t)ctx.scaledSrcHeight * FP_ONE / destHeight);
|
||||
|
||||
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f, jpegScale 1/%d, fineScale %.2f)%s", srcWidth, srcHeight, destWidth,
|
||||
destHeight, targetScale, jpegScaleDenom, (float)destWidth / ctx.scaledSrcWidth,
|
||||
isProgressive ? " [progressive]" : "");
|
||||
|
||||
// Set pixel type to 8-bit grayscale (must be after open())
|
||||
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
|
||||
jpeg->setUserPointer(&ctx);
|
||||
|
||||
// Allocate cache buffer using final output dimensions
|
||||
ctx.caching = !config.cachePath.empty();
|
||||
if (ctx.caching) {
|
||||
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
||||
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
|
||||
ctx.caching = false;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned long decodeStart = millis();
|
||||
rc = jpeg->decode(0, 0, jpegScaleOption);
|
||||
unsigned long decodeTime = millis() - decodeStart;
|
||||
|
||||
if (rc != 1) {
|
||||
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
return false;
|
||||
}
|
||||
|
||||
jpeg->close();
|
||||
delete jpeg;
|
||||
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
|
||||
|
||||
// Write cache file if caching was enabled
|
||||
if (ctx.caching) {
|
||||
ctx.cache.writeToFile(config.cachePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||
return FsHelpers::hasJpgExtension(extension);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||
public:
|
||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||
|
||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||
|
||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||
return getDimensionsStatic(imagePath, dims);
|
||||
}
|
||||
|
||||
static bool supportsFormat(const std::string& extension);
|
||||
const char* getFormatName() const override { return "JPEG"; }
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
|
||||
// Packs 4 pixels per byte, MSB first.
|
||||
struct PixelCache {
|
||||
uint8_t* buffer;
|
||||
int width;
|
||||
int height;
|
||||
int bytesPerRow;
|
||||
int originX; // config.x - to convert screen coords to cache coords
|
||||
int originY; // config.y
|
||||
|
||||
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
|
||||
PixelCache(const PixelCache&) = delete;
|
||||
PixelCache& operator=(const PixelCache&) = delete;
|
||||
|
||||
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
|
||||
|
||||
bool allocate(int w, int h, int ox, int oy) {
|
||||
width = w;
|
||||
height = h;
|
||||
originX = ox;
|
||||
originY = oy;
|
||||
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
size_t bufferSize = (size_t)bytesPerRow * h;
|
||||
if (bufferSize > MAX_CACHE_BYTES) {
|
||||
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
|
||||
return false;
|
||||
}
|
||||
buffer = (uint8_t*)malloc(bufferSize);
|
||||
if (buffer) {
|
||||
memset(buffer, 0, bufferSize);
|
||||
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
|
||||
}
|
||||
return buffer != nullptr;
|
||||
}
|
||||
|
||||
void setPixel(int screenX, int screenY, uint8_t value) {
|
||||
if (!buffer) return;
|
||||
int localX = screenX - originX;
|
||||
int localY = screenY - originY;
|
||||
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
|
||||
|
||||
int byteIdx = localY * bytesPerRow + localX / 4;
|
||||
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
|
||||
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
||||
}
|
||||
|
||||
bool writeToFile(const std::string& cachePath) {
|
||||
if (!buffer) return false;
|
||||
|
||||
FsFile cacheFile;
|
||||
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
|
||||
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t w = width;
|
||||
uint16_t h = height;
|
||||
cacheFile.write(&w, 2);
|
||||
cacheFile.write(&h, 2);
|
||||
cacheFile.write(buffer, bytesPerRow * height);
|
||||
cacheFile.close();
|
||||
|
||||
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
~PixelCache() {
|
||||
if (buffer) {
|
||||
free(buffer);
|
||||
buffer = nullptr;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,402 +0,0 @@
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <PNGdec.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
|
||||
#include "DirectPixelWriter.h"
|
||||
#include "DitherUtils.h"
|
||||
#include "PixelCache.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Context struct passed through PNGdec callbacks to avoid global mutable state.
|
||||
// The draw callback receives this via pDraw->pUser (set by png.decode()).
|
||||
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
|
||||
struct PngContext {
|
||||
GfxRenderer* renderer{nullptr};
|
||||
const RenderConfig* config{nullptr};
|
||||
int screenWidth{0};
|
||||
int screenHeight{0};
|
||||
|
||||
// Scaling state
|
||||
float scale{1.f};
|
||||
int srcWidth{0};
|
||||
int srcHeight{0};
|
||||
int dstWidth{0};
|
||||
int dstHeight{0};
|
||||
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
|
||||
|
||||
PixelCache cache;
|
||||
bool caching{false};
|
||||
|
||||
uint8_t* grayLineBuffer{nullptr};
|
||||
};
|
||||
|
||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||
// avoiding the need for global file state.
|
||||
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
||||
FsFile* f = new FsFile();
|
||||
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
|
||||
delete f;
|
||||
return nullptr;
|
||||
}
|
||||
*size = f->size();
|
||||
return f;
|
||||
}
|
||||
|
||||
void pngCloseWithHandle(void* handle) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
||||
if (f) {
|
||||
f->close();
|
||||
delete f;
|
||||
}
|
||||
}
|
||||
|
||||
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return 0;
|
||||
return f->read(pBuf, len);
|
||||
}
|
||||
|
||||
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||
if (!f) return -1;
|
||||
return f->seek(pos);
|
||||
}
|
||||
|
||||
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
|
||||
// We heap-allocate it on demand rather than using a static instance, so this memory
|
||||
// is only consumed while actually decoding/querying PNG images. This is critical on
|
||||
// the ESP32-C3 where total RAM is ~320 KB.
|
||||
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
||||
|
||||
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
|
||||
// and each scanline includes a leading filter byte.
|
||||
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
|
||||
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
|
||||
// PNGdec can overrun its internal buffer before our draw callback executes.
|
||||
int bytesPerPixelFromType(int pixelType) {
|
||||
switch (pixelType) {
|
||||
case PNG_PIXEL_TRUECOLOR:
|
||||
return 3;
|
||||
case PNG_PIXEL_GRAY_ALPHA:
|
||||
return 2;
|
||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||
return 4;
|
||||
case PNG_PIXEL_GRAYSCALE:
|
||||
case PNG_PIXEL_INDEXED:
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
|
||||
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
|
||||
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
|
||||
return ((pitch + 1) * 2) + 32;
|
||||
}
|
||||
|
||||
// Convert entire source line to grayscale with alpha blending to white background.
|
||||
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
||||
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
||||
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
|
||||
switch (pixelType) {
|
||||
case PNG_PIXEL_GRAYSCALE:
|
||||
memcpy(grayLine, pPixels, width);
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_TRUECOLOR:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &pPixels[x * 3];
|
||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_INDEXED:
|
||||
if (palette) {
|
||||
if (hasAlpha) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t idx = pPixels[x];
|
||||
uint8_t* p = &palette[idx * 3];
|
||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
uint8_t alpha = palette[768 + idx];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
} else {
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &palette[pPixels[x] * 3];
|
||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
memcpy(grayLine, pPixels, width);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_GRAY_ALPHA:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t gray = pPixels[x * 2];
|
||||
uint8_t alpha = pPixels[x * 2 + 1];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||
for (int x = 0; x < width; x++) {
|
||||
uint8_t* p = &pPixels[x * 4];
|
||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||
uint8_t alpha = p[3];
|
||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
memset(grayLine, 128, width);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
|
||||
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
|
||||
|
||||
int srcY = pDraw->y;
|
||||
int srcWidth = ctx->srcWidth;
|
||||
|
||||
// Calculate destination Y with scaling
|
||||
int dstY = (int)(srcY * ctx->scale);
|
||||
|
||||
// Skip if we already rendered this destination row (multiple source rows map to same dest)
|
||||
if (dstY == ctx->lastDstY) return 1;
|
||||
ctx->lastDstY = dstY;
|
||||
|
||||
// Check bounds
|
||||
if (dstY >= ctx->dstHeight) return 1;
|
||||
|
||||
int outY = ctx->config->y + dstY;
|
||||
if (outY >= ctx->screenHeight) return 1;
|
||||
|
||||
// Convert entire source line to grayscale (improves cache locality)
|
||||
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
|
||||
pDraw->iHasAlpha);
|
||||
|
||||
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
|
||||
int dstWidth = ctx->dstWidth;
|
||||
int outXBase = ctx->config->x;
|
||||
int screenWidth = ctx->screenWidth;
|
||||
bool useDithering = ctx->config->useDithering;
|
||||
bool caching = ctx->caching;
|
||||
|
||||
// Pre-compute orientation and render-mode state once per row
|
||||
DirectPixelWriter pw;
|
||||
pw.init(*ctx->renderer);
|
||||
pw.beginRow(outY);
|
||||
|
||||
DirectCacheWriter cw;
|
||||
if (caching) {
|
||||
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
|
||||
cw.beginRow(outY, ctx->config->y);
|
||||
}
|
||||
|
||||
int srcX = 0;
|
||||
int error = 0;
|
||||
|
||||
for (int dstX = 0; dstX < dstWidth; dstX++) {
|
||||
int outX = outXBase + dstX;
|
||||
if (outX < screenWidth) {
|
||||
uint8_t gray = ctx->grayLineBuffer[srcX];
|
||||
|
||||
uint8_t ditheredGray;
|
||||
if (useDithering) {
|
||||
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
||||
} else {
|
||||
ditheredGray = gray / 85;
|
||||
if (ditheredGray > 3) ditheredGray = 3;
|
||||
}
|
||||
pw.writePixel(outX, ditheredGray);
|
||||
if (caching) cw.writePixel(outX, ditheredGray);
|
||||
}
|
||||
|
||||
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
|
||||
error += srcWidth;
|
||||
while (error >= dstWidth) {
|
||||
error -= dstWidth;
|
||||
srcX++;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions");
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||
nullptr);
|
||||
|
||||
if (rc != 0) {
|
||||
LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = png->getWidth();
|
||||
out.height = png->getHeight();
|
||||
|
||||
png->close();
|
||||
delete png;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str());
|
||||
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
LOG_ERR("PNG", "Failed to allocate PNG decoder");
|
||||
return false;
|
||||
}
|
||||
|
||||
PngContext ctx;
|
||||
ctx.renderer = &renderer;
|
||||
ctx.config = &config;
|
||||
ctx.screenWidth = renderer.getScreenWidth();
|
||||
ctx.screenHeight = renderer.getScreenHeight();
|
||||
|
||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||
pngDrawCallback);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
LOG_ERR("PNG", "Failed to open PNG: %d", rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate output dimensions
|
||||
ctx.srcWidth = png->getWidth();
|
||||
ctx.srcHeight = png->getHeight();
|
||||
|
||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||
ctx.dstWidth = config.maxWidth;
|
||||
ctx.dstHeight = config.maxHeight;
|
||||
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
|
||||
} else {
|
||||
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||
float scaleX = (float)config.maxWidth / ctx.srcWidth;
|
||||
float scaleY = (float)config.maxHeight / ctx.srcHeight;
|
||||
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
|
||||
|
||||
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
|
||||
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
|
||||
}
|
||||
ctx.lastDstY = -1; // Reset row tracking
|
||||
|
||||
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
|
||||
ctx.scale, png->getBpp());
|
||||
|
||||
const int pixelType = png->getPixelType();
|
||||
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
|
||||
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
|
||||
LOG_ERR("PNG",
|
||||
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
|
||||
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
|
||||
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (png->getBpp() != 8) {
|
||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||
}
|
||||
|
||||
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
|
||||
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
|
||||
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
|
||||
if (!ctx.grayLineBuffer) {
|
||||
LOG_ERR("PNG", "Failed to allocate gray line buffer");
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allocate cache buffer using SCALED dimensions.
|
||||
// PNG decode is fast enough (~135ms for 400x600) that caching provides minimal benefit
|
||||
// for larger images, while the cache buffer competes with the 44KB PNG decoder for heap.
|
||||
// Skip caching when the buffer would exceed the framebuffer size (48KB).
|
||||
static constexpr size_t PNG_MAX_CACHE_BYTES = 48000;
|
||||
ctx.caching = !config.cachePath.empty();
|
||||
if (ctx.caching) {
|
||||
size_t cacheSize = (size_t)((ctx.dstWidth + 3) / 4) * ctx.dstHeight;
|
||||
if (cacheSize > PNG_MAX_CACHE_BYTES) {
|
||||
LOG_DBG("PNG", "Skipping cache: %zu bytes exceeds PNG limit (%zu)", cacheSize, PNG_MAX_CACHE_BYTES);
|
||||
ctx.caching = false;
|
||||
} else if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
|
||||
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
|
||||
ctx.caching = false;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned long decodeStart = millis();
|
||||
rc = png->decode(&ctx, 0);
|
||||
unsigned long decodeTime = millis() - decodeStart;
|
||||
|
||||
free(ctx.grayLineBuffer);
|
||||
ctx.grayLineBuffer = nullptr;
|
||||
|
||||
if (rc != PNG_SUCCESS) {
|
||||
LOG_ERR("PNG", "Decode failed: %d", rc);
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
png->close();
|
||||
delete png;
|
||||
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
|
||||
|
||||
// Write cache file if caching was enabled and buffer was allocated
|
||||
if (ctx.caching) {
|
||||
ctx.cache.writeToFile(config.cachePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||
return FsHelpers::hasPngExtension(extension);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||
public:
|
||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||
|
||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||
|
||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||
return getDimensionsStatic(imagePath, dims);
|
||||
}
|
||||
|
||||
static bool supportsFormat(const std::string& extension);
|
||||
const char* getFormatName() const override { return "PNG"; }
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user