Compare commits

...
105 Commits
Author SHA1 Message Date
Uri Tauber 6e8dbd7f23 chore: Update version to 1.4.0 (#2283)
Compile Release / build-release (push) Canceled after 0s
2026-06-24 18:30:32 +03:00
Uri Tauber c4b1d9644a fix: correct font size selection (#2410) 2026-06-24 17:01:10 +03:00
Justin Mitchell f6e59aab72 feat: Add displayGrayscaleBase method for differential refresh
(#2334)

Introduces a new display method that prepares the framebuffer as a base
frame for grayscale overlays. On X3 panels, this uses the OEM
differential base waveform (AA-pre-BW) without forcing a resync. Other
panels fall back to normal display with configurable refresh mode.


Dependent upon matching SDK commit to work
2026-06-24 07:53:19 -04:00
Uri Tauber 9ad2da0950 fix: add seven missing hebrew translations (#2409) 2026-06-24 09:34:40 +03:00
Uri TauberandJulia Nguyen 1db1442319 fix: several bookmarks UX improvments (#2372)
## Summary

This PR enhances the EPUB reader's bookmark system with two
complementary improvements: a per-page bookmark indicator icon and
toggle behavior on the existing long-press action.

---

### What Changed

**Bookmark Toggle (was: add-only)**

The long-press Confirm action now toggles bookmarks rather than always
adding. `addBookmark()` checks whether a bookmark with the same xpath
already exists in the in-memory cache:
- If found → removes it and shows "Bookmark removed."
- If not found → adds it and shows "Bookmark added."

A new `STR_BOOKMARK_REMOVED` translation string was added to support the
removal message.

**Bookmark Icon Indicator**

A `BookmarkIcon` is now drawn at the top-right corner of the page
whenever the current page has a bookmark. `updateBookmarkFlag()` is
called at render time to determine whether the current page is
bookmarked.

**In-Memory Bookmark Cache**

Bookmarks are now loaded into `cachedBookmarks` on `onEnter()` rather
than being re-read from disk on every toggle. All subsequent add/remove
operations work against this cache and flush to disk, avoiding redundant
file reads on each bookmark action.

**Faster bookmarks list**

Previously, calculating "page X/Y" for each entry required decompressing
the entire spine item. We now persist `si`/`pc`/`pp` (spine index, page
count, and page progress) in the bookmark JSON when saving, and restore
them when loading. This avoids the expensive `toCrossPoint()` loop in
`onEnter()`, significantly reducing the cost of initializing the
bookmarks list.

---

### Files Changed

- `EpubReaderActivity.cpp` — `addBookmark()` toggle logic,
`updateBookmarkFlag()` (new), icon rendering in `renderContents()`,
cache initialization in `onEnter()`
- `EpubReaderActivity.h` — new fields: `currentPageBookmarked`,
`bookmarkRemoved`, `cachedBookmarks`; new method declaration
`updateBookmarkFlag()`

---

### AI Usage

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

Did you use AI tools to help write this code? _**< PARTIALLY >**_

---------

Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-06-23 14:55:01 -04:00
Leopoldo Pla Sempere 362dcb2a65 feat: Smooth progressive JPEG cover upscales in BMP conversion (#2214) 2026-06-22 13:55:12 -04:00
Julia d1abcc00a2 feat: render grayscale epub images without text aa (#2393) 2026-06-22 17:15:27 +03:00
Zeph 0ce874f8d4 feat: footnote returns to original position on deep sleep to avoid losing reading position (#2394) 2026-06-22 15:28:42 +03:00
kygia 282514f755 fix(epub): flush displaced anchor before overwrite (#2336) (#2382)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)

Fixes footnote links landing in start section instead of the specific
foot note target.

* **What changes are included?**

ChapterHtmlSlimParser.cpp: call flushPendingAnchor() before overwriting
pendingAnchorId. First id got lost due to consecutive non-block elements
carry ids, the first id was lost and the reader had no page to jump to,
so it defaulted to page 0.

## Additional Context

Tested with the epubs attached to to #2336. Need to clear .crosspoint/
cache after flashing so the anchor map gets rebuilt with the fix.

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

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

Did you use AI tools to help write this code? 

Partially assisted with writing documentation.
2026-06-21 16:43:39 -04:00
SurprisedDuck d1e4650e19 fix: don't justify-stretch a leading no-break space (#2185) (#2298) 2026-06-21 16:12:14 +03:00
Justin Mitchell 370f87ea01 feat: add Spacing Modifier Letters range to font presets (#2194) 2026-06-21 09:14:42 +03:00
Leopoldo Pla Sempere 8b9a73a735 chore: update Spanish, Catalan, and Valencian translations (#2384) 2026-06-21 00:26:30 +03:00
Julia 29c69b3d8f fix: automatically connect to wifi for clock sync (#2379)
## Summary

* **What is the goal of this PR?** 
* Make manual clock sync work even when the device is not already
connected to Wi-Fi, so the user can start the sync flow directly from
settings instead of being blocked by connection state.
* **What changes are included?**
* `ClockSyncActivity` now launches the normal Wi-Fi selection flow
before syncing when the device is offline.
* After Wi-Fi selection succeeds, clock sync resumes automatically and
performs the existing forced NTP sync.
* If clock sync had to bring Wi-Fi up, the activity disconnects and uses
the existing silent restart cleanup path to avoid leaving the device in
a fragmented post-Wi-Fi heap state.
* The completion UI now only advertises Back, matching the updated input
handling.

## Additional Context

* Existing behavior is unchanged when Wi-Fi is already connected: the
activity syncs immediately.
* Cancelling Wi-Fi selection exits the clock sync flow.

---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-06-20 22:09:28 +03:00
Matteo Scopel 4b5a84dd81 chore: update the Italian translation (#2376) 2026-06-20 22:08:35 +03:00
muhasandmuhas b9874c8114 fix(lang): [russian] new string (#2374)
Co-authored-by: muhas <mail@muhas.name>
2026-06-19 13:15:15 +03:00
Paul Delestrac b1131795d8 feat: add live font preview pane to font selection screen (#2349)
## Summary
* Adds a live font preview pane to the font selection screen so users
can see how a font looks before committing to it.
* Changes
* A preview pane occupying the top 30% of the font selection screen,
rendering sample pangram text in the previewed font
* A two-step confirm flow: first press (enter button) previews the font,
second press selects it
* Back restores the original font settings, so browsing has no side
effects
* Layout dimensions cached in `onEnter()` to avoid redundant
recalculation between `loop()` and `render()`

## Additional Context
* Preview sample text is hardcoded English; didn't want to use AI for
translation as I would not be able to verify the output in most
languages...
* The preview pane reduces visible list height; this is compensated by
passing the reserved height into `getNumberOfItemsPerPage`

---
### AI Usage
Did you use AI tools to help write this code? **PARTIALLY**
AI use for assisting in coding and in writing the PR description.
2026-06-18 18:16:28 -04:00
Pedro CardosoandClaude Opus 4.8 5e990b3991 fix: prevent progress.bin corruption from interrupted writes (#2275) (#2305)
## Summary

* **What is the goal of this PR?**
Fixes #2275. A book could get stuck reopening on an old page, with
progress no
longer saving and neither "Delete Book Cache" nor "Clear Reading Cache"
able to
fix it. Root cause: `progress.bin` was written truncate-in-place, so an
interrupted write (power loss, or a crash mid-SPI during sleep) left it
with a
broken FAT cluster chain that the firmware could neither rewrite nor
delete —
  recovery required `fsck`/manual deletion on a host PC.

  Confirmed in the SDK: `SDCardManager::openFileForWrite` opens with
`O_RDWR | O_CREAT | O_TRUNC`, so the canonical file is zeroed before the
few
progress bytes are rewritten — exactly the window that corrupts the FAT
chain.

* **What changes are included?**
  * New shared helper `ProgressFile::writeAtomic()`
    (`src/activities/reader/ProgressFile.h`): writes progress to
    `progress.bin.tmp`, flushes and closes it, then `remove`s the old
`progress.bin` and `rename`s the temp into place. An interrupted write
now
only ever damages the throwaway temp; the canonical file is never torn.
  * All three readers route their progress saves through the helper:
EPUB (`EpubReaderUtils.h`), `TxtReaderActivity`, `XtcReaderActivity` —
they
    all shared the identical vulnerable pattern.
* Minor: `EpubReaderUtils::saveProgress` now takes `const Epub&` (clears
a
    cppcheck `constParameterReference` finding).

## Additional Context

* **Crash-safe, not metadata-atomic.** On FAT the replace is `remove` +
`rename`
(two directory ops; SdFat's `rename` won't overwrite, hence
remove-first). A
crash between them leaves *neither* file, which reads as "no saved
progress" on
next launch — a harmless reset to an old page, never a
corrupt/unclearable file.
  The guarantee is that `progress.bin` is never half-written.
* **Prevents, does not repair.** This stops new corruption on healthy
cards. It
cannot fix an already-corrupted `progress.bin` (removing it may itself
fail at
the FAT level) — those still need `fsck`/manual deletion, as in the
issue's
  workaround.
* **Known follow-up (out of scope here):** a crash *while writing the
temp* can
leave an orphan `progress.bin.tmp`. It's harmless and self-healing (the
next
save overwrites it, and it never blocks reading progress), but a
boot-time
  orphan-`.tmp` cleanup would be a tidy follow-up.
* **Focus areas for review:** the close-before-rename ordering in
  `ProgressFile.h` and the remove-before-rename rationale.

## Verification

* `./bin/clang-format-fix` — clean
* `pio check --fail-on-defect low --fail-on-defect medium
--fail-on-defect high` — no defects
* `pio run` — SUCCESS (RAM 30.9%, Flash 78.8%; footprint essentially
unchanged)
* Tested on a **Xteink X4** device: open book, turn pages, sleep/exit,
reopen —
  progress now restores to the navigated page across all three readers
  (EPUB / TXT / XTC).

---

### AI Usage

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:01:56 -04:00
Uri Tauber ff1951c715 fix: correct behaviour for prev/next side buttons (#2373)
## Summary

* **What is the goal of this PR?** fix #2365
2026-06-18 13:13:35 -04:00
Ankit 121c0690b0 fix: use STR_SELECT instead of STR_OPEN in bookmark button hint (#2371) 2026-06-18 19:58:47 +03:00
16b0853654 fix(epub): NFC-normalize EPUB text so NFD diacritics render correctly (#2277)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
Co-authored-by: Julia <julia@uxj.io>
2026-06-18 13:00:01 +03:00
Uri Tauber 7d639cf880 fix: submodule pointer (#2368) 2026-06-17 22:35:12 +03:00
Justin MitchellandUri Tauber 22f3575064 feat: Support for Korean line breaks and glyph spacing (#2288)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-06-17 17:27:03 +03:00
darkbubluandClaude Opus 4.7 d4069aeae5 feat: long-press Confirm launches KOReader sync from EPUB (#1808)
## Summary
- Hold the menu (Confirm) button for ≥1s while reading an EPUB to launch
the existing `KOReaderSyncActivity` directly — replaces the three-step
path (open reader menu → scroll to Sync → confirm) with a single
gesture.
- Reuses `ReaderUtils::GO_HOME_MS` (same 1s threshold used by long-press
Back) and the existing `KOREADER_STORE.hasCredentials()` guard.
- Adds a Controls picker **"Long-press Menu"** (`longPressMenuFunction`,
default **Bookmark**) that **cycles through the available functions**
bound to the long-press gesture: `KOSync → Disabled → Bookmark`. The
field name and `LONG_PRESS_MENU_FUNCTION` enum are intentionally general
so future actions (dictionary lookup, table of contents, etc.) can be
appended without another schema migration. The setting is **not** a
binary toggle.
- Existing menu Sync entry still works — both call sites share one
extracted helper (`launchKOReaderSync`); no logic duplication.
- Short-press Confirm release is gated on duration so the reader menu
does not also open after a long press that *acts*, mirroring the
existing long-press Back pattern.
- **No-credentials fall-through:** `launchKOReaderSync()` now returns
whether it acted. When the function is set to KOSync but no KOReader
credentials are stored, the long-press is a no-op that **falls through
to open the reader menu** — so the menu stays reachable instead of the
hold silently swallowing the gesture. The release is only suppressed
when sync actually launched or surfaced a save error.

## Test plan
- [x] `pio run` succeeds clean for the `default` ESP32-C3 environment.
- [x] On-device, value **KOSync**, valid KOReader credentials:
long-press Confirm ≥1s → sync screen launches; get + update progress
return HTTP 200; release does **not** also open the reader menu; returns
to the same page.
- [x] On-device, value **KOSync**, **no** credentials: long-press
Confirm falls through and **opens the reader menu** (regression fix);
short-press also opens the menu.
- [x] On-device: menu → Sync still launches the same screen (shared
helper) and syncs (200/200).
- [x] On-device, value **Bookmark**: long-press drops a bookmark and
does **not** also open the menu.
- [x] On-device, value **Disabled**: long-press Confirm opens the menu
on release; no sync, no bookmark.
- [x] On-device: long-press Back still goes to the file browser
(unchanged path).
- [x] Heap: epub is released before the TLS handshake (frees ~16 KB);
min free heap stayed ~84 KB during sync, well above the safe floor. No
panics/OOM across the session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:26 +03:00
Mauvis LedfordandJulia 54d5a788a5 feat: add drag-and-drop to upload modal (#2290)
Co-authored-by: Julia <julia@uxj.io>
2026-06-17 16:53:31 +03:00
Ankit f66220a245 fix: enable Shift key for URL keyboard input (#2178) (#2357) 2026-06-17 16:32:36 +03:00
KymAndriyandKymAndriy 2266060e84 fix: Ukrainian translation (#2354)
Co-authored-by: KymAndriy <test@notamail.ua>
2026-06-16 14:02:23 +03:00
Julia 90039d7f4d fix(koreader): resolve element XPath progress against visible body text (#2308) 2026-06-15 22:03:32 -04:00
rafaelmsseandRafael Santos 16eb66d7cf fix: add missing HTML 4.01 named entities (#2352)
Co-authored-by: Rafael Santos <rmsantos@applaudostudios.com>
2026-06-15 22:25:37 +03:00
RoninandUri Tauber 02bab00be6 fix: swap reader menu navigation direction in CCW/inverted (#2321) (#2341)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-06-15 22:19:34 +03:00
Mauvis Ledford 55a56914d7 fix(i18n): localize home empty-state strings (#2342) 2026-06-14 21:40:05 +03:00
Uri Tauber 8634cb8ad3 fix: Restore first-line paragraph indentation (#2320) 2026-06-12 11:55:00 -04:00
Julia 21caebd6e4 fix: use Noto Sans as punctuation fallback when generating downloadable SD fonts (#2331) 2026-06-12 11:54:34 -04:00
Matteo Scopel 6f05e401a1 feat: add the Libre Baskerville font family (#2088)
## Summary

This PR adds the [Libre Baskerville
](https://github.com/impallari/Libre-Baskerville) font family to the
fonts available for download in CrossPoint.


## Additional Context

Baskerville is a classic font family, widely available in some form or
the other in most ereaders and widely used in publishing and in the
academy.

Libre Baskerville is a libre (SIL Open Font License v1) implementation
of it, which makes it possible for CrossPoint to ship it without issues.


![Vita-e-destino_ch30_p1_13pct_99220.bmp](https://github.com/user-attachments/files/28070672/Vita-e-destino_ch30_p1_13pct_99220.bmp)


---

### AI Usage

Did you use AI tools to help write this code? _**NO**_
2026-06-11 19:10:41 +03:00
Zach Nelson f2e3d117dc fix: Hanging indent causes overlapping words (#2324) 2026-06-11 18:23:49 +03:00
Zach Nelson 9202522a39 refactor: Drop redundant self-class prefix on applyDirectionToEntry (#2325)
## Summary

Non-functional cleanup: `ChapterHtmlSlimParser::` prefix is redundant
when calling `applyDirectionToEntry` within `ChapterHtmlSlimParser`
methods.

---

### AI Usage

Did you use AI tools to help write this code? _**NO**_
2026-06-11 10:38:00 -04:00
Uri Tauber 301f1d1a38 fix: Compile Error: Duplicate _order values found (#2323) 2026-06-11 15:18:53 +03:00
Hoang Manh LinhandClaude Opus 4.8 1f83087aa5 feat(fonts): add Vietnamese glyph coverage to the built-in UI font (#2280)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:11:50 +03:00
Hoang Manh LinhandClaude Opus 4.8 68138fffdb feat(i18n): add Vietnamese translation (#2279)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:10:45 +03:00
MartinandUri Tauber eb98735115 feat: Slovak translation add (#2251)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-06-10 18:49:39 +03:00
Uri Tauber b94b58756f fix: german traslation for STR_INVERTED (#2315) 2026-06-10 16:33:54 +03:00
muhas ca6ebc56fb fix(lang): russian.yaml new string (#2309) 2026-06-10 08:10:26 +03:00
Uri Tauber 2ea042a68a fix: skip <span> anchors (#2303)
## Summary

* **What is the goal of this PR?** Prevent heap memory exhaustion caused
by thousands of machine-generated anchor IDs injected by epub converters
like Kobo KePub.
* Fix #2292.

* **What changes are included?**
* **Element-type filter (Layer 1):** Introduced the
`isNonNavigableInlineElement()` function in `ChapterHtmlSlimParser.cpp`
to automatically skip recording IDs on `<span>` elements, as they are
purely inline wrappers used for tracking and lack navigable meaning.
* **Hard cap (Layer 2):** Added the `MAX_ANCHORS_PER_CHAPTER = 1024`
constant to act as a fallback safety net against unbounded heap growth
from unknown future ID-injection patterns on non-span elements.
* **TOC Safety net:** Ensured that IDs matching known Table of Contents
(TOC) entries explicitly bypass both the `<span>` filter and the
1024-anchor cap, guaranteeing that chapter page-break and core
navigation logic are never compromised.


---

### AI Usage

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

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-06-09 08:58:21 -04:00
Lorenzo Santini 9c12b90737 feat: add power button short press for quick access to footnotes (#1658) 2026-06-08 14:45:41 -04:00
Leopoldo Pla Sempere 7f01eab62e docs: refresh cache formats and web server workflows (#2233)
## Summary

* **What is the goal of this PR?** Update project documentation to match
the current master implementation for cache formats, i18n, file
transfer/web server workflows, SD-card fonts, and root user-facing docs.
* **What changes are included?** Refreshes `book.bin`/`section.bin` docs
for v6/v25, updates File Transfer/Calibre/WebDAV/API docs, documents 24
UI languages and JSON language persistence, updates root
README/USER_GUIDE cache and network details, and syncs the tracked
CLAUDE skill doc cache-version notes.

## Additional Context

* Docs-only change. Verified with `git diff --check origin/master..HEAD`
and stale-reference greps for old cache versions, removed i18n APIs, old
WiFi screen wording, and raw `Serial.printf` examples. No firmware build
was run.

---

### AI Usage

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

Did you use AI tools to help write this code? _**NO**_
2026-06-07 15:47:44 -04:00
Zach Nelson 3a1e9f3023 refactor: Dedupe wifi scan in-place without std::map (#2262)
## Summary

`std::map` heap-allocates each node. Avoid using it for wifi SSID
dedupe, and instead just dedupe in-place with the existing `networks`
vector. Removed dead `ipAddress` member in `WifiNetworkInfo` struct.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-06-07 08:27:20 -04:00
SurprisedDuck fad1a801a4 fix: scale SUP/SUB underline to match 50%-scaled glyphs (#2255) 2026-06-06 18:55:47 -04:00
Julia 67936cb3af fix(epub): decode footnote href path before spine lookup (#2271) 2026-06-05 18:38:13 -04:00
Uri Tauber fd5b8078c6 chore: add t5s3 fork (#2268) 2026-06-05 18:50:22 +03:00
Julia bd101b2af8 fix(epub): decode percent-encoded internal asset paths so assets render correctly (#2249)
## Summary

* **What is the goal of this PR?** 
* Fix EPUBs where internal file references are written in URL-style
escaped form, like spaces appearing as `%20`, so the reader can find the
right files instead of treating those references as missing.
* **What changes are included?**
* Added a small shared helper that converts those escaped EPUB-internal
paths back into their normal filenames before we try to look them up.
* Applied that cleanup step across the EPUB parsing flow wherever we
resolve internal references, including cover images, manifest items, TOC
links, spine entries, and inline HTML images.
* Kept the change narrowly focused on EPUB-internal asset resolution
rather than changing broader URL or networking behavior.

## Additional Context

* The user-facing bug here is that some books package their internal
filenames in an escaped form, so a file like `Chapter 1.xhtml` may be
referenced more like `Chapter%201.xhtml`. The reader was treating that
escaped text as the literal filename, which means otherwise-valid books
could lose images, covers, or chapter targets because the lookup no
longer matched the real file inside the EPUB.
* Risk is intentionally low. The helper only rewrites valid `%XX` escape
sequences and leaves malformed input alone, so it should improve
compatibility with escaped filenames without broadening the parser’s
behavior in unrelated cases.

## Local Testing Performed
* This was tested on my device with the user-provided optimized epub
that was not rendering images within the text prior to this fix (cover
image and chapter headers were rendering fine):

[orv_main_baseline.epub.zip](https://github.com/user-attachments/files/28529545/orv_main_baseline.epub.zip)
* This was also tested by the user with a local build and the affected
epub and confirmed to be working

## Steps for Testing
* Try to open the affected epub (linked above) or any epub that has
similar percent-encoding on a build prior to this fix.
* Apply this fix, clear book cache, and re-open the affected book.
* Images should render properly.
---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-06-05 10:31:20 -04:00
Uri Tauber 19d51ec08c fix: german translation for STR_INVERTED (#2269) 2026-06-05 12:22:29 +03:00
Uri Tauber ea231ffd18 Revert "Update german.yaml" (#2267) 2026-06-05 11:22:36 +03:00
Uri Tauber a2d4c6a139 Update german.yaml
Fix #2266
2026-06-05 11:17:22 +03:00
Zach Nelson b5b1f650e2 perf: Minimize string allocations in CSS parsing (#2263)
## Summary

Use `std::string_view` and case-insensitive comparisons to avoid string
allocations during CSS parsing.

**Hot path:** `resolveStyle` (called per HTML start tag during chapter
rendering) now does zero heap allocations. Previously it allocated a
normalized tag string, a vector of class strings, and a composite key
per class. For a chapter with ~2000 tags × 2 classes each, that's ~12
000 small short-lived allocations eliminated per page render — primarily
a heap-fragmentation win on the ESP32-C3's ~380KB RAM.

**Cold path:** CSS load no longer allocates per-rule selector vectors or
per-token strings; `splitOnChar`/`splitWhitespace` are gone, replaced
with callback-based tokenization (`forEachDelimitedToken`).

**Behavioral notes:**
- The selector `unordered_map` now uses an ASCII-case-insensitive
hash/equal. Selectors are stored with their original case rather than
pre-lowercased; the observable lookup result is unchanged.
- `stripTrailingImportant` is now case-insensitive (per CSS spec;
previously matched only lowercase `!important`).

**Cache compatibility:** `CSS_CACHE_VERSION` unchanged. Old caches
(lowercase selectors) load correctly under the new lookup; new caches
will contain verbatim-case selectors — both forms work.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-06-04 18:05:32 -04:00
Eloren1 f04b8aa9a9 fix: apply progress bar offset from top instead of bottom edge (#2250) 2026-06-04 17:54:57 +03:00
Justin Mitchell d9bcef7a58 fix: Replace full-image cache buffer with streaming band buffer to reduce memory usage (#2230) 2026-06-04 10:46:22 -04:00
Uri TauberandJulia a60f31cdd4 fix: Crash on invalid font filename (#2253)
Co-authored-by: Julia <julia@uxj.io>
2026-06-04 10:34:34 -04:00
Uri TauberandJulia f055fdd774 fix: long-press back should move to the start of chapter (#2243)
Co-authored-by: Julia <julia@uxj.io>
2026-06-04 16:35:38 +03:00
Julia bb078bae09 fix: KOReader sync drift when syncing at chapter start (#2245) 2026-06-04 15:23:52 +03:00
Matteo Scopel 2d65808302 chore: small Italian fixes (#2252) 2026-06-03 14:43:43 +03:00
Matheus Martins 6e75e5a6be docs: Fix section numbering and table of contents in USER_GUIDE.md (#2244) 2026-06-02 22:12:00 +03:00
Uri Tauber db94a86fba fix: Skip Underline Calculations During Font Cache Scan Pass (#2237)
## Summary

This PR optimizes the text rendering process by skipping underline style
calculations and measurements during the initial font cache scanning
phase. This prevents excessive and unnecessary SD card reads on pages
with heavy use of underlines (e.g., Table of Contents pages).

### **The Problem**

During the first rendering pass (the font cache scan pass used to
collect text for prewarming), `GfxRenderer::drawText()` early-returns
after recording text as expected. However, `TextBlock::render()`
continued past this point to execute the underline decoration logic.

Because underline calculation calls `getTextWidth()` and
`getTextAdvanceX()`, it triggered immediate glyph lookups via
`EpdFont::getGlyph()`. Since the SD card font had not been prewarmed yet
at this stage, the lookups fell back to the `glyphMissHandler`,
resulting in hundreds of individual, slow SD card reads into a limited
8-slot ring buffer.

### **The Fix**

1. **Exposed Scan State:** Added `GfxRenderer::isFontCacheScanning()` to
safely check if the font cache manager is currently in
text-collection/scan mode.
2. **Bypassed Underline Logic:** Modified `TextBlock::render()` to check
this state and skip underline measurement and drawing entirely while
scanning is active.

> [!NOTE]
> The text itself is still properly captured for prewarming via
`drawText()`. The underlines will be safely and efficiently calculated
and drawn during the actual render pass after the fonts have been
completely prewarmed.

---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-06-02 09:47:24 -04:00
Uri Tauber b12839d1d4 chore: Move opendyslexic from flash to SD (#2231) 2026-06-01 13:49:57 -04:00
Justin Mitchell 50e4d550fb Fix ghosting on pages following images in grayscale (#2226) 2026-05-31 23:33:26 -04:00
Julia 03f73fadc7 fix: avoid zip-wide css scan for large epubs (#2213)
## Summary

* **What is the goal of this PR?** 
* This PR ports over Crossink's handling of large EPUBs that helps
prevent crashes during open after the book metadata cache is built.

* **What changes are included?**
* Removes the post-indexing ZIP-wide CSS discovery pass that built an
in-memory map of every ZIP entry.
* Reuses `content.opf` parsing to collect declared CSS files without
writing spine entries again.
* Temporarily releases the loaded book metadata cache while rebuilding
CSS for cached books.
* Parses CSS before reloading `book.bin` after a fresh cache build,
leaving more heap available during CSS rule parsing.

## Additional Context

* User reported their EPUB opening fine on Crossink but would crash on
Crosspoint. Verified this claim on my own devices.
* The crash this addresses happened after `book.bin` was successfully
built, when CSS discovery allocated a large `unordered_map` for ~3k EPUB
ZIP entries.
* Tradeoff: CSS files not declared in `content.opf` are no longer
discovered by scanning the full ZIP. This avoids the high-risk memory
allocation but improperly formatted EPUBs (ones that don't declare their
CSS styles in `content.opf` will render without styling and fallback to
inline styles.
* User provided epub that was crashing prior to this change:
https://www.mediafire.com/file/g57ea4mj13iunvh/Quang+%C3%82m+Chi+Ngo%E1%BA%A1i+-+Nh%C4%A9+C%C4%83n.epub/file

---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-05-31 18:31:06 -04:00
Wilhelm Schuster 0d64980d25 fix: update German translation (#2224) 2026-06-01 00:51:52 +03:00
Nathanael Maher 4fe80ba7a5 fix: bookmark percentage always 0% and page number starts at 0 (#2188)
## Summary

* Fix the issue described in #2176 where bookmark percentages are always
0%
* Also fixed the page number rendering to +1 because they are 0 index
based, but should be rendered starting from 1

---

### AI Usage

Did you use AI tools to help write this code? _**< NO >**_
2026-05-31 17:37:21 -04:00
Wilhelm Schuster b229428a17 chore: remove unused sleep timer interval strings (#2223) 2026-06-01 00:26:17 +03:00
Matteo Scopel 36d0e52e4b chore: new Italian fixes (#2218) 2026-05-31 17:39:15 +03:00
Uri Tauber 94c0ba466c feat: Hebrew localization (#2068) 2026-05-31 16:57:51 +03:00
muhas da2e4eabb0 fix(lang): Russian translate for new strings (#2217) 2026-05-31 16:05:47 +03:00
Matteo Scopel 3392b3e35a chore: update the Italian translation (#2207) 2026-05-30 23:42:19 +03:00
Matheus Martins 52e7446757 fix(docs): Section numbering in USER_GUIDE.md (#2195) 2026-05-30 23:34:44 +03:00
Jeremy Klein d83605e003 docs: add Claude Code contributor skills for firmware code quality (#2212) 2026-05-30 23:30:18 +03:00
WuTofuandJulia ebf0413fe1 feat: add custom sleep timer picker (#2206)
## Summary

* **What is the goal of this PR?**
Mainly fixes #2196, but also allows users to set a sleep time of 1~30
min or to never sleep.

* **What changes are included?**

1. Revert changes from #1948 and #2137 (Sorry @Uri-Tauber)
2. Cherrypick changes from
https://github.com/uxjulia/CrossInk/commit/0cb91c3dd1c63f0559d4392fadc4a8340af5bdde
and
https://github.com/uxjulia/CrossInk/commit/4e6bd634b6ede51ff1a7107ba3e8efcd152bc6ee
for a timeout interval picker UI, the custom sleep time feature, and
migrate the existing setting in v1.3.0, `sleepTimeout`, to
`sleepTimeoutMinutes` (Thanks! @uxjulia)
3. Add a "Never" at the far right of the timeout interval picker

---

### AI Usage

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

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

---------

Co-authored-by: Julia <julia@uxj.io>
2026-05-30 08:28:07 -04:00
Julia 66d9e4403a fix: redesign X3's UTC offset picker so it is easier to use (#2205)
## Summary

* **What is the goal of this PR?**
Improve the X3 clock UTC offset picker so it is clearer which part of
the offset will change when the user presses up/down.

* **What changes are included?**
Replaces the single underlined `UTC +/- H:MM` string with separate
editable fields for the sign, hour, and minutes.
The editable fields are spaced out, outlined, and the currently selected
field is highlighted with a light gray background.

## Additional Context

* This is a render-only UI change in `ClockOffsetActivity`; it does not
change how UTC offsets are stored, clamped, saved, or applied.
* The selected field still cycles with Confirm, and up/down still
adjusts the active sign/hour/minute value.

Screenshot of **current design** showing the sign (+/-) highlighted:
<img width="264" height="396" alt="old utc offset"
src="https://github.com/user-attachments/assets/c88d69ea-089c-47da-b5b3-ed0ebda5b112"
/>


Screenshot of **new design** showing the sign (+/-) highlighted:
<img width="264" height="396" alt="utc offset fix"
src="https://github.com/user-attachments/assets/f51da8b3-9aab-4f48-a9d1-21c828c01499"
/>

---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-05-29 19:37:38 -04:00
Leopoldo Pla Sempere 174a24ba42 fix: complete Spanish, Catalan and Valencian strings (#2203) 2026-05-29 16:09:14 +03:00
Stefan Blixten Karlsson 9897025ded fix: swedish translation for strings added by #1337 and #2105 (#2197) 2026-05-29 11:13:57 +03:00
Uri TauberandZach Nelson f5bc554ae7 feat: add RTL support in epub and txt readers (#1700)
Co-authored-by: Zach Nelson <zach@zdnelson.com>
2026-05-29 03:25:17 -04:00
Angad Kandhari cc2079a578 docs: sync book.bin/section.bin version refs to source (#2169) 2026-05-29 03:07:57 -04:00
Djef0o 75a35f0578 chore: Update font size translation in French YAML (#2187) 2026-05-29 00:39:52 +03:00
Jeremy Klein f872f1f549 fix(docs): Remove fork with upstream attribution issues. (#2177)
Due to attribution issues with jpirnay's crosspoint fork, I am removing
it from our blessed list of forks.

I will not tolerate stolen code.
2026-05-27 22:21:10 -04:00
Justin Mitchell 02573dc592 chore: bump open-x4-sdk to 344c479 (restore X3 4-level grayscale LUT) (#2174) 2026-05-27 18:56:59 -04:00
muhasandmuhas dfefbfe9b5 feat(lang): Russian translate for new string (#2173)
Co-authored-by: muhas <mail@muhas.name>
2026-05-28 00:22:51 +03:00
36a3a0cc3a feat: epub bookmarks (#1337)
Co-authored-by: vedi0boy <nate@origin8publishing.com>
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-05-27 15:52:01 -04:00
Uri Tauber bbb3e06eb1 fix: BOOK_CACHE_VERSION jump (#2161) 2026-05-26 22:13:09 +03:00
Vadim KaushanandClaude Sonnet 4.6 213972badc fix: navigate to TOC anchor when selecting sub-chapters (#1981)
Chapter selection previously only used the spine index, causing
navigation to always land on page 0 of the spine item. Sub-chapters that
share a spine file but differ by anchor (e.g. `chapter.xhtml#sec2`) were
silently ignored. Now the TOC anchor is passed through `ChapterResult`
and applied via the existing `pendingAnchor` mechanism.

## Summary

* This PR implements navigation to sub-chapters which didn't work
correctly previously. If a sub-chapter of the current top-level chapter
was selected, nothing happened. If a sub-chapter of another top-level
chapter was selected, reader switched to the beginning of the top-level
chapter.
* In addition, chapters now always start from a new page. This fixes
anchor to page calculation for the cases when the actual chapter content
doesn't fit on the page where the corresponding ToC anchor was found.

## Additional Context

* I might misuse `pendingAnchor` here which was previously used for
footnote navigation, please double check. I'm open to suggestions for
improvements.
* Note that the chapter selected by default when
`EpubReaderChapterSelectionActivity` opens is still wrong. I'm going to
fix this separately. This PR addresses only navigation to the selected
chapter.
* I tested this PR on my X4 and verified that navigation to a different
sub-chapter works correctly, both inside and outside the current spine.
* Some of the changes were borrowed from
https://github.com/crosspoint-reader/crosspoint-reader/pull/1455
---

### AI Usage

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:14:32 -05:00
c5e861d71c feat: <sup> and <sub> support (#2131)
## Summary

* **What is the goal of this PR?** Support for `<sup>` and `<sub>` tags.

## Additional Context

This isn't my work, but @jpirnay 's (missing you here, man!). I migrated
his work from
https://github.com/jpirnay/crosspoint-reader/commit/bcd8c32cf26447ccc792cfedd2fdbfce4fee5210
with some micro-optimizations.

Screenshots: 

[Subscript-and-Superscript-Tests_ch2_p1_10pct_55632.bmp](https://github.com/user-attachments/files/28196936/Subscript-and-Superscript-Tests_ch2_p1_10pct_55632.bmp)

[Subscript-and-Superscript-Tests_ch3_p1_21pct_77473.bmp](https://github.com/user-attachments/files/28196937/Subscript-and-Superscript-Tests_ch3_p1_21pct_77473.bmp)


---

### AI Usage

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

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

---------

Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: Julia <julia@uxj.io>
2026-05-26 12:38:40 -04:00
Zach Nelson 34e923d722 chore: Pin PNGdec lib_dep to 1.1.6 (#2154) 2026-05-26 01:06:24 -04:00
Jacob Latonis f4e7eaa198 feat: implement CSS enumeration through OPF directory when missing from manifest (#2148) 2026-05-25 21:23:19 -04:00
Zach Nelson 94d4b0c7bb ci: Cache PlatformIO packages between runs (#2142) 2026-05-25 17:03:10 -07:00
Zach Nelson 6dbfc29c7e test: Migrate unit tests to gtest, integrate with CI (#2144) 2026-05-25 15:51:53 -07:00
Jeremy Klein 4ee406897b feat: tiled grayscale rendering to drop the storeBwBuffer peak (#2106)
Tiled grayscale rendering to drop the storeBwBuffer peak largest
contiguous free block (the value that actually drives OOM on the C3)
from ~114 KB to ~82-90 KB.

This renders each grayscale plane band-by-band into a small (~8 KB)
scratch and
streams each band straight to controller RAM (community-sdk
writeGrayscalePlaneStrip), leaving the BW framebuffer intact. No save,
no
restore; controller RAM is re-synced for the next differential turn
directly
from the live framebuffer.

Three writers honor the active band target so per-band re-rendering
stays cheap
and correct:

- drawPixel (text) redirects writes to the band scratch and clips to it.
- renderCharImpl skips glyphs whose physical y-extent is outside the
band before
the bitmap decode (glyphIntersectsStrip), so the per-band re-render
doesn't
  pay N x glyph decode.
- DirectPixelWriter (images) writes the band scratch via getWriteTarget
instead
  of the framebuffer. Without this, image pixels wrote the live BW frame
directly and cleanup re-synced that corruption, leaving thin outlines
after
  navigating away from an image.

Controller specifics live in the SDK (X4 setRamArea windowing, X3 PTL);
the
reader checks supportsStripGrayscale() and is otherwise
controller-agnostic.

Measured on hardware (X4 and X3, text and images, visually correct):

- Grayscale scratch ~8 KB vs ~50 KB save; largest contiguous free block
held at
  full size during grayscale instead of dropping ~25-32 KB.
- X4 text page about +25 ms/page; X3 page time is dominated by its
intrinsic
  grayscale refresh, not tiling.

Depends on community-sdk #13 (the writeGrayscalePlaneStrip API). The
submodule
bump here points at that branch, so until #13 merges the submodule won't
resolve
from upstream and CI will fail there; keeping this a draft until then.
Will
rebase onto master and re-point the submodule to the merged SDK commit
once #13
lands.

Did you use AI tools to help write this code? partial
2026-05-25 18:03:01 -04:00
Joseph DiGiovanni 67973166e3 feat: Allow disabling side buttons in reader (#2105) 2026-05-25 18:00:27 -04:00
Jason HuebelandUri Tauber 50bc9325a4 docs: update USER_GUIDE.md for v1.1.0–v1.3.0 features (#2134)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-05-26 00:00:00 +03:00
WuTofu d736e52bdd fix: clear cache when deleting folders in FileBrowserActivity (#1892) 2026-05-25 16:47:35 -04:00
OrbisAI Security 91d17d9f48 perf: right-size (#2093) 2026-05-25 16:46:49 -04:00
Leopoldo Pla Sempere d5c28fa1f1 fix: refresh Catalan, add Valencian locale, and complete Spanish clock strings (#2063) 2026-05-25 16:41:48 -04:00
Leopoldo Pla Sempere f69650fb86 fix: validate OPF cover items as images (#2062) 2026-05-25 16:40:41 -04:00
Boris Faure e3298c6e43 docs: update presets on sd-card-fonts (#2110) 2026-05-25 16:31:38 -04:00
Chun Ming Lee 56ba9e4337 feat: add image auto-cropping in web epub optimizer (#2139) 2026-05-25 16:31:05 -04:00
Stefan Blixten Karlsson b56d3d853d fix: update Swedish translations and remove unused strings (#2090) 2026-05-25 16:30:22 -04:00
Zach Nelson e9120888fa refactor: Drop FsFile alias, use HalFile in downstream code (#2141) 2026-05-25 16:26:54 -04:00
Eloren1 d7797baff2 fix: Fill the gap under the screen for the progress bar (#2138) 2026-05-25 15:10:45 -04:00
Zach Nelson b53ac3d52c chore: Minor cleanup flagged by newer gcc (#2140)
## Summary

Minor cleanup flagged by newer gcc:
- Removed unused variables
- Removed unimplemented function declaration
- `static` -> `inline` to avoid per-TU duplication

---

### AI Usage

Did you use AI tools to help write this code? _**NO**_
2026-05-25 11:39:28 -05:00
Uri Tauber 38210be820 fix: SLEEP_TIMEOUT enum mismatch (#2137)
## Summary

* **What is the goal of this PR?** fixes #2132.

---

### AI Usage

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

Did you use AI tools to help write this code? _**< NO >**_
2026-05-25 09:51:52 -05:00
251 changed files with 17629 additions and 57412 deletions
+33
View File
@@ -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.
+65
View File
@@ -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.
+54
View File
@@ -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."
+42
View File
@@ -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:
+7
View File
@@ -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
+7
View File
@@ -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
+4
View File
@@ -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/
+8 -10
View File
@@ -152,20 +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 includes `<HalStorage.h>`, which transparently `using FsFile = HalFile;`; 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` directly. **Never** define `HAL_STORAGE_IMPL` outside `HalStorage.cpp`; that disables the `FsFile -> HalFile` typedef and you'll get a raw SdFat handle that bypasses the mutex.
- If you're storing a raw `FsFile` in a place that won't transitively include `<HalStorage.h>` (rare), include the header explicitly so the typedef applies.
- `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.
---
@@ -458,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
@@ -898,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
@@ -909,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 {
@@ -920,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.
+12 -11
View File
@@ -10,7 +10,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
## 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`.
@@ -29,21 +29,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.
@@ -211,7 +207,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
@@ -220,13 +216,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).
@@ -250,8 +251,6 @@ One of the best things about open source is that anyone can take the code in a d
- [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.
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
- [inx](https://github.com/obijuankenobiii/inx) — Completely reimagines the user interface with tabbed navigation.
@@ -260,6 +259,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.
+255 -57
View File
@@ -21,24 +21,30 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [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)
- [3.7 Sleep Screen](#37-sleep-screen)
- [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)
- [System Navigation](#system-navigation)
- [Supported Languages](#supported-languages)
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
- [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. 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 +53,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 +90,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,23 +103,37 @@ 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.
2. On the device: File Transfer -> Calibre Wireless, then join a network.
3. Make sure your computer is on the same Wi-Fi network.
4. In Calibre, click "Send to device" to transfer books.
### 3.6 Settings
@@ -121,96 +143,152 @@ 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
- **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
- **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 +302,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 +344,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 +399,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 +421,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 +467,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 +498,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 +509,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).
+39 -22
View File
@@ -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[open-x4-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,6 +195,7 @@ 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 open-x4-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)
+153 -87
View File
@@ -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")]];
u32 lutOffset [[comment("Offset to lookup tables")]];
u16 spineCount;
u16 tocCount;
// Metadata section
Metadata metadata [[comment("Book metadata")]];
Metadata metadata;
// Validate LUT offset alignment
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")]];
u32 spineLut[spineCount] [[comment("Spine entry offsets")]];
u32 tocLut[tocCount] [[comment("TOC entry offsets")]];
// Data Entries
SpineEntry spines[spineCount] [[comment("Spine entries (reading order)")]];
TocEntry toc[tocCount] [[comment("Table of contents entries")]];
SpineEntry spines[spineCount];
TocEntry toc[tocCount];
};
// === File Parsing ===
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,33 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 24
### Version 25
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 25 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
ImHex pattern:
```c++
import std.mem;
import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 24
#define EXPECTED_VERSION 25
#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,44 +130,79 @@ fn format_string(String s) {
return s.data;
};
// === Page Structure ===
enum PageElementTag : u8 {
PageLine = 1,
PageImage = 2,
PageHorizontalRule = 3
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;
String words[wordCount];
s16 wordXPos[wordCount];
WordStyle wordStyle[wordCount];
u8 hasFocus;
if (hasFocus != 0) {
u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]];
u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]];
}
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;
String imagePath;
s16 width;
s16 height;
ImageBlock image;
};
struct PageHorizontalRule {
@@ -179,63 +213,95 @@ struct PageHorizontalRule {
};
struct PageElement {
u8 pageElementType;
if (pageElementType == 1) {
PageElementTag pageElementType;
if (pageElementType == TAG_PageLine) {
PageLine pageLine [[inline]];
} else if (pageElementType == 2) {
} else if (pageElementType == TAG_PageImage) {
PageImage pageImage [[inline]];
} else if (pageElementType == 3) {
} 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;
u32 pageLutOffset;
u32 anchorMapOffset;
u32 paragraphLutOffset;
u32 listItemLutOffset;
Page page[pageCount];
Page pages[pageCount];
// Validate LUT offset alignment
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));
}
// Lookup Tables
u32 lut[pageCount];
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;
}
};
// === 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));
}
+1 -1
View File
@@ -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
View File
@@ -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

+29 -8
View File
@@ -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,15 +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+0020U+007E (Basic Latin) |
| `latin1` | U+0080U+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+2000U+206F) |
| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth |
| `hangul` | Korean Hangul syllables |
| `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 built-in Bookerly coverage exactly |
| `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
View File
@@ -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
View File
@@ -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
+399 -231
View File
@@ -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,259 +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.
```bash
curl -X POST -d "path=/Books/old.epub&name=new.epub" http://crosspoint.local/rename
```
Form parameters:
| Parameter | Required | Description |
|-----------|----------|-------------|
| `path` | Yes | Existing file path |
| `name` | Yes | New file name, not a path |
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
```
Form parameters:
| Parameter | Required | Description |
|-----------|----------|-------------|
| `path` | Yes | Existing file path |
| `dest` | Yes | Existing destination folder |
Only files can be moved through this endpoint. The old EPUB cache path is
cleared before the move.
### `POST /delete`
Deletes one or more files or empty folders.
| 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 one or more files or empty folders from the SD card.
**Request:**
```bash
# Delete a file
curl -X POST -d "path=/Books/mybook.epub" http://crosspoint.local/delete
# Delete an empty folder
curl -X POST -d "path=/OldFolder" http://crosspoint.local/delete
# Delete multiple items
curl -X POST -d 'paths=["/Books/old.epub","/OldFolder"]' http://crosspoint.local/delete
```
**Form Parameters:**
Form parameters:
| Parameter | Required | Default | Description |
| --------- | -------- | ------- | ----------- |
| `path` | Yes, unless `paths` is provided | - | Path to one item to delete |
| `paths` | Yes, unless `path` is provided | - | JSON array of paths to delete |
| 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:
**Response (200 OK):**
```text
All items deleted successfully
Applied 2 setting(s)
```
**Error Responses:**
## Font Management API
| Status | Body | Cause |
| ------ | ------------------------------------------- | ---------------------------------- |
| 400 | `Missing "path" or "paths" argument` | Neither parameter was provided |
| 400 | `Provide either 'path' or 'paths', not both` | Both delete parameters were sent |
| 400 | `Invalid paths format` | `paths` was not valid JSON |
| 400 | `No paths provided` | `paths` was an empty JSON array |
| 500 | `Failed to delete some items: ...` | One or more paths could not be deleted |
### `GET /api/fonts`
**Protected Items:**
- Files/folders starting with `.`
- `System Volume Information`
- `XTCache`
Lists installed SD-card font families.
---
## WebSocket Endpoint
### 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 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
View File
@@ -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)
+3
View File
@@ -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;
}
+14 -1
View File
@@ -3,7 +3,20 @@
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
};
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
+87 -47
View File
@@ -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;
@@ -1104,7 +1144,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;
@@ -1257,7 +1297,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++) {
@@ -1277,7 +1317,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;
+11 -3
View File
@@ -58,9 +58,9 @@ class SdCardFont {
// 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 +140,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
+5 -9
View File
@@ -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());
+5 -5
View File
@@ -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_; };
+58 -4
View File
@@ -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));
+1
View File
@@ -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;
};
-16
View File
@@ -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/
@@ -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.
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
-36
View File
@@ -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",
+12
View File
@@ -46,6 +46,7 @@ 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:
@@ -186,12 +187,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 +333,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(","))
+12 -13
View File
@@ -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..."
+1 -1
View File
@@ -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))
+44 -7
View File
@@ -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)],
@@ -61,7 +63,7 @@ INTERVAL_PRESETS = {
# Composite preset for English-language literary fiction including scifi/popsci.
# Greek for physics terms, math operators, geometric shapes, uncommon
# dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats.
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
"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),
@@ -517,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
@@ -530,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:
@@ -540,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.
@@ -551,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
@@ -762,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
@@ -775,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
@@ -891,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()
@@ -912,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
@@ -979,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)
+22
View File
@@ -122,6 +122,16 @@ families:
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"}
# ── Sans-serif ─────────────────────────────────────────────────────────
@@ -198,6 +208,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
+70 -31
View File
@@ -5,6 +5,7 @@
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <Utf8.h>
#include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h"
@@ -44,7 +45,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
return true;
}
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const bool writeSpineEntries) {
std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) {
LOG_ERR("EBP", "Could not find content.opf in zip");
@@ -61,7 +62,8 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
return false;
}
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize,
writeSpineEntries ? bookMetadataCache.get() : nullptr);
if (!opfParser.setup()) {
LOG_ERR("EBP", "Could not setup content.opf parser");
return false;
@@ -72,8 +74,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
return false;
}
// Grab data from opfParser into epub
bookMetadata.title = opfParser.title;
// Grab data from opfParser into epub. Normalize titles to NFC so NFD (combining
// mark) text renders correctly — the device fonts have no mark positioning.
bookMetadata.title = utf8ComposeNfc(opfParser.title);
bookMetadata.author = opfParser.author;
bookMetadata.language = opfParser.language;
bookMetadata.coverItemHref = opfParser.coverItemHref;
@@ -104,8 +107,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
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)) {
// Cover BMP generation supports JPG/PNG only; skip GIF so an unsupported wrapper image
// does not block a later supported cover reference.
if (FsHelpers::hasPngExtension(ref) || FsHelpers::hasJpgExtension(ref)) {
imageRef = ref;
break;
}
@@ -116,7 +120,7 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
}
if (!imageRef.empty()) {
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
bookMetadata.coverItemHref = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(coverPageBase + imageRef));
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
}
}
@@ -150,7 +154,7 @@ bool Epub::parseTocNcxFile() const {
LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
FsFile tempNcxFile;
HalFile tempNcxFile;
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
@@ -206,7 +210,7 @@ bool Epub::parseTocNavFile() const {
LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile;
HalFile tempNavFile;
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
return false;
}
@@ -254,6 +258,30 @@ bool Epub::parseTocNavFile() const {
return true;
}
void Epub::discoverCssFilesFromZip() {
const std::string& opfDir = contentBasePath;
ZipFile zf(filepath);
if (!zf.enumerateFilePaths([&](std::string_view filePath) {
if (!opfDir.empty() && filePath.find(opfDir) != 0) {
return;
}
if (!FsHelpers::hasCssExtension(filePath)) {
return;
}
if (std::find(cssFiles.begin(), cssFiles.end(), filePath) != cssFiles.end()) {
return;
}
LOG_DBG("EBP", "Discovered CSS file via ZIP enumeration: %.*s", (int)filePath.size(), filePath.data());
cssFiles.push_back(std::string{filePath});
})) {
LOG_ERR("EBP", "Failed to enumerate ZIP file paths for CSS discovery");
}
}
void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed)
// Larger files risk memory exhaustion on ESP32
@@ -297,7 +325,7 @@ void Epub::parseCssFiles() const {
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
HalFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
@@ -328,9 +356,9 @@ void Epub::parseCssFiles() const {
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());
cssParser->clear();
}
// load in the meta data for the epub file
@@ -350,11 +378,20 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
cssParser->deleteCache();
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
BookMetadataCache::BookMetadata cachedMetadata = bookMetadataCache->coreMetadata;
if (!parseContentOpf(cachedMetadata, /*writeSpineEntries=*/false)) {
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
} else {
discoverCssFilesFromZip();
}
bookMetadataCache.reset();
parseCssFiles();
bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!bookMetadataCache->load()) {
LOG_ERR("EBP", "Failed to reload cache after CSS rebuild");
return false;
}
// Invalidate section caches so they are rebuilt with the new CSS
Storage.removeDir((cachePath + "/sections").c_str());
}
@@ -391,6 +428,7 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
LOG_ERR("EBP", "Could not parse content.opf");
return false;
}
discoverCssFilesFromZip();
if (!bookMetadataCache->endContentOpfPass()) {
LOG_ERR("EBP", "Could not end writing content.opf pass");
return false;
@@ -448,6 +486,13 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
}
if (!skipLoadingCss) {
// Parse CSS before reloading book.bin to leave more heap for CSS rule-table growth.
bookMetadataCache.reset();
parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
}
// Reload the cache from disk so it's in the correct state
bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!bookMetadataCache->load()) {
@@ -455,12 +500,6 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
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;
}
@@ -545,7 +584,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
HalFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
@@ -557,7 +596,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
FsFile coverBmp;
HalFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
return false;
}
@@ -579,7 +618,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
HalFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
@@ -591,7 +630,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
FsFile coverBmp;
HalFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
return false;
}
@@ -634,7 +673,7 @@ bool Epub::generateThumbBmp(int height) const {
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
HalFile coverJpg;
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
@@ -646,7 +685,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
FsFile thumbBmp;
HalFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
return false;
}
@@ -671,7 +710,7 @@ bool Epub::generateThumbBmp(int height) const {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
HalFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
@@ -683,7 +722,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
FsFile thumbBmp;
HalFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
return false;
}
@@ -707,7 +746,7 @@ bool Epub::generateThumbBmp(int height) const {
}
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
HalFile thumbBmp;
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
return false;
}
@@ -863,10 +902,10 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
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);
// Split before decoding so escaped '#' characters in filenames stay part of the path.
const size_t hashPos = href.find('#');
const std::string rawTarget = hashPos != std::string::npos ? href.substr(0, hashPos) : href;
const std::string target = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(rawTarget));
// Same-file reference (anchor-only)
if (target.empty()) return -1;
+2 -1
View File
@@ -31,9 +31,10 @@ class Epub {
std::vector<std::string> cssFiles;
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true);
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
void discoverCssFilesFromZip();
void parseCssFiles() const;
public:
+9 -6
View File
@@ -2,6 +2,7 @@
#include <Logging.h>
#include <Serialization.h>
#include <Utf8.h>
#include <ZipFile.h>
#include <deque>
@@ -9,7 +10,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 5;
constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-composed
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
@@ -292,7 +293,7 @@ bool BookMetadataCache::cleanupTmpFiles() const {
return true;
}
uint32_t BookMetadataCache::writeSpineEntry(FsFile& file, const SpineEntry& entry) const {
uint32_t BookMetadataCache::writeSpineEntry(HalFile& file, const SpineEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.href);
serialization::writePod(file, entry.cumulativeSize);
@@ -300,7 +301,7 @@ uint32_t BookMetadataCache::writeSpineEntry(FsFile& file, const SpineEntry& entr
return pos;
}
uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) const {
uint32_t BookMetadataCache::writeTocEntry(HalFile& file, const TocEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.title);
serialization::writeString(file, entry.href);
@@ -364,7 +365,9 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
}
}
const TocEntry entry(title, href, anchor, level, spineIndex);
// Compose the title to NFC at index time so the cache stores precomposed glyphs;
// device fonts have no combining-mark positioning, so NFD titles render broken.
const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex);
writeTocEntry(tocFile, entry);
tocCount++;
}
@@ -438,7 +441,7 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
return readTocEntry(bookFile);
}
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(FsFile& file) const {
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const {
SpineEntry entry;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
@@ -446,7 +449,7 @@ BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(FsFile& file) co
return entry;
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(FsFile& file) const {
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
TocEntry entry;
serialization::readString(file, entry.title);
serialization::readString(file, entry.href);
+7 -7
View File
@@ -50,10 +50,10 @@ class BookMetadataCache {
bool loaded;
bool buildMode;
FsFile bookFile;
HalFile bookFile;
// Temp file handles during build
FsFile spineFile;
FsFile tocFile;
HalFile spineFile;
HalFile tocFile;
// Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry {
@@ -76,10 +76,10 @@ class BookMetadataCache {
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;
uint32_t writeSpineEntry(HalFile& file, const SpineEntry& entry) const;
uint32_t writeTocEntry(HalFile& file, const TocEntry& entry) const;
SpineEntry readSpineEntry(HalFile& file) const;
TocEntry readTocEntry(HalFile& file) const;
public:
BookMetadata coreMetadata;
+28 -11
View File
@@ -6,11 +6,25 @@
#include <new>
namespace {
template <typename Predicate>
void renderFilteredPageElements(const std::vector<std::shared_ptr<PageElement>>& elements, GfxRenderer& renderer,
const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) {
for (const auto& element : elements) {
if (predicate(*element)) {
element->render(renderer, fontId, xOffset, yOffset);
}
}
}
} // namespace
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) {
bool PageLine::serialize(HalFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
@@ -18,7 +32,7 @@ bool PageLine::serialize(FsFile& file) {
return block->serialize(file);
}
std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
std::unique_ptr<PageLine> PageLine::deserialize(HalFile& file) {
int16_t xPos;
int16_t yPos;
serialization::readPod(file, xPos);
@@ -33,7 +47,7 @@ void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffse
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
}
bool PageImage::serialize(FsFile& file) {
bool PageImage::serialize(HalFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
@@ -41,7 +55,7 @@ bool PageImage::serialize(FsFile& file) {
return imageBlock->serialize(file);
}
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
std::unique_ptr<PageImage> PageImage::deserialize(HalFile& file) {
int16_t xPos;
int16_t yPos;
serialization::readPod(file, xPos);
@@ -60,7 +74,7 @@ void PageHorizontalRule::render(GfxRenderer& renderer, const int fontId, const i
renderer.drawLine(xPos + xOffset, yPos + yOffset, xPos + xOffset + width - 1, yPos + yOffset, thickness, true);
}
bool PageHorizontalRule::serialize(FsFile& file) {
bool PageHorizontalRule::serialize(HalFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
serialization::writePod(file, width);
@@ -68,7 +82,7 @@ bool PageHorizontalRule::serialize(FsFile& file) {
return true;
}
std::unique_ptr<PageHorizontalRule> PageHorizontalRule::deserialize(FsFile& file) {
std::unique_ptr<PageHorizontalRule> PageHorizontalRule::deserialize(HalFile& file) {
int16_t xPos = 0;
int16_t yPos = 0;
uint16_t width = 0;
@@ -93,12 +107,15 @@ std::unique_ptr<PageHorizontalRule> PageHorizontalRule::deserialize(FsFile& file
}
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);
}
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; });
}
bool Page::serialize(FsFile& file) const {
void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset,
[](const PageElement& element) { return element.getTag() == TAG_PageImage; });
}
bool Page::serialize(HalFile& file) const {
const uint16_t count = elements.size();
serialization::writePod(file, count);
@@ -126,7 +143,7 @@ bool Page::serialize(FsFile& file) const {
return true;
}
std::unique_ptr<Page> Page::deserialize(FsFile& file) {
std::unique_ptr<Page> Page::deserialize(HalFile& file) {
auto page = std::unique_ptr<Page>(new Page());
uint16_t count;
+10 -9
View File
@@ -24,7 +24,7 @@ class PageElement {
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 bool serialize(HalFile& file) = 0;
virtual PageElementTag getTag() const = 0; // Add type identification
};
@@ -37,9 +37,9 @@ class PageLine final : public PageElement {
: 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;
bool serialize(HalFile& file) override;
PageElementTag getTag() const override { return TAG_PageLine; }
static std::unique_ptr<PageLine> deserialize(FsFile& file);
static std::unique_ptr<PageLine> deserialize(HalFile& file);
};
// New PageImage class
@@ -50,9 +50,9 @@ class PageImage final : public PageElement {
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;
bool serialize(HalFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; }
static std::unique_ptr<PageImage> deserialize(FsFile& file);
static std::unique_ptr<PageImage> deserialize(HalFile& file);
const ImageBlock& getImageBlock() const { return *imageBlock; }
};
@@ -65,9 +65,9 @@ class PageHorizontalRule final : public PageElement {
: PageElement(xPos, yPos), width(width), thickness(thickness) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
bool serialize(HalFile& file) override;
PageElementTag getTag() const override { return TAG_PageHorizontalRule; }
static std::unique_ptr<PageHorizontalRule> deserialize(FsFile& file);
static std::unique_ptr<PageHorizontalRule> deserialize(HalFile& file);
};
class Page {
@@ -88,8 +88,9 @@ class Page {
}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file);
void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(HalFile& file) const;
static std::unique_ptr<Page> deserialize(HalFile& file);
// Check if page contains any images (used to force full refresh)
bool hasImages() const {
+522 -115
View File
@@ -1,5 +1,6 @@
#include "ParsedText.h"
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Utf8.h>
@@ -18,6 +19,20 @@ 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;
// Paragraph-level direction: scan the first N words to find base direction.
constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3;
// Per-word: scan enough chars to see through leading neutrals (quotes, numbers)
// before giving up. 64 is a hedge for pathological cases like long numeric tokens.
constexpr int RTL_PER_WORD_PROBE_DEPTH = 64;
constexpr size_t MIN_JUSTIFY_GAPS = 1;
// Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB.
bool mayContainRtlBytes(const char* str) {
for (const auto* p = reinterpret_cast<const unsigned char*>(str); *p; ++p) {
if (*p >= 0xD6 && *p <= 0xDB) return true;
}
return false;
}
// Returns the first rendered codepoint of a word (skipping leading soft hyphens).
uint32_t firstCodepoint(const std::string& word) {
@@ -43,6 +58,134 @@ uint32_t lastCodepoint(const std::string& word) {
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
bool isNoBreakBeforeCjkPunctuation(const uint32_t cp) {
switch (cp) {
case '.':
case ',':
case ':':
case ';':
case '!':
case '?':
case ')':
case ']':
case '}':
case 0x00BB: // »
case 0x2019: //
case 0x201D: // ”
case 0x3001: // 、
case 0x3002: // 。
case 0x3009: // 〉
case 0x300B: // 》
case 0x300D: // 」
case 0x300F: // 』
case 0x3011: // 】
case 0x3015: //
case 0x3017: // 〗
case 0x3019: // 〙
case 0x301B: // 〛
case 0xFF01: //
case 0xFF09: //
case 0xFF0C: //
case 0xFF0E: //
case 0xFF1A: //
case 0xFF1B: //
case 0xFF1F: //
case 0xFF3D: //
case 0xFF5D: //
return true;
default:
return false;
}
}
bool isNoBreakAfterCjkPunctuation(const uint32_t cp) {
switch (cp) {
case '(':
case '[':
case '{':
case 0x00AB: // «
case 0x2018: //
case 0x201C: // “
case 0x3008: // 〈
case 0x300A: // 《
case 0x300C: // 「
case 0x300E: // 『
case 0x3010: // 【
case 0x3014: //
case 0x3016: // 〖
case 0x3018: // 〘
case 0x301A: // 〚
case 0xFF08: //
case 0xFF3B: //
case 0xFF5B: //
return true;
default:
return false;
}
}
bool containsCjkBreakableCodepoint(const std::string& text) {
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
while (*ptr) {
const uint32_t cp = utf8NextCodepoint(&ptr);
if (utf8IsCjkBreakable(cp)) {
return true;
}
}
return false;
}
bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) {
if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false;
if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false;
if (utf8IsCombiningMark(rightCp)) return false;
return true;
}
std::vector<size_t> cjkCharacterBreakByteOffsets(const std::string& text) {
struct CodepointBoundary {
uint32_t cp;
size_t endOffset;
};
std::vector<CodepointBoundary> codepoints;
codepoints.reserve(text.size());
bool hasCjkBreakable = false;
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
const auto* const start = ptr;
while (*ptr) {
const uint32_t cp = utf8NextCodepoint(&ptr);
if (cp == 0) break;
if (utf8IsCjkBreakable(cp)) {
hasCjkBreakable = true;
}
codepoints.push_back({cp, static_cast<size_t>(ptr - start)});
}
if (!hasCjkBreakable || codepoints.size() < 2) return {};
std::vector<size_t> allowedOffsets;
allowedOffsets.reserve(codepoints.size() - 1);
for (size_t i = 0; i + 1 < codepoints.size(); ++i) {
const uint32_t current = codepoints[i].cp;
const uint32_t next = codepoints[i + 1].cp;
if (!hasCjkBreakOpportunityBetween(current, next)) continue;
allowedOffsets.push_back(codepoints[i].endOffset);
}
return allowedOffsets;
}
int computeJustifyExtra(const int spareSpace, const size_t gapCount) {
if (gapCount < MIN_JUSTIFY_GAPS || spareSpace <= 0) return 0;
// Distribute the spare space evenly across gaps. Do NOT bail out to 0 when the
// per-gap stretch is large: a sparse line (few words on a wide page) legitimately
// needs big gaps to reach the margin. Returning 0 there disables justification for
// that line, leaving it right-aligned (RTL) / left-aligned (LTR) — the mismatched
// alignment bug. Match the un-capped behavior of the old code.
return spareSpace / static_cast<int>(gapCount);
}
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
void stripSoftHyphensInPlace(std::string& word) {
size_t pos = 0;
@@ -111,17 +254,72 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
const bool attachToPrevious) {
if (word.empty()) return;
// The device fonts carry no combining-mark positioning, so EPUB text stored in NFD
// (a base letter followed by separate combining accents -- common for Vietnamese,
// and used for many EPUB <h1> chapter headings) renders with the marks detached or
// misplaced. Compose to NFC here, the single funnel every word passes through, so a
// precomposed glyph is used instead. This runs once per word at layout time (the
// result is cached in the section file) and is a cheap no-op for mark-free text.
word = utf8ComposeNfc(word);
EpdFontFamily::Style baseStyle = fontStyle;
if (underline) {
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::UNDERLINE);
}
const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) &&
BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH);
const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore,
const bool isFocusSuffix) {
words.push_back(std::move(token));
wordStyles.push_back(baseStyle);
wordContinues.push_back(continues);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(isFocusSuffix);
};
bool effectiveAttachToPrevious = attachToPrevious;
bool effectiveNoSpaceBefore = false;
if (attachToPrevious && !words.empty() &&
hasCjkBreakOpportunityBetween(lastCodepoint(words.back()), firstCodepoint(word))) {
effectiveAttachToPrevious = false;
effectiveNoSpaceBefore = true;
}
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
bool firstToken = true;
size_t tokenStart = 0;
for (const size_t breakOffset : breakOffsets) {
if (breakOffset <= tokenStart || breakOffset > word.size()) continue;
pushToken(word.substr(tokenStart, breakOffset - tokenStart), firstToken ? effectiveAttachToPrevious : false,
firstToken ? effectiveNoSpaceBefore : true, false);
firstToken = false;
tokenStart = breakOffset;
}
if (tokenStart < word.size()) {
pushToken(word.substr(tokenStart), firstToken ? effectiveAttachToPrevious : false,
firstToken ? effectiveNoSpaceBefore : true, false);
}
if (wordStartsRtl) {
hasRtlWord = true;
}
return;
}
if (containsCjkBreakableCodepoint(word)) {
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
if (wordStartsRtl) {
hasRtlWord = true;
}
return;
}
// 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);
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
if (wordStartsRtl) {
hasRtlWord = true;
}
return;
}
@@ -147,17 +345,19 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.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) {
auto processSegment = [&](std::string_view segment, bool isWord, bool attach, bool noSpaceBefore) {
if (!isWord) {
// Punctuation and Numbers stay regular
words.emplace_back(segment);
wordStyles.push_back(baseStyle);
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(false);
} else {
size_t charCount = 0;
@@ -179,6 +379,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.emplace_back(segment);
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(false);
} else {
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
@@ -191,12 +392,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.emplace_back(segment.substr(0, splitByteOffset));
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
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);
wordNoSpaceBefore.push_back(false);
wordIsFocusSuffix.push_back(true);
}
}
@@ -224,7 +427,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// 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);
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
isFirstSegment ? effectiveNoSpaceBefore : false);
// Setup for the next segment
segmentStart = currentCpStart;
@@ -236,7 +440,27 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// 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);
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
isFirstSegment ? effectiveNoSpaceBefore : false);
if (wordStartsRtl) {
hasRtlWord = true;
}
}
int ParsedText::resolveFirstLineIndent(const bool isFirstLine, const GfxRenderer& renderer, const int fontId) const {
if (!isFirstLine || !isNaturalAlign) {
return 0;
}
if (blockStyle.textIndentDefined) {
if (blockStyle.textIndent < 0 || !extraParagraphSpacing) {
return blockStyle.textIndent;
}
return 0;
}
if (!extraParagraphSpacing) {
return renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR) * 3;
}
return 0;
}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
@@ -246,8 +470,22 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
return;
}
// Apply fixed transforms before any per-line layout work.
applyParagraphIndent();
// Per-paragraph RTL auto-detection: only when CSS/HTML didn't explicitly set direction.
// Explicit dir="ltr" must be respected and not overridden by content heuristic.
if (!blockStyle.directionDefined && hasRtlWord) {
// Check the first few words for RTL letter codepoints (no heap allocation).
const size_t wordsToScan = std::min(words.size(), RTL_PARAGRAPH_PROBE_WORDS);
for (size_t i = 0; i < wordsToScan; ++i) {
if (BidiUtils::startsWithRtl(words[i].c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH)) {
blockStyle.isRtl = true;
break;
}
}
}
isNaturalAlign =
blockStyle.alignment == CssTextAlign::Justify ||
(blockStyle.isRtl ? blockStyle.alignment == CssTextAlign::Right : blockStyle.alignment == CssTextAlign::Left);
// Ensure SD card font glyph metrics are loaded before measuring word widths.
// For flash-based fonts isSdCardFont() returns false and this block is skipped
@@ -272,14 +510,16 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
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);
lineBreakIndices =
computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
}
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);
extractLine(i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, lineBreakIndices, processLine, renderer,
fontId);
}
// Remove consumed words so size() reflects only remaining words
@@ -288,6 +528,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
words.erase(words.begin(), words.begin() + consumed);
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
wordNoSpaceBefore.erase(wordNoSpaceBefore.begin(), wordNoSpaceBefore.begin() + consumed);
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
}
}
@@ -304,20 +545,13 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
}
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec) {
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;
const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId);
// 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) {
@@ -351,7 +585,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
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]) {
if (j > static_cast<size_t>(i) && noSpaceBeforeVec[j]) {
gap = 0;
} else 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]) {
@@ -423,33 +659,12 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
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<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec) {
const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId);
std::vector<size_t> lineBreakIndices;
size_t currentIndex = 0;
@@ -466,7 +681,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
while (currentIndex < wordWidths.size()) {
const bool isFirstWord = currentIndex == lineStart;
int spacing = 0;
if (!isFirstWord && !continuesVec[currentIndex]) {
if (!isFirstWord && noSpaceBeforeVec[currentIndex]) {
spacing = 0;
} else if (!isFirstWord && !continuesVec[currentIndex]) {
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
} else if (!isFirstWord && continuesVec[currentIndex]) {
@@ -596,6 +813,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// 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);
wordNoSpaceBefore.insert(wordNoSpaceBefore.begin() + wordIndex + 1, false);
// Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
@@ -605,23 +823,30 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
}
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::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
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;
const int firstLineIndent = resolveFirstLineIndent(breakIndex == 0, renderer, fontId);
// Build line data by moving from the original vectors using index range
std::vector<std::string> lineWords;
lineWords.reserve(lineWordCount);
std::vector<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.reserve(lineWordCount);
for (size_t i = 0; i < lineWordCount; ++i) {
std::string word = std::move(words[lastBreakAt + i]);
if (containsSoftHyphen(word)) {
stripSoftHyphensInPlace(word);
}
lineWords.push_back(std::move(word));
lineWordStyles.push_back(wordStyles[lastBreakAt + i]);
}
// Calculate total word width for this line, count actual word gaps,
// and accumulate total natural gap widths (including space kerning adjustments).
@@ -632,21 +857,23 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
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]) {
if (wordIdx > 0 && noSpaceBeforeVec[lastBreakAt + wordIdx]) {
// Unicode break opportunity with no inserted Latin-style space. It is still
// a stretchable gap for justified CJK/Korean text.
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]) {
actualGapCount++;
totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]),
firstCodepoint(lineWords[wordIdx]), lineWordStyles[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] == " ") {
if (lineWords[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]);
totalNaturalGaps += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx - 1]),
firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]);
}
}
@@ -654,73 +881,252 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
const int effectivePageWidth = pageWidth - firstLineIndent;
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
// For RTL, implicit/default Left alignment becomes Right alignment.
// Explicit text-align:left must remain left for CSS correctness.
const CssTextAlign effectiveAlignment =
(blockStyle.isRtl && !blockStyle.textAlignDefined && blockStyle.alignment == CssTextAlign::Left)
? CssTextAlign::Right
: blockStyle.alignment;
// 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)
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? computeJustifyExtra(spareSpace, 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;
}
// BiDi processing: reorder words with UAX#9 in full-line context.
visualOrderScratch.clear();
visualOrderScratch.reserve(lineWordCount);
// Skip expensive visual-order resolution for pure LTR paragraphs that have no RTL words.
const bool shouldResolveVisualOrder = blockStyle.isRtl || hasRtlWord;
const bool willReorder =
shouldResolveVisualOrder && BidiUtils::computeVisualWordOrder(lineWords, blockStyle.isRtl, visualOrderScratch);
// 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);
if (willReorder) {
reorderedWordsScratch.clear();
reorderedStylesScratch.clear();
reorderedWidthsScratch.clear();
reorderedContinuesScratch.clear();
reorderedNoSpaceBeforeScratch.clear();
reorderedFocusSuffixScratch.clear();
reorderedWordsScratch.reserve(visualOrderScratch.size());
reorderedStylesScratch.reserve(visualOrderScratch.size());
reorderedWidthsScratch.reserve(visualOrderScratch.size());
reorderedContinuesScratch.reserve(visualOrderScratch.size());
reorderedNoSpaceBeforeScratch.reserve(visualOrderScratch.size());
reorderedFocusSuffixScratch.reserve(visualOrderScratch.size());
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;
for (size_t i = 0; i < visualOrderScratch.size(); ++i) {
const uint16_t src = visualOrderScratch[i];
reorderedWordsScratch.push_back(std::move(lineWords[src]));
reorderedStylesScratch.push_back(lineWordStyles[src]);
reorderedWidthsScratch.push_back(wordWidths[lastBreakAt + src]);
reorderedFocusSuffixScratch.push_back(wordIsFocusSuffix[lastBreakAt + src]);
// Continuation means "no break/gap between two adjacent logical tokens".
// After visual reordering (common in RTL), an adjacent logical pair can appear
// as either (prev -> curr) or (curr -> prev) in visual order; preserve both.
bool continues = false;
if (i > 0) {
const size_t prevSrc = visualOrderScratch[i - 1];
const size_t currSrc = src;
const bool forwardAdjacent = currSrc == prevSrc + 1;
const bool reverseAdjacent = prevSrc == currSrc + 1;
if (forwardAdjacent && continuesVec[lastBreakAt + currSrc]) {
continues = true;
} else if (reverseAdjacent && continuesVec[lastBreakAt + prevSrc]) {
continues = true;
}
}
reorderedContinuesScratch.push_back(continues);
reorderedNoSpaceBeforeScratch.push_back(!continues && noSpaceBeforeVec[lastBreakAt + src]);
}
int reorderedWordWidthSum = 0;
size_t reorderedGapCount = 0;
int reorderedNaturalGaps = 0;
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
reorderedWordWidthSum += reorderedWidthsScratch[wordIdx];
if (wordIdx > 0 && reorderedNoSpaceBeforeScratch[wordIdx]) {
// Unicode break opportunity with no inserted Latin-style space. It is still
// a stretchable gap for justified CJK/Korean text.
reorderedGapCount++;
} else if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) {
reorderedGapCount++;
reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
firstCodepoint(reorderedWordsScratch[wordIdx]),
reorderedStylesScratch[wordIdx - 1]);
} else if (wordIdx > 0 && reorderedContinuesScratch[wordIdx]) {
if (reorderedWordsScratch[wordIdx] == " ") {
reorderedGapCount++;
}
reorderedNaturalGaps +=
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
firstCodepoint(reorderedWordsScratch[wordIdx]), reorderedStylesScratch[wordIdx - 1]);
}
}
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? computeJustifyExtra(reorderedSpare, reorderedGapCount)
: 0;
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? reorderedJustifyExtra * static_cast<int>(reorderedGapCount)
: 0;
const int contentWidth = reorderedWordWidthSum + reorderedNaturalGaps + justifyContribution;
int xpos = 0;
if (blockStyle.isRtl) {
if (effectiveAlignment == CssTextAlign::Right || effectiveAlignment == CssTextAlign::Justify) {
xpos = effectivePageWidth - contentWidth;
} else if (effectiveAlignment == CssTextAlign::Center) {
xpos = (effectivePageWidth - contentWidth) / 2;
}
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]);
xpos = firstLineIndent;
if (effectiveAlignment == CssTextAlign::Right) {
xpos = effectivePageWidth - contentWidth;
} else if (effectiveAlignment == CssTextAlign::Center) {
xpos = (effectivePageWidth - contentWidth) / 2;
}
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
}
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
lineXPos.push_back(static_cast<int16_t>(xpos));
xpos += reorderedWidthsScratch[wordIdx];
const bool nextIsContinuation =
wordIdx + 1 < reorderedWidthsScratch.size() && reorderedContinuesScratch[wordIdx + 1];
if (nextIsContinuation) {
int advance =
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]);
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
// no-break space must not receive justifyExtra, or the line over-stretches by one
// gap and the last word is pushed past the right margin (issue #2185).
if (wordIdx > 0 && reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += reorderedJustifyExtra;
}
xpos += advance;
} else if (wordIdx + 1 < reorderedWidthsScratch.size()) {
const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1];
int gap = nextNoSpace ? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
reorderedStylesScratch[wordIdx]);
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += reorderedJustifyExtra;
}
xpos += gap;
}
}
lineWords.swap(reorderedWordsScratch);
lineWordStyles.swap(reorderedStylesScratch);
} else {
// Standard LTR/RTL positioning loop when no visual reordering is needed
if (blockStyle.isRtl) {
// RTL: position words from right to left
int xpos = effectivePageWidth;
if (effectiveAlignment == CssTextAlign::Left) {
// Explicit left alignment in RTL context
xpos = lineWordWidthSum + totalNaturalGaps;
} else if (effectiveAlignment == CssTextAlign::Center) {
xpos = (effectivePageWidth + lineWordWidthSum + totalNaturalGaps) / 2;
}
// For Right and Justify, start from right edge (xpos = effectivePageWidth)
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
xpos -= wordWidths[lastBreakAt + wordIdx];
lineXPos.push_back(static_cast<int16_t>(xpos));
const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1];
if (nextIsContinuation) {
// Cross-boundary kerning for continuation words
int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
// wordIdx > 0: see the LTR branch — a leading no-break space is not a justifiable gap.
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += justifyExtra;
}
xpos -= advance;
} else {
int gap = 0;
bool nextNoSpace = false;
if (wordIdx + 1 < lineWordCount) {
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
gap = nextNoSpace
? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
}
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
}
xpos -= gap;
}
}
} else {
// LTR: position words from left to right
int xpos = firstLineIndent;
if (effectiveAlignment == CssTextAlign::Right) {
xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
} else if (effectiveAlignment == CssTextAlign::Center) {
xpos = (effectivePageWidth - lineWordWidthSum - totalNaturalGaps) / 2;
}
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
lineXPos.push_back(static_cast<int16_t>(xpos));
const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1];
if (nextIsContinuation) {
int advance = wordWidths[lastBreakAt + wordIdx];
advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
// no-break space must not receive justifyExtra, or the line over-stretches by one
// gap and the last word is pushed past the right margin (issue #2185).
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += justifyExtra;
}
xpos += advance;
} else {
int gap = 0;
bool nextNoSpace = false;
if (wordIdx + 1 < lineWordCount) {
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
gap = nextNoSpace
? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
}
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
}
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
}
}
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);
}
}
const auto isFocusSuffixAt = [&](const size_t idx) {
return willReorder ? reorderedFocusSuffixScratch[idx] : wordIsFocusSuffix[lastBreakAt + idx];
};
// 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]) {
if (isFocusSuffixAt(i)) {
lineHasFocusSplit = true;
break;
}
@@ -747,17 +1153,18 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
outSuffixX.reserve(lineWordCount);
for (size_t i = 0; i < lineWordCount; i++) {
if (wordIsFocusSuffix[lastBreakAt + i] && !outWords.empty()) {
if (isFocusSuffixAt(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]) {
if (i + 1 < lineWordCount && isFocusSuffixAt(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]);
const int suffixDelta = static_cast<int>(lineXPos[i + 1]) - static_cast<int>(lineXPos[i]);
suffixX = static_cast<uint16_t>(suffixDelta > 0 ? suffixDelta : 0);
}
outWords.push_back(std::move(lineWords[i]));
outXPos.push_back(lineXPos[i]);
+22 -7
View File
@@ -15,22 +15,35 @@ 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> wordContinues; // true = word attaches to previous with no break
std::vector<bool> wordNoSpaceBefore; // true = may break before token, but no synthetic space when joined
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
BlockStyle blockStyle;
bool extraParagraphSpacing;
bool hyphenationEnabled;
bool focusReadingEnabled;
bool isNaturalAlign;
bool hasRtlWord;
std::vector<std::string> reorderedWordsScratch;
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
std::vector<uint16_t> reorderedWidthsScratch;
std::vector<bool> reorderedContinuesScratch;
std::vector<bool> reorderedNoSpaceBeforeScratch;
std::vector<bool> reorderedFocusSuffixScratch;
std::vector<uint16_t> visualOrderScratch;
void applyParagraphIndent();
int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const;
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec);
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec);
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::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
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);
@@ -41,7 +54,9 @@ class ParsedText {
: blockStyle(blockStyle),
extraParagraphSpacing(extraParagraphSpacing),
hyphenationEnabled(hyphenationEnabled),
focusReadingEnabled(focusReadingEnabled) {}
focusReadingEnabled(focusReadingEnabled),
isNaturalAlign(false),
hasRtlWord(false) {}
~ParsedText() = default;
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
@@ -52,4 +67,4 @@ class ParsedText {
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
};
};
+58 -7
View File
@@ -10,7 +10,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 24;
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
constexpr uint8_t SECTION_FILE_VERSION = 27;
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) +
@@ -176,7 +177,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Storage.remove(tmpHtmlPath.c_str());
}
FsFile tmpHtml;
HalFile tmpHtml;
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
@@ -221,13 +222,26 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
}
// Collect TOC anchors for this spine so the parser can insert page breaks at chapter boundaries
std::vector<std::string> tocAnchors;
const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex);
if (startTocIndex >= 0) {
for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) {
auto entry = epub->getTocItem(i);
if (entry.spineIndex != spineIndex) break;
if (!entry.anchor.empty()) {
tocAnchors.push_back(std::move(entry.anchor));
}
}
}
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);
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -316,8 +330,45 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return page;
}
std::string Section::getTextFromSectionFile() {
std::string fullText;
auto p = this->loadPageFromSectionFile();
if (p) {
for (const auto& el : p->elements) {
if (el->getTag() == TAG_PageLine) {
const auto& line = static_cast<const PageLine&>(*el);
if (line.getBlock()) {
const auto& words = line.getBlock()->getWords();
for (const auto& w : words) {
if (!fullText.empty()) fullText += " ";
fullText += w;
}
}
}
}
}
return fullText;
}
std::optional<uint16_t> Section::getCachedPageCount() const {
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
if (fileSize < HEADER_SIZE) {
return std::nullopt;
}
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
uint16_t count;
serialization::readPod(f, count);
return count;
}
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
FsFile f;
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
@@ -347,7 +398,7 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
}
std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex) const {
FsFile f;
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
@@ -386,7 +437,7 @@ std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex)
}
std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) const {
FsFile f;
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
@@ -418,7 +469,7 @@ std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) c
}
std::optional<uint16_t> Section::getPageForListItemIndex(const uint16_t liIndex) const {
FsFile f;
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
+5 -1
View File
@@ -14,7 +14,7 @@ class Section {
const int spineIndex;
GfxRenderer& renderer;
std::string filePath;
FsFile file;
HalFile file;
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
@@ -40,10 +40,14 @@ class Section {
uint8_t imageRendering, bool focusReadingEnabled,
const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
std::string getTextFromSectionFile();
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Get the page count from the section cache file without fully loading it.
std::optional<uint16_t> getCachedPageCount() const;
// Look up the page number for a synthetic paragraph index from XPath p[N].
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
+14 -1
View File
@@ -29,6 +29,8 @@ struct BlockStyle {
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
bool isRtl = false; // true if resolved direction is RTL
bool directionDefined = false; // true if direction was explicitly set in CSS/HTML
// Combined insets (margin + padding)
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
@@ -84,6 +86,12 @@ struct BlockStyle {
result.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
}
// Direction is not axis-specific. Inherit from parent when child doesn't define it.
if (!child.directionDefined && directionDefined) {
result.isRtl = isRtl;
result.directionDefined = true;
}
return result;
}
@@ -107,7 +115,7 @@ struct BlockStyle {
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
// leave textIndentDefined=false so the space-width fallback in resolveFirstLineIndent() is used
if (cssStyle.hasTextIndent() && cssStyle.textIndent.isResolvable(vw)) {
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize, vw);
blockStyle.textIndentDefined = true;
@@ -119,6 +127,11 @@ struct BlockStyle {
} else {
blockStyle.alignment = paragraphAlignment;
}
// RTL direction from CSS/HTML
if (cssStyle.hasDirection()) {
blockStyle.isRtl = (cssStyle.direction == CssTextDirection::Rtl);
blockStyle.directionDefined = true;
}
return blockStyle;
}
};
+59 -13
View File
@@ -1,5 +1,6 @@
#include "ImageBlock.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h>
@@ -30,7 +31,7 @@ std::string getCachePath(const std::string& imagePath) {
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
FsFile cacheFile;
HalFile cacheFile;
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
}
@@ -55,10 +56,22 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
// Read several rows per SD access. A full-page image is re-rendered on every
// grayscale strip pass (~14x per page), and a one-row-per-read loop here means
// cachedHeight (~728) tiny reads through the storage mutex + SdFat each time —
// the dominant cost of displaying an image page. Batching rows into a ~4KB
// buffer cuts that to ~20 reads per pass without holding the whole image.
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
int rowsPerRead = 4096 / bytesPerRow;
if (rowsPerRead < 1) rowsPerRead = 1;
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
uint8_t* readBuffer = (uint8_t*)malloc((size_t)rowsPerRead * bytesPerRow);
if (!readBuffer) {
// Fall back to a single-row buffer under memory pressure.
rowsPerRead = 1;
readBuffer = (uint8_t*)malloc(bytesPerRow);
}
if (!readBuffer) {
LOG_ERR("IMG", "Failed to allocate row buffer");
return false;
}
@@ -66,16 +79,31 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
DirectPixelWriter pw;
pw.init(renderer);
int rowsInBuffer = 0;
int bufferRow = 0;
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;
if (bufferRow >= rowsInBuffer) {
const int toRead = (cachedHeight - row < rowsPerRead) ? (cachedHeight - row) : rowsPerRead;
const size_t bytes = (size_t)toRead * bytesPerRow;
if (cacheFile.read(readBuffer, bytes) != static_cast<int>(bytes)) {
LOG_ERR("IMG", "Cache read error at row %d", row);
free(readBuffer);
return false;
}
rowsInBuffer = toRead;
bufferRow = 0;
}
const uint8_t* rowBuffer = readBuffer + (size_t)bufferRow * bytesPerRow;
bufferRow++;
const int destY = y + row;
pw.beginRow(destY);
for (int col = 0; col < cachedWidth; col++) {
// On a grayscale strip pass only a narrow column window of the image is in
// the active band; skip the rest instead of unpacking+clipping every pixel.
int colStart, colEnd;
pw.bandColRange(x, cachedWidth, colStart, colEnd);
for (int col = colStart; col < colEnd; 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;
@@ -84,7 +112,7 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
}
}
free(rowBuffer);
free(readBuffer);
LOG_DBG("IMG", "Cache render complete");
return true;
}
@@ -92,6 +120,14 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
} // namespace
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// The font-prewarm scan pass only accumulates glyphs; an image contributes
// none, and its DirectPixelWriter output bypasses the renderer's scan-mode
// suppression, so it would otherwise do a full (discarded) cache render every
// page view. Skip it here. The image still draws in the real BW/grayscale
// passes; on first view this just moves the one-time decode to the BW pass.
FontCacheManager* fcm = renderer.getFontCacheManager();
if (fcm && fcm->isScanning()) return;
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
@@ -104,6 +140,16 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return;
}
// Tiled grayscale (#2190): skip the whole image when it doesn't touch the
// active band. The per-pixel writer already clips off-band pixels, but without
// this each of the ~7 bands per plane re-ran the full cache load / pixel walk
// and discarded the result — the dominant cost of AA on image pages. The check
// is orientation-aware and returns true when no strip is active, so the BW
// pass and non-tiled controllers render the image exactly as before.
if (!renderer.glyphIntersectsStrip(x, y, x + width - 1, y + height - 1)) {
return;
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
@@ -112,7 +158,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// No cache - need to decode the image
// Check if image file exists
FsFile file;
HalFile file;
if (!Storage.openFileForRead("IMG", imagePath, file)) {
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
return;
@@ -155,14 +201,14 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
LOG_DBG("IMG", "Decode successful");
}
bool ImageBlock::serialize(FsFile& file) {
bool ImageBlock::serialize(HalFile& file) {
serialization::writeString(file, imagePath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
}
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
std::string path;
serialization::readString(file, path);
int16_t w, h;
+2 -2
View File
@@ -21,8 +21,8 @@ class ImageBlock final : public 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);
bool serialize(HalFile& file);
static std::unique_ptr<ImageBlock> deserialize(HalFile& file);
private:
std::string imagePath;
+31 -21
View File
@@ -1,5 +1,6 @@
#include "TextBlock.h"
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h>
@@ -18,11 +19,26 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return;
}
const bool scanning = renderer.isFontCacheScanning();
const int ascender = renderer.getFontAscenderSize(fontId);
for (size_t i = 0; i < words.size(); i++) {
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
const auto baseDir = static_cast<BidiUtils::BidiBaseDir>(
BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0));
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
// SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside
// drawText, so these offsets are chosen relative to the full-size ascender:
// SUP: raise by 40% of ascender — sits clearly above the cap-height
// SUB: lower by 25% of ascender — descends below baseline without clashing with ascenders below
int wordY = y;
if ((currentStyle & EpdFontFamily::SUP) != 0) {
wordY -= ascender * 2 / 5;
} else if ((currentStyle & EpdFontFamily::SUB) != 0) {
wordY += ascender / 4;
}
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
@@ -36,38 +52,28 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
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);
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir);
const int suffixX = wordX + wordFocusSuffixX[i];
renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle);
renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir);
} else {
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
}
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
if (!scanning && (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 underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
const int underlineY = wordY + ascender + 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;
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
underlineWidth = (underlineWidth + 1) / 2;
}
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
}
}
}
bool TextBlock::serialize(FsFile& file) const {
bool TextBlock::serialize(HalFile& 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();
@@ -106,11 +112,13 @@ bool TextBlock::serialize(FsFile& file) const {
serialization::writePod(file, blockStyle.paddingRight);
serialization::writePod(file, blockStyle.textIndent);
serialization::writePod(file, blockStyle.textIndentDefined);
serialization::writePod(file, blockStyle.isRtl);
serialization::writePod(file, blockStyle.directionDefined);
return true;
}
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
uint16_t wc;
std::vector<std::string> words;
std::vector<int16_t> wordXpos;
@@ -159,6 +167,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.paddingRight);
serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.textIndentDefined);
serialization::readPod(file, blockStyle.isRtl);
serialization::readPod(file, blockStyle.directionDefined);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
+2 -2
View File
@@ -47,6 +47,6 @@ class TextBlock final : public Block {
// 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);
bool serialize(HalFile& file) const;
static std::unique_ptr<TextBlock> deserialize(HalFile& file);
};
+79 -9
View File
@@ -4,6 +4,8 @@
#include <HalDisplay.h>
#include <stdint.h>
#include <cassert>
// 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,
@@ -16,6 +18,12 @@ struct DirectPixelWriter {
uint8_t* fb;
GfxRenderer::RenderMode mode;
uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99)
// Active write target: for tiled grayscale, fb is the band scratch, originY is
// the band's top physical row, and clipRows is the band height. Off-band
// pixels are dropped. With no strip active these collapse to the full frame
// (originY 0, clipRows panelHeight) so the clip doubles as a bounds guard.
int originY;
int clipRows;
// Orientation is collapsed into a linear transform:
// phyX = phyXBase + x * phyXStepX + y * phyXStepY
@@ -28,7 +36,9 @@ struct DirectPixelWriter {
int rowPhyXBase, rowPhyYBase;
void init(GfxRenderer& renderer) {
fb = renderer.getFrameBuffer();
fb = renderer.getWriteTarget();
originY = renderer.getWriteOriginY();
clipRows = renderer.getWriteRows();
mode = renderer.getRenderMode();
displayWidthBytes = renderer.getDisplayWidthBytes();
@@ -91,6 +101,46 @@ struct DirectPixelWriter {
rowPhyYBase = phyYBase + logicalY * phyYStepY;
}
// For the current row (set via beginRow), narrow [colStart, colEnd) to the
// columns whose pixels fall inside the active strip band. writePixel() would
// clip the rest anyway, but on a strip pass that is most of a full-page image
// (only ~one strip-height worth of columns survive in portrait); skipping them
// here avoids the per-pixel unpack+transform entirely. For full-frame passes
// (clipRows == panel height) the range is unchanged. xBase is the logical X of
// column 0; the band test mirrors writePixel(): 0 <= phyY - originY < clipRows.
inline void bandColRange(int xBase, int width, int& colStart, int& colEnd) const {
// init() only ever sets phyYStepX to 0, +1, or -1; the +1/-1 solve below
// relies on that.
assert(phyYStepX == 0 || phyYStepX == 1 || phyYStepX == -1);
colStart = 0;
colEnd = width;
if (phyYStepX == 0) {
// phyY is constant across the row: the whole row is in-band or out.
const int sy = rowPhyYBase - originY;
if (static_cast<unsigned>(sy) >= static_cast<unsigned>(clipRows)) colEnd = 0;
return;
}
// phyY = rowPhyYBase + logicalX * phyYStepX (phyYStepX is +1 or -1).
// Solve originY <= phyY <= originY + clipRows - 1 for logicalX.
const int loY = originY;
const int hiY = originY + clipRows - 1;
int xLo, xHi;
if (phyYStepX > 0) {
xLo = loY - rowPhyYBase;
xHi = hiY - rowPhyYBase;
} else {
xLo = rowPhyYBase - hiY;
xHi = rowPhyYBase - loY;
}
const int cs = xLo - xBase;
const int ce = xHi - xBase + 1; // exclusive
if (cs > colStart) colStart = cs;
if (ce < colEnd) colEnd = ce;
if (colStart < 0) colStart = 0;
if (colEnd > width) colEnd = width;
if (colStart > colEnd) colStart = colEnd;
}
// 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.
@@ -120,7 +170,12 @@ struct DirectPixelWriter {
const int phyX = rowPhyXBase + logicalX * phyXStepX;
const int phyY = rowPhyYBase + logicalX * phyYStepX;
const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3);
// Band-local row. The unsigned compare drops both off-band pixels (strip
// mode) and any out-of-frame row (full-frame mode) in one branch.
const int sy = phyY - originY;
if (static_cast<unsigned>(sy) >= static_cast<unsigned>(clipRows)) return;
const uint16_t byteIndex = static_cast<uint16_t>(sy * displayWidthBytes + (phyX >> 3));
const uint8_t bitMask = 1 << (7 - (phyX & 7));
if (state) {
@@ -134,27 +189,42 @@ struct DirectPixelWriter {
// 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.
// The cache buffer is a small streaming band (e.g. 16 rows), not the full image,
// so a band-relative row/column that lands outside it would corrupt adjacent
// heap. This writer therefore bounds-checks every access: beginRow() invalidates
// the row when it falls outside the band, and writePixel() drops out-of-range
// columns. This path only runs during the single decode that populates the
// cache, never on the screen render hot path, so the checks are cheap.
struct DirectCacheWriter {
uint8_t* buffer;
int bytesPerRow;
int bandRows;
int originX;
uint8_t* rowPtr; // Pre-computed for current row
uint8_t* rowPtr; // Pre-computed for current row; nullptr if row is out of band
void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheOriginX) {
void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheBandRows, int cacheOriginX) {
buffer = cacheBuffer;
bytesPerRow = cacheBytesPerRow;
bandRows = cacheBandRows;
originX = cacheOriginX;
rowPtr = nullptr;
}
// Call once per row before the column loop.
inline void beginRow(int screenY, int cacheOriginY) { rowPtr = buffer + (screenY - cacheOriginY) * bytesPerRow; }
// Call once per row before the column loop. Drops rows outside the band.
inline void beginRow(int screenY, int cacheOriginY) {
const int localRow = screenY - cacheOriginY;
rowPtr = (static_cast<unsigned>(localRow) < static_cast<unsigned>(bandRows))
? buffer + (size_t)localRow * bytesPerRow
: nullptr;
}
// Write a 2-bit pixel value. No bounds checking.
// Write a 2-bit pixel value. Drops the write if the row is out of band or the
// column is out of range.
inline void writePixel(int screenX, uint8_t value) const {
if (!rowPtr) return;
const int localX = screenX - originX;
const int byteIdx = localX >> 2; // localX / 4
const int byteIdx = localX >> 2; // localX / 4
if (static_cast<unsigned>(byteIdx) >= static_cast<unsigned>(bytesPerRow)) return;
const int bitShift = 6 - (localX & 3) * 2; // MSB first: pixel 0 at bits 6-7
rowPtr[byteIdx] = (rowPtr[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
@@ -19,7 +19,7 @@ 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()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by jpegOpen()).
struct JpegContext {
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
@@ -48,10 +48,10 @@ struct JpegContext {
bool caching{false};
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// File I/O callbacks use pFile->fHandle to access the HalFile*,
// avoiding the need for global file state.
void* jpegOpen(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
HalFile* f = new HalFile();
if (!Storage.openFileForRead("JPG", std::string(filename), *f)) {
delete f;
return nullptr;
@@ -61,7 +61,7 @@ void* jpegOpen(const char* filename, int32_t* size) {
}
void jpegClose(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
HalFile* f = reinterpret_cast<HalFile*>(handle);
if (f) {
f->close();
delete f;
@@ -73,7 +73,7 @@ void jpegClose(void* handle) {
// 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);
HalFile* f = reinterpret_cast<HalFile*>(pFile->fHandle);
if (!f) return 0;
int32_t bytesRead = f->read(pBuf, len);
if (bytesRead < 0) return 0;
@@ -82,7 +82,7 @@ int32_t jpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
}
int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
HalFile* f = reinterpret_cast<HalFile*>(pFile->fHandle);
if (!f) return -1;
if (!f->seek(pos)) return -1;
pFile->iPos = pos;
@@ -132,7 +132,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
bool caching = ctx->caching;
const int32_t fineScaleFPX = ctx->fineScaleFPX;
const int32_t invScaleFPX = ctx->invScaleFPX;
const int32_t fineScaleFPY = ctx->fineScaleFPY;
@@ -169,9 +169,21 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
DirectPixelWriter pw;
pw.init(renderer);
// The cache streams to disk one MCU-row band at a time. Flushing rows below
// this block (raster order guarantees they are final) repositions the band;
// cacheOriginY then maps screen rows to the band-local buffer rows. If a flush
// write fails, stop caching for the rest of this decode (and let finalize drop
// the partial file) rather than writing past the band buffer.
DirectCacheWriter cw;
int cacheOriginY = 0;
if (caching) {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
if (!ctx->cache.advanceTo(dstYStart)) {
caching = false;
ctx->caching = false;
} else {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cacheOriginY = ctx->config->y + ctx->cache.bandStart;
}
}
// === 1:1 fast path: no scaling math ===
@@ -179,7 +191,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const uint8_t* row = &pixels[(dstY - blockY) * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
@@ -213,7 +225,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const int32_t srcFyFP = dstY * invScaleFPY;
const int32_t fy = srcFyFP & FP_MASK;
const int32_t fyInv = FP_ONE - fy;
@@ -310,7 +322,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const int32_t srcFyFP = dstY * invScaleFPY;
int ly = (srcFyFP >> FP_SHIFT) - blockY;
if (ly < 0) ly = 0;
@@ -469,11 +481,14 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
jpeg->setUserPointer(&ctx);
// Allocate cache buffer using final output dimensions
// Start streaming the pixel cache to disk. The band only needs to hold the
// tallest single decode block: a JPEGDEC MCU cell is at most 16 scaled-source
// rows tall, which our fine scale maps to this many output rows.
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");
const int maxBlockDstRows = (int)(((int64_t)16 * ctx.fineScaleFPY) >> FP_SHIFT) + 2;
if (!ctx.cache.begin(config.cachePath, destWidth, destHeight, config.x, config.y, maxBlockDstRows)) {
LOG_ERR("JPG", "Failed to start cache stream, continuing without caching");
ctx.caching = false;
}
}
@@ -484,14 +499,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
if (rc != 1) {
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
if (ctx.caching) ctx.cache.abort();
return false;
}
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled
// Finalize the streamed cache file. Note: a flush failure mid-decode clears
// ctx.caching (the partial file is dropped), so re-read the flag here.
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
ctx.cache.finalize();
}
return true;
+146 -41
View File
@@ -4,76 +4,181 @@
#include <Logging.h>
#include <stdint.h>
#include <cstdlib>
#include <cstring>
#include <string>
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
// Packs 4 pixels per byte, MSB first.
// Streaming cache writer for 2-bit pixels (4 levels). Packs 4 pixels per byte,
// MSB first.
//
// The .pxc file is written incrementally in small row bands rather than holding
// the whole decoded image in one heap buffer. A full-page image (e.g. 482x728)
// needs ~88KB packed, which will not fit alongside the ~20KB JPEG decoder on a
// fragmented 380KB heap (free heap is routinely ~55KB on an image page). When
// the cache cannot be written, every render pass re-decodes the JPEG from
// scratch; an anti-aliased image page renders ~14 times (BW + AA restore + two
// grayscale planes x ~6 strips), so a 2s decode becomes a ~30s freeze / watchdog
// reset. Streaming keeps the working set to a single MCU-row band, so caching
// succeeds and the image is decoded exactly once.
//
// Correctness relies on JPEGDEC delivering blocks in raster MCU order (outer
// loop over y, inner over x: see jpeg.inl DecodeJPEG). Consecutive MCU rows map
// to contiguous, non-overlapping destination row ranges, so once a block whose
// top row is Y arrives, every output row < Y is final and is flushed to disk.
struct PixelCache {
uint8_t* buffer;
uint8_t* buffer; // band buffer: (bandRows + 1) rows; last row kept zeroed
uint8_t* zeroRow; // points at the spare zeroed row, for gap/clip fill
int width;
int height;
int bytesPerRow;
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
int bandRows; // rows held in the band buffer
int bandStart; // image-local row index of band buffer row 0
int flushedRows; // image-local rows already written to file
HalFile file;
std::string cachePathStr;
bool ok;
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
PixelCache()
: buffer(nullptr),
zeroRow(nullptr),
width(0),
height(0),
bytesPerRow(0),
originX(0),
originY(0),
bandRows(0),
bandStart(0),
flushedRows(0),
ok(false) {}
PixelCache(const PixelCache&) = delete;
PixelCache& operator=(const PixelCache&) = delete;
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
static constexpr int MIN_BAND_ROWS = 16;
static constexpr size_t MAX_BAND_BYTES = 24 * 1024; // band working-set ceiling
bool allocate(int w, int h, int ox, int oy) {
// Open the cache file, write the header, and allocate a band buffer big enough
// to hold the tallest single decode block (maxBlockDstRows output rows).
bool begin(const std::string& cachePath, int w, int h, int ox, int oy, int maxBlockDstRows) {
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);
bandStart = 0;
flushedRows = 0;
ok = false;
int wantRows = maxBlockDstRows + 2;
if (wantRows < MIN_BAND_ROWS) wantRows = MIN_BAND_ROWS;
if (wantRows > h) wantRows = h;
size_t maxRowsByMem = MAX_BAND_BYTES / (size_t)bytesPerRow;
if (maxRowsByMem < 1) maxRowsByMem = 1;
if ((size_t)wantRows > maxRowsByMem) wantRows = (int)maxRowsByMem;
// A single decode block must fit inside the band, otherwise streaming would
// drop rows. This only fails for pathological upscales that could not be
// cached at all; fall back to the no-cache path.
if (wantRows < maxBlockDstRows) {
LOG_ERR("IMG", "Cache band too small (%d < %d rows) for %dx%d", wantRows, maxBlockDstRows, w, h);
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);
bandRows = wantRows;
const size_t bufSize = (size_t)(bandRows + 1) * bytesPerRow; // +1 spare zero row
buffer = (uint8_t*)malloc(bufSize);
if (!buffer) {
LOG_ERR("IMG", "OOM cache band: %u bytes", (unsigned)bufSize);
return false;
}
return buffer != nullptr;
}
memset(buffer, 0, bufSize);
zeroRow = buffer + (size_t)bandRows * bytesPerRow;
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)) {
if (!Storage.openFileForWrite("IMG", cachePath, file)) {
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
free(buffer);
buffer = nullptr;
return false;
}
cachePathStr = cachePath;
uint16_t w16 = (uint16_t)w;
uint16_t h16 = (uint16_t)h;
if (file.write(&w16, 2) != 2 || file.write(&h16, 2) != 2) {
LOG_ERR("IMG", "Failed to write cache header: %s", cachePath.c_str());
abort();
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);
LOG_DBG("IMG", "Cache stream started: %s (%dx%d, band %d rows)", cachePath.c_str(), w, h, bandRows);
ok = true;
return true;
}
// Flush every output row below newTopRow (they are final in raster order) and
// reposition the band to start at newTopRow. Returns false if a write failed,
// in which case the caller must stop caching for the rest of the decode.
bool advanceTo(int newTopRow) {
if (!ok) return false;
if (newTopRow <= bandStart) return true;
if (newTopRow > height) newTopRow = height;
for (int r = bandStart; r < newTopRow; ++r) {
const int idx = r - bandStart;
const uint8_t* rowPtr = (idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow;
if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) {
LOG_ERR("IMG", "Cache write error at row %d", r);
ok = false;
return false;
}
}
flushedRows = newTopRow;
bandStart = newTopRow;
memset(buffer, 0, (size_t)bandRows * bytesPerRow); // fresh band (gaps stay black)
return true;
}
// Flush the final band and zero-fill any rows never covered (image clipped by
// the screen), then close the file.
bool finalize() {
if (!ok) {
abort();
return false;
}
for (int r = flushedRows; r < height; ++r) {
const int idx = r - bandStart;
const uint8_t* rowPtr = (idx >= 0 && idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow;
if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) {
LOG_ERR("IMG", "Cache write error at row %d", r);
abort();
return false;
}
}
file.close();
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePathStr.c_str(), width, height,
4 + bytesPerRow * height);
ok = false; // file handed off; nothing left to clean up
return true;
}
// Drop a partial/failed cache so a later decode re-creates it cleanly.
void abort() {
if (file.isOpen()) file.close();
if (!cachePathStr.empty()) {
Storage.remove(cachePathStr.c_str());
}
ok = false;
}
~PixelCache() {
if (file.isOpen()) {
// The file is still open, so neither finalize() nor abort() ran, or a
// mid-stream write failed (advanceTo() cleared ok but left the file open).
// Drop the partial cache so we leave no corrupt file behind.
abort();
}
if (buffer) {
free(buffer);
buffer = nullptr;
@@ -19,7 +19,7 @@ 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()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
@@ -40,10 +40,10 @@ struct PngContext {
uint8_t* grayLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// File I/O callbacks use pFile->fHandle to access the HalFile*,
// avoiding the need for global file state.
void* pngOpenWithHandle(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
HalFile* f = new HalFile();
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
delete f;
return nullptr;
@@ -53,7 +53,7 @@ void* pngOpenWithHandle(const char* filename, int32_t* size) {
}
void pngCloseWithHandle(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
HalFile* f = reinterpret_cast<HalFile*>(handle);
if (f) {
f->close();
delete f;
@@ -61,13 +61,13 @@ void pngCloseWithHandle(void* handle) {
}
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
HalFile* f = reinterpret_cast<HalFile*>(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);
HalFile* f = reinterpret_cast<HalFile*>(pFile->fHandle);
if (!f) return -1;
return f->seek(pos);
}
@@ -201,10 +201,19 @@ int pngDrawCallback(PNGDRAW* pDraw) {
pw.init(*ctx->renderer);
pw.beginRow(outY);
// The cache streams to disk one row at a time. Flushing rows below this one
// (PNGdec delivers scanlines top to bottom) repositions the single-row band.
// A flush failure stops caching for the rest of the decode so we never write
// past the band buffer; finalize() then drops the partial file.
DirectCacheWriter cw;
if (caching) {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y);
if (!ctx->cache.advanceTo(dstY)) {
caching = false;
ctx->caching = false;
} else {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y + ctx->cache.bandStart);
}
}
int srcX = 0;
@@ -348,19 +357,16 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
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;
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
// band only needs a single row. Streaming keeps the working set tiny, so
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.cachePath.empty();
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");
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
ctx.caching = false;
}
}
@@ -374,14 +380,15 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
if (ctx.caching) ctx.cache.abort();
return false;
}
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled and buffer was allocated
// Finalize the streamed cache (caching may have been cleared on a flush error).
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
ctx.cache.finalize();
}
return true;
+325 -290
View File
@@ -6,6 +6,8 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <charconv>
#include <cstring>
#include <string_view>
namespace {
@@ -29,9 +31,7 @@ struct StackBuffer {
// Get string view of current content (zero-copy)
std::string_view view() const { return std::string_view(data, len); }
// Convert to string for passing to functions (single allocation)
std::string str() const { return std::string(data, len); }
operator std::string_view() const noexcept { return view(); }
};
// Buffer size for reading CSS files
@@ -50,7 +50,87 @@ constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
constexpr size_t MAX_SELECTOR_LENGTH = 256;
// Check if character is CSS whitespace
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
constexpr bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
constexpr std::string_view trimCssWhitespace(std::string_view s) {
while (!s.empty() && isCssWhitespace(s.front())) s.remove_prefix(1);
while (!s.empty() && isCssWhitespace(s.back())) s.remove_suffix(1);
return s;
}
constexpr char asciiToLower(const char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
// Case-insensitive equality on ASCII. lowercaseKeyword MUST already be
// lowercase; CSS keywords are ASCII by spec so byte-wise tolower is safe.
constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKeyword) {
return std::equal(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
[](char a, char b) { return asciiToLower(a) == b; });
}
// Case-insensitive ASCII substring search. Only needed by text-decoration,
// which accepts multi-value strings like "underline solid red".
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
}
// Walk s and invoke fn(token) for each non-empty run between delimiters.
// Tokens are boundary-trimmed and yielded as string_views into s; no
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
// emitted. `isDelimiter` is invoked once per character.
template <typename Pred, typename F>
void forEachDelimitedToken(std::string_view s, Pred isDelimiter, F&& fn) {
size_t start = 0;
for (size_t i = 0; i <= s.size(); ++i) {
if (i == s.size() || isDelimiter(s[i])) {
const std::string_view trimmed = trimCssWhitespace(s.substr(start, i - start));
if (!trimmed.empty()) {
fn(trimmed);
}
start = i + 1;
}
}
}
// FNV-1a per Fowler/Noll/Vo, sized to match size_t on the target. The firmware
// runs on a 32-bit core where size_t is 32 bits, so naively using the 64-bit
// constants would silently truncate FNV_PRIME to a non-prime and wreck hash
// distribution. The selection below picks the canonical 32- or 64-bit
// constants at compile time so the same source works in a 64-bit host
// simulator. `fnv1aMix` is the per-byte mix step; callers apply any
// byte-level transform (e.g. asciiToLower) first.
static_assert(sizeof(size_t) == 4 || sizeof(size_t) == 8, "FNV constants are only defined for 32- or 64-bit size_t");
constexpr size_t FNV_OFFSET_BASIS =
sizeof(size_t) == 8 ? static_cast<size_t>(14695981039346656037ULL) : static_cast<size_t>(2166136261U);
constexpr size_t FNV_PRIME =
sizeof(size_t) == 8 ? static_cast<size_t>(1099511628211ULL) : static_cast<size_t>(16777619U);
constexpr size_t fnv1aMix(size_t hash, unsigned char byte) { return (hash ^ byte) * FNV_PRIME; }
// Parse the entirety of s as a number into `out`. Accepts an optional leading
// '+' (which std::from_chars rejects by spec) so callers can pass CSS-style
// signed numbers without manual trimming. Returns false on empty input, a
// non-numeric suffix, or any from_chars error.
template <typename T>
bool tryParseNumber(std::string_view s, T& out) {
const char* begin = s.data();
const char* end = s.data() + s.size();
if (begin < end && *begin == '+') ++begin;
const auto r = std::from_chars(begin, end, out);
return r.ec == std::errc{} && r.ptr == end;
}
// Collect up to 4 whitespace-separated tokens for a CSS edge-value shorthand
// (margin, padding, and the border-* family). Returns the number of tokens
// written; extras are silently dropped. Callers apply the 1/2/3/4-value
// fallback rule using the returned count.
size_t collectEdgeValueTokens(std::string_view s, std::string_view (&out)[4]) {
size_t count = 0;
forEachDelimitedToken(s, isCssWhitespace, [&](std::string_view tok) {
if (count < 4) out[count++] = tok;
});
return count;
}
std::string_view stripTrailingImportant(std::string_view value) {
constexpr std::string_view IMPORTANT = "!important";
@@ -64,7 +144,7 @@ std::string_view stripTrailingImportant(std::string_view value) {
}
const size_t suffixPos = value.size() - IMPORTANT.size();
if (value.substr(suffixPos) != IMPORTANT) {
if (!iequalsAscii(value.substr(suffixPos), IMPORTANT)) {
return value;
}
@@ -77,178 +157,143 @@ std::string_view stripTrailingImportant(std::string_view value) {
} // anonymous namespace
// String utilities implementation
// Transparent case-insensitive hash/equal. Bodies live here (rather than
// inline in the header) so they can share the anonymous-namespace asciiToLower
// with the other ASCII helpers in this translation unit.
std::string CssParser::normalized(const std::string& s) {
std::string result;
result.reserve(s.size());
bool inSpace = true; // Start true to skip leading space
for (const char c : s) {
if (isCssWhitespace(c)) {
if (!inSpace) {
result.push_back(' ');
inSpace = true;
}
} else {
result.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
inSpace = false;
}
}
// Remove trailing space
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
result.pop_back();
}
return result;
size_t CssParser::SvHash::operator()(std::string_view sv) const noexcept {
size_t h = FNV_OFFSET_BASIS;
for (char c : sv) h = fnv1aMix(h, asciiToLower(c));
return h;
}
void CssParser::normalizedInto(const std::string& s, std::string& out) {
out.clear();
out.reserve(s.size());
size_t CssParser::SvHash::operator()(const std::string& s) const noexcept { return operator()(std::string_view(s)); }
bool inSpace = true; // Start true to skip leading space
for (const char c : s) {
if (isCssWhitespace(c)) {
if (!inSpace) {
out.push_back(' ');
inSpace = true;
}
} else {
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
inSpace = false;
}
}
if (!out.empty() && out.back() == ' ') {
out.pop_back();
size_t CssParser::SvHash::operator()(CompositeKey k) const noexcept {
// Hash the case-folded concatenation of every piece without materializing
// it — the running hash continues across pieces as if they were one buffer.
size_t h = FNV_OFFSET_BASIS;
for (std::string_view piece : k.pieces) {
for (char c : piece) h = fnv1aMix(h, asciiToLower(c));
}
return h;
}
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
std::vector<std::string> parts;
size_t start = 0;
for (size_t i = 0; i <= s.size(); ++i) {
if (i == s.size() || s[i] == delimiter) {
std::string part = s.substr(start, i - start);
std::string trimmed = normalized(part);
if (!trimmed.empty()) {
parts.push_back(trimmed);
}
start = i + 1;
}
bool CssParser::SvEqual::operator()(std::string_view a, std::string_view b) const noexcept {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i) {
if (asciiToLower(a[i]) != asciiToLower(b[i])) return false;
}
return parts;
return true;
}
std::vector<std::string> CssParser::splitWhitespace(const std::string& s) {
std::vector<std::string> parts;
size_t start = 0;
bool inWord = false;
bool CssParser::SvEqual::operator()(const std::string& a, std::string_view b) const noexcept {
return operator()(std::string_view(a), b);
}
for (size_t i = 0; i <= s.size(); ++i) {
const bool isSpace = i == s.size() || isCssWhitespace(s[i]);
if (isSpace && inWord) {
parts.push_back(s.substr(start, i - start));
inWord = false;
} else if (!isSpace && !inWord) {
start = i;
inWord = true;
bool CssParser::SvEqual::operator()(std::string_view a, const std::string& b) const noexcept {
return operator()(a, std::string_view(b));
}
bool CssParser::SvEqual::operator()(const std::string& a, const std::string& b) const noexcept {
return operator()(std::string_view(a), std::string_view(b));
}
bool CssParser::SvEqual::operator()(CompositeKey k, std::string_view sv) const noexcept {
size_t total = 0;
for (std::string_view piece : k.pieces) total += piece.size();
if (total != sv.size()) return false;
size_t i = 0;
for (std::string_view piece : k.pieces) {
for (char c : piece) {
if (asciiToLower(c) != asciiToLower(sv[i++])) return false;
}
}
return parts;
return true;
}
bool CssParser::SvEqual::operator()(std::string_view sv, CompositeKey k) const noexcept { return operator()(k, sv); }
// Property value interpreters
CssTextAlign CssParser::interpretAlignment(const std::string& val) {
const std::string v = normalized(val);
CssTextAlign CssParser::interpretAlignment(std::string_view val) {
val = trimCssWhitespace(val);
if (v == "left" || v == "start") return CssTextAlign::Left;
if (v == "right" || v == "end") return CssTextAlign::Right;
if (v == "center") return CssTextAlign::Center;
if (v == "justify") return CssTextAlign::Justify;
if (iequalsAscii(val, "left") || iequalsAscii(val, "start")) return CssTextAlign::Left;
if (iequalsAscii(val, "right") || iequalsAscii(val, "end")) return CssTextAlign::Right;
if (iequalsAscii(val, "center")) return CssTextAlign::Center;
if (iequalsAscii(val, "justify")) return CssTextAlign::Justify;
return CssTextAlign::Left;
}
CssFontStyle CssParser::interpretFontStyle(const std::string& val) {
const std::string v = normalized(val);
CssFontStyle CssParser::interpretFontStyle(std::string_view val) {
val = trimCssWhitespace(val);
if (v == "italic" || v == "oblique") return CssFontStyle::Italic;
if (iequalsAscii(val, "italic") || iequalsAscii(val, "oblique")) return CssFontStyle::Italic;
return CssFontStyle::Normal;
}
CssFontWeight CssParser::interpretFontWeight(const std::string& val) {
const std::string v = normalized(val);
CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
val = trimCssWhitespace(val);
// Named values
if (v == "bold" || v == "bolder") return CssFontWeight::Bold;
if (v == "normal" || v == "lighter") return CssFontWeight::Normal;
if (iequalsAscii(val, "bold") || iequalsAscii(val, "bolder")) return CssFontWeight::Bold;
if (iequalsAscii(val, "normal") || iequalsAscii(val, "lighter")) return CssFontWeight::Normal;
// Numeric values: 100-900
// CSS spec: 400 = normal, 700 = bold
// We use: 0-400 = normal, 700+ = bold, 500-600 = normal (conservative)
char* endPtr = nullptr;
const long numericWeight = std::strtol(v.c_str(), &endPtr, 10);
// If we parsed a number and consumed the whole string
if (endPtr != v.c_str() && *endPtr == '\0') {
long numericWeight = 0;
if (tryParseNumber(val, numericWeight)) {
return numericWeight >= 700 ? CssFontWeight::Bold : CssFontWeight::Normal;
}
return CssFontWeight::Normal;
}
CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
const std::string v = normalized(val);
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
// text-decoration can have multiple space-separated values
if (v.find("underline") != std::string::npos) {
if (icontainsAscii(val, "underline")) {
return CssTextDecoration::Underline;
}
return CssTextDecoration::None;
}
CssLength CssParser::interpretLength(const std::string& val) {
CssLength CssParser::interpretLength(std::string_view val) {
CssLength result;
tryInterpretLength(val, result);
return result;
}
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
const std::string v = normalized(val);
if (v.empty()) {
bool CssParser::tryInterpretLength(std::string_view val, CssLength& out) {
val = trimCssWhitespace(val);
if (val.empty()) {
out = CssLength{};
return false;
}
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
size_t unitStart = val.size();
for (size_t i = 0; i < val.size(); ++i) {
const char c = val[i];
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
unitStart = i;
break;
}
}
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) {
float numericValue;
if (!tryParseNumber(val.substr(0, unitStart), numericValue)) {
out = CssLength{};
return false; // No number parsed (e.g. auto, inherit, initial)
}
const std::string_view unitPart = val.substr(unitStart);
auto unit = CssUnit::Pixels;
if (unitPart == "em") {
if (iequalsAscii(unitPart, "em")) {
unit = CssUnit::Em;
} else if (unitPart == "rem") {
} else if (iequalsAscii(unitPart, "rem")) {
unit = CssUnit::Rem;
} else if (unitPart == "pt") {
} else if (iequalsAscii(unitPart, "pt")) {
unit = CssUnit::Points;
} else if (unitPart == "%") {
unit = CssUnit::Percent;
@@ -260,107 +305,119 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
// Declaration parsing
void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf) {
void CssParser::parseDeclarationIntoStyle(std::string_view decl, CssStyle& style) {
const size_t colonPos = decl.find(':');
if (colonPos == std::string::npos || colonPos == 0) return;
if (colonPos == std::string_view::npos || colonPos == 0) return;
normalizedInto(decl.substr(0, colonPos), propNameBuf);
normalizedInto(decl.substr(colonPos + 1), propValueBuf);
const std::string_view name = trimCssWhitespace(decl.substr(0, colonPos));
const std::string_view value = trimCssWhitespace(decl.substr(colonPos + 1));
if (propNameBuf.empty() || propValueBuf.empty()) return;
if (name.empty() || value.empty()) return;
if (propNameBuf == "text-align") {
style.textAlign = interpretAlignment(propValueBuf);
if (iequalsAscii(name, "text-align")) {
style.textAlign = interpretAlignment(value);
style.defined.textAlign = 1;
} else if (propNameBuf == "font-style") {
style.fontStyle = interpretFontStyle(propValueBuf);
} else if (iequalsAscii(name, "font-style")) {
style.fontStyle = interpretFontStyle(value);
style.defined.fontStyle = 1;
} else if (propNameBuf == "font-weight") {
style.fontWeight = interpretFontWeight(propValueBuf);
} else if (iequalsAscii(name, "font-weight")) {
style.fontWeight = interpretFontWeight(value);
style.defined.fontWeight = 1;
} else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
style.textDecoration = interpretDecoration(propValueBuf);
} else if (iequalsAscii(name, "text-decoration") || iequalsAscii(name, "text-decoration-line")) {
style.textDecoration = interpretDecoration(value);
style.defined.textDecoration = 1;
} else if (propNameBuf == "text-indent") {
style.textIndent = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "text-indent")) {
style.textIndent = interpretLength(value);
style.defined.textIndent = 1;
} else if (propNameBuf == "margin-top") {
style.marginTop = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "margin-top")) {
style.marginTop = interpretLength(value);
style.defined.marginTop = 1;
} else if (propNameBuf == "margin-bottom") {
style.marginBottom = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "margin-bottom")) {
style.marginBottom = interpretLength(value);
style.defined.marginBottom = 1;
} else if (propNameBuf == "margin-left") {
style.marginLeft = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "margin-left")) {
style.marginLeft = interpretLength(value);
style.defined.marginLeft = 1;
} else if (propNameBuf == "margin-right") {
style.marginRight = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "margin-right")) {
style.marginRight = interpretLength(value);
style.defined.marginRight = 1;
} else if (propNameBuf == "margin") {
const auto values = splitWhitespace(propValueBuf);
if (!values.empty()) {
style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
} else if (iequalsAscii(name, "margin")) {
std::string_view margins[4];
const size_t count = collectEdgeValueTokens(value, margins);
if (count > 0) {
style.marginTop = interpretLength(margins[0]);
style.marginRight = count >= 2 ? interpretLength(margins[1]) : style.marginTop;
style.marginBottom = count >= 3 ? interpretLength(margins[2]) : style.marginTop;
style.marginLeft = count >= 4 ? interpretLength(margins[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
}
} else if (propNameBuf == "padding-top") {
style.paddingTop = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "padding-top")) {
style.paddingTop = interpretLength(value);
style.defined.paddingTop = 1;
} else if (propNameBuf == "padding-bottom") {
style.paddingBottom = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "padding-bottom")) {
style.paddingBottom = interpretLength(value);
style.defined.paddingBottom = 1;
} else if (propNameBuf == "padding-left") {
style.paddingLeft = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "padding-left")) {
style.paddingLeft = interpretLength(value);
style.defined.paddingLeft = 1;
} else if (propNameBuf == "padding-right") {
style.paddingRight = interpretLength(propValueBuf);
} else if (iequalsAscii(name, "padding-right")) {
style.paddingRight = interpretLength(value);
style.defined.paddingRight = 1;
} else if (propNameBuf == "padding") {
const auto values = splitWhitespace(propValueBuf);
if (!values.empty()) {
style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
} else if (iequalsAscii(name, "padding")) {
std::string_view paddings[4];
const size_t count = collectEdgeValueTokens(value, paddings);
if (count > 0) {
style.paddingTop = interpretLength(paddings[0]);
style.paddingRight = count >= 2 ? interpretLength(paddings[1]) : style.paddingTop;
style.paddingBottom = count >= 3 ? interpretLength(paddings[2]) : style.paddingTop;
style.paddingLeft = count >= 4 ? interpretLength(paddings[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
1;
}
} else if (propNameBuf == "height") {
} else if (iequalsAscii(name, "height")) {
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
if (tryInterpretLength(value, len)) {
style.imageHeight = len;
style.defined.imageHeight = 1;
}
} else if (propNameBuf == "width") {
} else if (iequalsAscii(name, "width")) {
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
if (tryInterpretLength(value, len)) {
style.imageWidth = len;
style.defined.imageWidth = 1;
}
} else if (propNameBuf == "display") {
const std::string_view displayValue = stripTrailingImportant(propValueBuf);
style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block;
} else if (iequalsAscii(name, "display")) {
const std::string_view displayValue = stripTrailingImportant(value);
style.display = iequalsAscii(displayValue, "none") ? CssDisplay::None : CssDisplay::Block;
style.defined.display = 1;
} else if (iequalsAscii(name, "direction")) {
const std::string_view directionValue = stripTrailingImportant(value);
if (iequalsAscii(directionValue, "rtl")) {
style.direction = CssTextDirection::Rtl;
style.defined.direction = 1;
} else if (iequalsAscii(directionValue, "ltr")) {
style.direction = CssTextDirection::Ltr;
style.defined.direction = 1;
}
} else if (iequalsAscii(name, "vertical-align")) {
if (iequalsAscii(value, "super")) {
style.verticalAlign = CssVerticalAlign::Super;
style.defined.verticalAlign = 1;
} else if (iequalsAscii(value, "sub")) {
style.verticalAlign = CssVerticalAlign::Sub;
style.defined.verticalAlign = 1;
}
}
}
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
CssStyle CssParser::parseDeclarations(std::string_view declBlock) {
CssStyle style;
std::string propNameBuf;
std::string propValueBuf;
size_t start = 0;
for (size_t i = 0; i <= declBlock.size(); ++i) {
if (i == declBlock.size() || declBlock[i] == ';') {
if (i > start) {
const size_t len = i - start;
std::string decl = declBlock.substr(start, len);
if (!decl.empty()) {
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
}
parseDeclarationIntoStyle(declBlock.substr(start, i - start), style);
}
start = i + 1;
}
@@ -371,96 +428,64 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Rule processing
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
void CssParser::processRuleBlockWithStyle(std::string_view selectorGroup, const CssStyle& style) {
// Check if we've reached the rule limit before processing
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
return;
}
// Handle comma-separated selectors
const auto selectors = splitOnChar(selectorGroup, ',');
// Walk comma-separated selectors in place — no vector allocation. Selectors
// with unsupported syntax (combinators, attributes, pseudo, etc.) are skipped
// silently; the only heap allocation per kept selector is the std::string
// map key, which is unavoidable since the map owns its keys.
bool limitReached = false;
forEachDelimitedToken(
selectorGroup, [](char c) { return c == ','; },
[&](std::string_view sel) {
if (limitReached) return;
for (const auto& sel : selectors) {
// Validate selector length before processing
if (sel.size() > MAX_SELECTOR_LENGTH) {
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
continue;
}
if (sel.size() > MAX_SELECTOR_LENGTH) {
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
return;
}
// Normalize the selector
std::string key = normalized(sel);
if (key.empty()) continue;
// TODO: Support richer CSS selector syntax in the future. For now we only
// handle `tag`, `.class`, or `tag.class`. Reject anything containing a
// character that introduces unsupported syntax:
// '+' adjacent sibling combinator
// '>' child combinator
// '[' attribute selector
// ':' pseudo class/element
// '#' ID selector
// '~' general sibling combinator
// '*' wildcard
// ' ' descendant combinator
// Single-pass scan via find_first_of instead of eight sequential find() calls.
constexpr std::string_view kUnsupportedSelectorChars = "+>[:#~* ";
if (sel.find_first_of(kUnsupportedSelectorChars) != std::string_view::npos) return;
// TODO: Consider adding support for sibling css selectors in the future
// Ensure no + in selector as we don't support adjacent CSS selectors for now
if (key.find('+') != std::string_view::npos) {
continue;
}
// Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
limitReached = true;
return;
}
// TODO: Consider adding support for direct nested css selectors in the future
// Ensure no > in selector as we don't support nested CSS selectors for now
if (key.find('>') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for attribute css selectors in the future
// Ensure no [ in selector as we don't support attribute CSS selectors for now
if (key.find('[') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for pseudo selectors in the future
// Ensure no : in selector as we don't support pseudo CSS selectors for now
if (key.find(':') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for ID css selectors in the future
// Ensure no # in selector as we don't support ID CSS selectors for now
if (key.find('#') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for general sibling combinator selectors in the future
// Ensure no ~ in selector as we don't support general sibling combinator CSS selectors for now
if (key.find('~') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for wildcard css selectors in the future
// Ensure no * in selector as we don't support wildcard CSS selectors for now
if (key.find('*') != std::string_view::npos) {
continue;
}
// TODO: Add support for more complex selectors in the future
// At the moment, we only ever check for `tag`, `tag.class1` or `.class1`
// If the selector has whitespace in it, then it's either a CSS selector for a descendant element (e.g. `tag1 tag2`)
// or some other slightly more advanced CSS selector which we don't support yet
if (key.find(' ') != std::string_view::npos) {
continue;
}
// Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
return;
}
// Store or merge with existing
auto it = rulesBySelector_.find(key);
if (it != rulesBySelector_.end()) {
it->second.applyOver(style);
} else {
rulesBySelector_[key] = style;
}
}
// Store or merge with existing. Hash/equal are case-insensitive, so two
// selectors that differ only in ASCII case collide on insert and merge.
auto it = rulesBySelector_.find(sel);
if (it != rulesBySelector_.end()) {
it->second.applyOver(style);
} else {
rulesBySelector_.emplace(std::string(sel), style);
}
});
}
// Main parsing entry point
bool CssParser::loadFromStream(FsFile& source) {
bool CssParser::loadFromStream(HalFile& source) {
if (!source) {
LOG_ERR("CSS", "Cannot read from invalid file");
return false;
@@ -471,9 +496,6 @@ bool CssParser::loadFromStream(FsFile& source) {
// Use stack-allocated buffers for parsing to avoid heap reallocations
StackBuffer selector;
StackBuffer declBuffer;
// Keep these as std::string since they're passed by reference to parseDeclarationIntoStyle
std::string propNameBuf;
std::string propValueBuf;
bool inComment = false;
bool maybeSlash = false;
@@ -530,10 +552,10 @@ bool CssParser::loadFromStream(FsFile& source) {
--bodyDepth;
if (bodyDepth == 0) {
if (!skippingRule && !declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
parseDeclarationIntoStyle(declBuffer, currentStyle);
}
if (!skippingRule) {
processRuleBlockWithStyle(selector.str(), currentStyle);
processRuleBlockWithStyle(selector, currentStyle);
}
selector.clear();
declBuffer.clear();
@@ -548,7 +570,7 @@ bool CssParser::loadFromStream(FsFile& source) {
if (!skippingRule) {
if (c == ';') {
if (!declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
parseDeclarationIntoStyle(declBuffer, currentStyle);
declBuffer.clear();
}
} else {
@@ -608,7 +630,7 @@ bool CssParser::loadFromStream(FsFile& source) {
// Style resolution
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
CssStyle CssParser::resolveStyle(std::string_view tagName, std::string_view classAttr) const {
static bool lowHeapWarningLogged = false;
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) {
if (!lowHeapWarningLogged) {
@@ -618,47 +640,40 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
}
return CssStyle{};
}
CssStyle result;
const std::string tag = normalized(tagName);
// 1. Apply element-level style (lowest priority)
const auto tagIt = rulesBySelector_.find(tag);
if (tagIt != rulesBySelector_.end()) {
result.applyOver(tagIt->second);
CssStyle result;
// 1. Apply element-level style (lowest priority). The map's hash/equal are
// case-insensitive, so the raw tagName view can be used as the lookup key.
if (auto it = rulesBySelector_.find(tagName); it != rulesBySelector_.end()) {
result.applyOver(it->second);
}
if (classAttr.empty()) return result;
// TODO: Support combinations of classes (e.g. style on .class1.class2)
// 2. Apply class styles (medium priority)
if (!classAttr.empty()) {
const auto classes = splitWhitespace(classAttr);
for (const auto& cls : classes) {
std::string classKey = "." + normalized(cls);
auto classIt = rulesBySelector_.find(classKey);
if (classIt != rulesBySelector_.end()) {
result.applyOver(classIt->second);
}
// 2. Apply class styles (medium priority). The transparent hash/equal accept
// a CompositeKey, so we never materialize the concatenation.
forEachDelimitedToken(classAttr, isCssWhitespace, [&](std::string_view cls) {
if (auto it = rulesBySelector_.find(CompositeKey{".", cls}); it != rulesBySelector_.end()) {
result.applyOver(it->second);
}
});
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
// 3. Apply element.class styles (higher priority)
for (const auto& cls : classes) {
std::string combinedKey = tag + "." + normalized(cls);
auto combinedIt = rulesBySelector_.find(combinedKey);
if (combinedIt != rulesBySelector_.end()) {
result.applyOver(combinedIt->second);
}
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
// 3. Apply element.class styles (higher priority).
forEachDelimitedToken(classAttr, isCssWhitespace, [&](std::string_view cls) {
if (auto it = rulesBySelector_.find(CompositeKey{tagName, ".", cls}); it != rulesBySelector_.end()) {
result.applyOver(it->second);
}
}
});
return result;
}
// Inline style parsing (static - doesn't need rule database)
CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return parseDeclarations(styleValue); }
CssStyle CssParser::parseInlineStyle(std::string_view styleValue) { return parseDeclarations(styleValue); }
// Cache serialization
@@ -676,7 +691,7 @@ bool CssParser::saveToCache() const {
return false;
}
FsFile file;
HalFile file;
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
return false;
}
@@ -701,6 +716,7 @@ bool CssParser::saveToCache() const {
file.write(static_cast<uint8_t>(style.fontStyle));
file.write(static_cast<uint8_t>(style.fontWeight));
file.write(static_cast<uint8_t>(style.textDecoration));
file.write(static_cast<uint8_t>(style.direction));
// Write CssLength fields (value + unit)
auto writeLength = [&file](const CssLength& len) {
@@ -720,9 +736,10 @@ bool CssParser::saveToCache() const {
writeLength(style.imageHeight);
writeLength(style.imageWidth);
file.write(static_cast<uint8_t>(style.display));
file.write(static_cast<uint8_t>(style.verticalAlign));
// Write defined flags as uint16_t
uint16_t definedBits = 0;
// Write defined flags as uint32_t
uint32_t definedBits = 0;
if (style.defined.textAlign) definedBits |= 1 << 0;
if (style.defined.fontStyle) definedBits |= 1 << 1;
if (style.defined.fontWeight) definedBits |= 1 << 2;
@@ -739,6 +756,8 @@ bool CssParser::saveToCache() const {
if (style.defined.imageHeight) definedBits |= 1 << 13;
if (style.defined.imageWidth) definedBits |= 1 << 14;
if (style.defined.display) definedBits |= 1 << 15;
if (style.defined.direction) definedBits |= 1 << 16;
if (style.defined.verticalAlign) definedBits |= 1 << 17;
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
}
@@ -751,7 +770,7 @@ bool CssParser::loadFromCache() {
return false;
}
FsFile file;
HalFile file;
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
return false;
}
@@ -789,7 +808,7 @@ bool CssParser::loadFromCache() {
constexpr size_t CSS_LENGTH_FIELD_COUNT = 11;
constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t);
constexpr size_t CSS_FIXED_STYLE_BYTES =
4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t);
5 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint32_t);
// Read each rule
for (uint16_t i = 0; i < ruleCount; ++i) {
@@ -851,6 +870,12 @@ bool CssParser::loadFromCache() {
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
return false;
}
style.direction = static_cast<CssTextDirection>(enumVal);
// Read CssLength fields
auto readLength = [&file](CssLength& len) -> bool {
if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) {
@@ -880,8 +905,16 @@ bool CssParser::loadFromCache() {
}
style.display = static_cast<CssDisplay>(displayVal);
// Read verticalAlign value
uint8_t verticalAlignVal;
if (file.read(&verticalAlignVal, 1) != 1) {
rulesBySelector_.clear();
return false;
}
style.verticalAlign = static_cast<CssVerticalAlign>(verticalAlignVal);
// Read defined flags
uint16_t definedBits = 0;
uint32_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear();
return false;
@@ -902,6 +935,8 @@ bool CssParser::loadFromCache() {
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
style.defined.display = (definedBits & 1 << 15) != 0;
style.defined.direction = (definedBits & 1 << 16) != 0;
style.defined.verticalAlign = (definedBits & 1 << 17) != 0;
rulesBySelector_[selector] = style;
}
+48 -22
View File
@@ -2,7 +2,9 @@
#include <HalStorage.h>
#include <initializer_list>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
@@ -31,7 +33,7 @@
class CssParser {
public:
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 4;
static constexpr uint8_t CSS_CACHE_VERSION = 6;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
@@ -46,7 +48,7 @@ class CssParser {
* @param source Open file handle to read from
* @return true if parsing completed (even if no rules found)
*/
bool loadFromStream(FsFile& source);
bool loadFromStream(HalFile& source);
/**
* Look up the style for an HTML element, considering tag name and class attributes.
@@ -56,14 +58,14 @@ class CssParser {
* @param classAttr The class attribute value (may contain multiple space-separated classes)
* @return Combined style with all applicable rules merged
*/
[[nodiscard]] CssStyle resolveStyle(const std::string& tagName, const std::string& classAttr) const;
[[nodiscard]] CssStyle resolveStyle(std::string_view tagName, std::string_view classAttr) const;
/**
* Parse an inline style attribute string.
* @param styleValue The value of a style="" attribute
* @return Parsed style properties
*/
[[nodiscard]] static CssStyle parseInlineStyle(const std::string& styleValue);
[[nodiscard]] static CssStyle parseInlineStyle(std::string_view styleValue);
/**
* Check if any rules have been loaded
@@ -104,29 +106,53 @@ class CssParser {
bool loadFromCache();
private:
// Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_;
// Lookup key for a multi-piece selector. The pieces are hashed and compared
// as if concatenated, so callers can look up composite keys without
// materializing the concatenation in a scratch buffer. Constructed from a
// braced list of any arity, e.g. `CompositeKey{tagName, ".", cls}` or
// `CompositeKey{".", cls}`. The initializer_list's backing array lives for
// the full expression, which covers the lifetime of the find() call.
struct CompositeKey {
std::initializer_list<std::string_view> pieces;
CompositeKey(std::initializer_list<std::string_view> p) noexcept : pieces(p) {}
};
// ASCII-case-insensitive transparent hash/equal. Stored selectors and lookup
// keys are compared without regard to case, so callers may insert and look up
// using whatever case the CSS source or HTML element name happens to use.
// Bodies live in CssParser.cpp so they can share the file-local asciiToLower.
struct SvHash {
using is_transparent = void;
size_t operator()(std::string_view sv) const noexcept;
size_t operator()(const std::string& s) const noexcept;
size_t operator()(CompositeKey k) const noexcept;
};
struct SvEqual {
using is_transparent = void;
bool operator()(std::string_view a, std::string_view b) const noexcept;
bool operator()(const std::string& a, std::string_view b) const noexcept;
bool operator()(std::string_view a, const std::string& b) const noexcept;
bool operator()(const std::string& a, const std::string& b) const noexcept;
bool operator()(CompositeKey a, std::string_view b) const noexcept;
bool operator()(std::string_view a, CompositeKey b) const noexcept;
};
// Storage: maps selector -> style properties. Hash/equal are case-insensitive.
std::unordered_map<std::string, CssStyle, SvHash, SvEqual> rulesBySelector_;
std::string cachePath;
// Internal parsing helpers
void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
static CssStyle parseDeclarations(const std::string& declBlock);
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf);
void processRuleBlockWithStyle(std::string_view selectorGroup, const CssStyle& style);
static CssStyle parseDeclarations(std::string_view declBlock);
static void parseDeclarationIntoStyle(std::string_view decl, CssStyle& style);
// Individual property value parsers
static CssTextAlign interpretAlignment(const std::string& val);
static CssFontStyle interpretFontStyle(const std::string& val);
static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val);
static CssLength interpretLength(const std::string& val);
static CssTextAlign interpretAlignment(std::string_view val);
static CssFontStyle interpretFontStyle(std::string_view val);
static CssFontWeight interpretFontWeight(std::string_view val);
static CssTextDecoration interpretDecoration(std::string_view val);
static CssLength interpretLength(std::string_view val);
/** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */
static bool tryInterpretLength(const std::string& val, CssLength& out);
// String utilities
static std::string normalized(const std::string& s);
static void normalizedInto(const std::string& s, std::string& out);
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
static std::vector<std::string> splitWhitespace(const std::string& s);
static bool tryInterpretLength(std::string_view val, CssLength& out);
};
+30 -4
View File
@@ -5,6 +5,7 @@
// Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings
enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 };
enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3, Percent = 4 };
enum class CssTextDirection : uint8_t { Ltr = 0, Rtl = 1 };
// Represents a CSS length value with its unit, allowing deferred resolution to pixels
struct CssLength {
@@ -57,6 +58,9 @@ enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Display options - only None and Block are relevant for e-ink rendering
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
// Vertical alignment options for inline elements (e.g. superscript/subscript)
enum class CssVerticalAlign : uint8_t { Baseline = 0, Super = 1, Sub = 2 };
// Bitmask for tracking which properties have been explicitly set
struct CssPropertyFlags {
uint16_t textAlign : 1;
@@ -75,6 +79,8 @@ struct CssPropertyFlags {
uint16_t imageHeight : 1;
uint16_t imageWidth : 1;
uint16_t display : 1;
uint16_t direction : 1;
uint16_t verticalAlign : 1;
CssPropertyFlags()
: textAlign(0),
@@ -92,22 +98,28 @@ struct CssPropertyFlags {
paddingRight(0),
imageHeight(0),
imageWidth(0),
display(0) {}
display(0),
direction(0),
verticalAlign(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
imageWidth || display;
imageWidth || display || direction || verticalAlign;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
imageHeight = imageWidth = display = 0;
imageHeight = imageWidth = display = direction = verticalAlign = 0;
}
};
// Cache serializes defined flags as uint32_t with bit indices 0..17.
static_assert(sizeof(CssPropertyFlags) <= sizeof(uint32_t),
"CssPropertyFlags exceeds 32 bits; update cache read/write in CssParser.cpp");
// Represents a collection of CSS style properties
// Only stores properties relevant to e-ink text rendering
// Length values are stored as CssLength (value + unit) for deferred resolution
@@ -116,6 +128,7 @@ struct CssStyle {
CssFontStyle fontStyle = CssFontStyle::Normal;
CssFontWeight fontWeight = CssFontWeight::Normal;
CssTextDecoration textDecoration = CssTextDecoration::None;
CssTextDirection direction = CssTextDirection::Ltr;
CssLength textIndent; // First-line indent (deferred resolution)
CssLength marginTop; // Vertical spacing before block
@@ -128,7 +141,8 @@ struct CssStyle {
CssLength paddingRight; // Padding right
CssLength imageHeight; // Height for img (e.g. 2em) width derived from aspect ratio when only height set
CssLength imageWidth; // Width for img when both or only width set
CssDisplay display = CssDisplay::Block; // display property (Block or None)
CssDisplay display = CssDisplay::Block; // display property (Block or None)
CssVerticalAlign verticalAlign = CssVerticalAlign::Baseline; // vertical-align (super/sub positioning)
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -199,6 +213,14 @@ struct CssStyle {
display = base.display;
defined.display = 1;
}
if (base.hasDirection()) {
direction = base.direction;
defined.direction = 1;
}
if (base.hasVerticalAlign()) {
verticalAlign = base.verticalAlign;
defined.verticalAlign = 1;
}
}
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -217,17 +239,21 @@ struct CssStyle {
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
[[nodiscard]] bool hasDisplay() const { return defined.display; }
[[nodiscard]] bool hasDirection() const { return defined.direction; }
[[nodiscard]] bool hasVerticalAlign() const { return defined.verticalAlign; }
void reset() {
textAlign = CssTextAlign::Left;
fontStyle = CssFontStyle::Normal;
fontWeight = CssFontWeight::Normal;
textDecoration = CssTextDecoration::None;
direction = CssTextDirection::Ltr;
textIndent = CssLength{};
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
imageHeight = imageWidth = CssLength{};
display = CssDisplay::Block;
verticalAlign = CssVerticalAlign::Baseline;
defined.clearAll();
}
};
+51 -48
View File
@@ -13,54 +13,57 @@ struct EntityPair {
// Sorted lexicographically by key to allow binary search.
static constexpr EntityPair ENTITY_LOOKUP[] = {
{"&AElig;", "Æ"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Agrave;", "À"}, {"&Alpha;", "Α"},
{"&Aring;", "Å"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"}, {"&Beta;", "Β"}, {"&Ccedil;", "Ç"},
{"&Chi;", "Χ"}, {"&Dagger;", ""}, {"&Delta;", "Δ"}, {"&ETH;", "Ð"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Egrave;", "È"}, {"&Epsilon;", "Ε"}, {"&Eta;", "Η"}, {"&Euml;", "Ë"},
{"&Gamma;", "Γ"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"}, {"&Igrave;", "Ì"}, {"&Iota;", "Ι"},
{"&Iuml;", "Ï"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"}, {"&Ntilde;", "Ñ"},
{"&Nu;", "Ν"}, {"&OElig;", "Œ"}, {"&Oacute;", "Ó"}, {"&Ocirc;", "Ô"}, {"&Ograve;", "Ò"},
{"&Omega;", "Ω"}, {"&Omicron;", "Ο"}, {"&Oslash;", "Ø"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"},
{"&Phi;", "Φ"}, {"&Pi;", "Π"}, {"&Prime;", ""}, {"&Psi;", "Ψ"}, {"&Rho;", "Ρ"},
{"&Scaron;", "Š"}, {"&Sigma;", "Σ"}, {"&THORN;", "Þ"}, {"&Tau;", "Τ"}, {"&Theta;", "Θ"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Ugrave;", "Ù"}, {"&Upsilon;", "Υ"}, {"&Uuml;", "Ü"},
{"&Xi;", "Ξ"}, {"&Yacute;", "Ý"}, {"&Yuml;", "Ÿ"}, {"&Zeta;", "Ζ"}, {"&aacute;", "á"},
{"&acirc;", "â"}, {"&acute;", "´"}, {"&aelig;", "æ"}, {"&agrave;", "à"}, {"&alpha;", "α"},
{"&amp;", "&"}, {"&and;", ""}, {"&ang;", ""}, {"&aring;", "å"}, {"&asymp;", ""},
{"&atilde;", "ã"}, {"&auml;", "ä"}, {"&bdquo;", ""}, {"&beta;", "β"}, {"&brvbar;", "¦"},
{"&bull;", ""}, {"&cap;", ""}, {"&ccedil;", "ç"}, {"&cedil;", "¸"}, {"&cent;", "¢"},
{"&chi;", "χ"}, {"&circ;", "ˆ"}, {"&clubs;", ""}, {"&cong;", ""}, {"&copy;", "©"},
{"&crarr;", ""}, {"&cup;", ""}, {"&curren;", "¤"}, {"&dagger;", ""}, {"&darr;", ""},
{"&deg;", "°"}, {"&delta;", "δ"}, {"&diams;", ""}, {"&divide;", "÷"}, {"&eacute;", "é"},
{"&ecirc;", "ê"}, {"&egrave;", "è"}, {"&empty;", ""}, {"&emsp;", " "}, {"&ensp;", " "},
{"&epsilon;", "ε"}, {"&equiv;", ""}, {"&eta;", "η"}, {"&eth;", "ð"}, {"&euml;", "ë"},
{"&euro;", ""}, {"&exist;", ""}, {"&fnof;", "ƒ"}, {"&forall;", ""}, {"&frac12;", "½"},
{"&frac14;", "¼"}, {"&frac34;", "¾"}, {"&frasl;", ""}, {"&gamma;", "γ"}, {"&ge;", ""},
{"&gt;", ">"}, {"&harr;", ""}, {"&hearts;", ""}, {"&hellip;", ""}, {"&iacute;", "í"},
{"&icirc;", "î"}, {"&iexcl;", "¡"}, {"&igrave;", "ì"}, {"&infin;", ""}, {"&int;", ""},
{"&iota;", "ι"}, {"&iquest;", "¿"}, {"&isin;", ""}, {"&iuml;", "ï"}, {"&kappa;", "κ"},
{"&lambda;", "λ"}, {"&laquo;", "«"}, {"&larr;", ""}, {"&lceil;", ""}, {"&ldquo;", "\u201C"},
{"&le;", ""}, {"&lfloor;", ""}, {"&lowast;", ""}, {"&loz;", ""}, {"&lrm;", "\u200E"},
{"&lsaquo;", ""}, {"&lsquo;", "\u2018"}, {"&lt;", "<"}, {"&macr;", "¯"}, {"&mdash;", ""},
{"&micro;", "µ"}, {"&minus;", ""}, {"&mu;", "μ"}, {"&nabla;", ""}, {"&nbsp;", "\xC2\xA0"},
{"&ndash;", ""}, {"&ne;", ""}, {"&ni;", ""}, {"&not;", "¬"}, {"&notin;", ""},
{"&nsub;", ""}, {"&ntilde;", "ñ"}, {"&nu;", "ν"}, {"&oacute;", "ó"}, {"&ocirc;", "ô"},
{"&oelig;", "œ"}, {"&ograve;", "ò"}, {"&oline;", ""}, {"&omega;", "ω"}, {"&omicron;", "ο"},
{"&oplus;", ""}, {"&or;", ""}, {"&ordf;", "ª"}, {"&ordm;", "º"}, {"&oslash;", "ø"},
{"&otilde;", "õ"}, {"&otimes;", ""}, {"&ouml;", "ö"}, {"&para;", ""}, {"&part;", ""},
{"&permil;", ""}, {"&perp;", ""}, {"&phi;", "φ"}, {"&pi;", "π"}, {"&piv;", "ϖ"},
{"&plusmn;", "±"}, {"&pound;", "£"}, {"&prime;", ""}, {"&prod;", ""}, {"&prop;", ""},
{"&psi;", "ψ"}, {"&quot;", "\""}, {"&radic;", ""}, {"&raquo;", "»"}, {"&rarr;", ""},
{"&rceil;", ""}, {"&rdquo;", "\u201D"}, {"&reg;", "®"}, {"&rfloor;", ""}, {"&rho;", "ρ"},
{"&rlm;", "\u200F"}, {"&rsaquo;", ""}, {"&rsquo;", "\u2019"}, {"&sbquo;", ""}, {"&scaron;", "š"},
{"&sdot;", ""}, {"&sect;", "§"}, {"&shy;", "\xC2\xAD"}, {"&sigma;", "σ"}, {"&sigmaf;", "ς"},
{"&sim;", ""}, {"&spades;", ""}, {"&sub;", ""}, {"&sube;", ""}, {"&sum;", ""},
{"&sup1;", "¹"}, {"&sup2;", "²"}, {"&sup3;", "³"}, {"&sup;", ""}, {"&supe;", ""},
{"&szlig;", "ß"}, {"&tau;", "τ"}, {"&there4;", ""}, {"&theta;", "θ"}, {"&thetasym;", "ϑ"},
{"&thinsp;", " "}, {"&thorn;", "þ"}, {"&tilde;", "˜"}, {"&times;", "×"}, {"&trade;", ""},
{"&uacute;", "ú"}, {"&uarr;", ""}, {"&ucirc;", "û"}, {"&ugrave;", "ù"}, {"&uml;", "¨"},
{"&upsih;", "ϒ"}, {"&upsilon;", "υ"}, {"&uuml;", "ü"}, {"&xi;", "ξ"}, {"&yacute;", "ý"},
{"&yen;", "¥"}, {"&yuml;", "ÿ"}, {"&zeta;", "ζ"}, {"&zwj;", "\u200D"}, {"&zwnj;", "\u200C"},
{"&AElig;", "Æ"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Agrave;", "À"}, {"&Alpha;", "Α"},
{"&Aring;", "Å"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"}, {"&Beta;", "Β"}, {"&Ccedil;", "Ç"},
{"&Chi;", "Χ"}, {"&Dagger;", ""}, {"&Delta;", "Δ"}, {"&ETH;", "Ð"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Egrave;", "È"}, {"&Epsilon;", "Ε"}, {"&Eta;", "Η"}, {"&Euml;", "Ë"},
{"&Gamma;", "Γ"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"}, {"&Igrave;", "Ì"}, {"&Iota;", "Ι"},
{"&Iuml;", "Ï"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"}, {"&Ntilde;", "Ñ"},
{"&Nu;", "Ν"}, {"&OElig;", "Œ"}, {"&Oacute;", "Ó"}, {"&Ocirc;", "Ô"}, {"&Ograve;", "Ò"},
{"&Omega;", "Ω"}, {"&Omicron;", "Ο"}, {"&Oslash;", "Ø"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"},
{"&Phi;", "Φ"}, {"&Pi;", "Π"}, {"&Prime;", ""}, {"&Psi;", "Ψ"}, {"&Rho;", "Ρ"},
{"&Scaron;", "Š"}, {"&Sigma;", "Σ"}, {"&THORN;", "Þ"}, {"&Tau;", "Τ"}, {"&Theta;", "Θ"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Ugrave;", "Ù"}, {"&Upsilon;", "Υ"}, {"&Uuml;", "Ü"},
{"&Xi;", "Ξ"}, {"&Yacute;", "Ý"}, {"&Yuml;", "Ÿ"}, {"&Zeta;", "Ζ"}, {"&aacute;", "á"},
{"&acirc;", "â"}, {"&acute;", "´"}, {"&aelig;", "æ"}, {"&agrave;", "à"}, {"&alefsym;", ""},
{"&alpha;", "α"}, {"&amp;", "&"}, {"&and;", ""}, {"&ang;", ""}, {"&aring;", "å"},
{"&asymp;", ""}, {"&atilde;", "ã"}, {"&auml;", "ä"}, {"&bdquo;", ""}, {"&beta;", "β"},
{"&brvbar;", "¦"}, {"&bull;", ""}, {"&cap;", ""}, {"&ccedil;", "ç"}, {"&cedil;", "¸"},
{"&cent;", "¢"}, {"&chi;", "χ"}, {"&circ;", "ˆ"}, {"&clubs;", ""}, {"&cong;", ""},
{"&copy;", "©"}, {"&crarr;", ""}, {"&cup;", ""}, {"&curren;", "¤"}, {"&dArr;", ""},
{"&dagger;", ""}, {"&darr;", ""}, {"&deg;", "°"}, {"&delta;", "δ"}, {"&diams;", ""},
{"&divide;", "÷"}, {"&eacute;", "é"}, {"&ecirc;", "ê"}, {"&egrave;", "è"}, {"&empty;", ""},
{"&emsp;", " "}, {"&ensp;", " "}, {"&epsilon;", "ε"}, {"&equiv;", ""}, {"&eta;", "η"},
{"&eth;", "ð"}, {"&euml;", "ë"}, {"&euro;", ""}, {"&exist;", ""}, {"&fnof;", "ƒ"},
{"&forall;", ""}, {"&frac12;", "½"}, {"&frac14;", "¼"}, {"&frac34;", "¾"}, {"&frasl;", ""},
{"&gamma;", "γ"}, {"&ge;", ""}, {"&gt;", ">"}, {"&hArr;", ""}, {"&harr;", ""},
{"&hearts;", ""}, {"&hellip;", ""}, {"&iacute;", "í"}, {"&icirc;", "î"}, {"&iexcl;", "¡"},
{"&igrave;", "ì"}, {"&image;", ""}, {"&infin;", ""}, {"&int;", ""}, {"&iota;", "ι"},
{"&iquest;", "¿"}, {"&isin;", ""}, {"&iuml;", "ï"}, {"&kappa;", "κ"}, {"&lArr;", ""},
{"&lambda;", "λ"}, {"&lang;", ""}, {"&laquo;", "«"}, {"&larr;", ""}, {"&lceil;", ""},
{"&ldquo;", "\u201C"}, {"&le;", ""}, {"&lfloor;", ""}, {"&lowast;", ""}, {"&loz;", ""},
{"&lrm;", "\u200E"}, {"&lsaquo;", ""}, {"&lsquo;", "\u2018"}, {"&lt;", "<"}, {"&macr;", "¯"},
{"&mdash;", ""}, {"&micro;", "µ"}, {"&middot;", "·"}, {"&minus;", ""}, {"&mu;", "μ"},
{"&nabla;", ""}, {"&nbsp;", "\xC2\xA0"}, {"&ndash;", ""}, {"&ne;", ""}, {"&ni;", ""},
{"&not;", "¬"}, {"&notin;", ""}, {"&nsub;", ""}, {"&ntilde;", "ñ"}, {"&nu;", "ν"},
{"&oacute;", "ó"}, {"&ocirc;", "ô"}, {"&oelig;", "œ"}, {"&ograve;", "ò"}, {"&oline;", ""},
{"&omega;", "ω"}, {"&omicron;", "ο"}, {"&oplus;", ""}, {"&or;", ""}, {"&ordf;", "ª"},
{"&ordm;", "º"}, {"&oslash;", "ø"}, {"&otilde;", "õ"}, {"&otimes;", ""}, {"&ouml;", "ö"},
{"&para;", ""}, {"&part;", ""}, {"&permil;", ""}, {"&perp;", ""}, {"&phi;", "φ"},
{"&pi;", "π"}, {"&piv;", "ϖ"}, {"&plusmn;", "±"}, {"&pound;", "£"}, {"&prime;", ""},
{"&prod;", ""}, {"&prop;", ""}, {"&psi;", "ψ"}, {"&quot;", "\""}, {"&rArr;", ""},
{"&radic;", ""}, {"&rang;", ""}, {"&raquo;", "»"}, {"&rarr;", ""}, {"&rceil;", ""},
{"&rdquo;", "\u201D"}, {"&real;", "\u211C"}, {"&reg;", "®"}, {"&rfloor;", ""}, {"&rho;", "ρ"},
{"&rlm;", "\u200F"}, {"&rsaquo;", ""}, {"&rsquo;", "\u2019"}, {"&sbquo;", ""}, {"&scaron;", "š"},
{"&sdot;", ""}, {"&sect;", "§"}, {"&shy;", "\xC2\xAD"}, {"&sigma;", "σ"}, {"&sigmaf;", "ς"},
{"&sim;", ""}, {"&spades;", ""}, {"&sub;", ""}, {"&sube;", ""}, {"&sum;", ""},
{"&sup1;", "¹"}, {"&sup2;", "²"}, {"&sup3;", "³"}, {"&sup;", ""}, {"&supe;", ""},
{"&szlig;", "ß"}, {"&tau;", "τ"}, {"&there4;", ""}, {"&theta;", "θ"}, {"&thetasym;", "ϑ"},
{"&thinsp;", " "}, {"&thorn;", "þ"}, {"&tilde;", "˜"}, {"&times;", "×"}, {"&trade;", ""},
{"&uArr;", ""}, {"&uacute;", "ú"}, {"&uarr;", ""}, {"&ucirc;", "û"}, {"&ugrave;", "ù"},
{"&uml;", "¨"}, {"&upsih;", "ϒ"}, {"&upsilon;", "υ"}, {"&uuml;", "ü"}, {"&weierp;", ""},
{"&xi;", "ξ"}, {"&yacute;", "ý"}, {"&yen;", "¥"}, {"&yuml;", "ÿ"}, {"&zeta;", "ζ"},
{"&zwj;", "\u200D"}, {"&zwnj;", "\u200C"},
};
// Verify the table is sorted at compile time.
+157 -16
View File
@@ -22,6 +22,13 @@
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024;
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
// chapter. A runaway count usually means a converter injected machine-generated IDs on
// every text fragment (e.g. Kobo KePub spans). The cap prevents unbounded heap growth
// on resource-constrained devices (~380KB heap). TOC anchors bypass this cap.
constexpr size_t MAX_ANCHORS_PER_CHAPTER = 1024;
constexpr const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
@@ -49,6 +56,14 @@ const char* getAttribute(const XML_Char** atts, const char* attrName) {
return nullptr;
}
// Returns true if the HTML element is a purely inline, non-navigable wrapper.
// IDs on these elements are never meaningful navigation targets in epub content.
// Reading-system converters (Kobo KePub, Calibre, etc.) frequently inject thousands
// of such IDs for progress tracking or internal bookkeeping, and recording each one
// as a navigation anchor exhausts the heap on memory-constrained devices.
// Block-level, sectioning, and structural elements are always considered navigable.
bool isNonNavigableInlineElement(const char* name) { return strcmp(name, "span") == 0; }
bool isInternalEpubLink(const char* href) {
if (!href || href[0] == '\0') return false;
if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false;
@@ -67,6 +82,13 @@ bool isTableStructuralTag(const char* name) {
return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0;
}
void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css) {
if (css.hasDirection()) {
entry.hasDirection = true;
entry.direction = css.direction;
}
}
// Update effective bold/italic/underline based on block style and inline style stack
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Start with block-level styles
@@ -74,6 +96,10 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
effectiveUnderline =
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
effectiveDirectionDefined = currentCssStyle.hasDirection();
effectiveDirection = currentCssStyle.direction;
effectiveSup = false;
effectiveSub = false;
// Apply inline style stack in order
for (const auto& entry : inlineStyleStack) {
@@ -86,7 +112,51 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
if (entry.hasUnderline) {
effectiveUnderline = entry.underline;
}
if (entry.hasDirection) {
effectiveDirectionDefined = true;
effectiveDirection = entry.direction;
}
if (entry.hasSup) {
effectiveSup = entry.sup;
if (entry.sup) effectiveSub = false;
}
if (entry.hasSub) {
effectiveSub = entry.sub;
if (entry.sub) effectiveSup = false;
}
}
// Keep inherited direction in the active empty text block so upcoming block starts
// can inherit from non-block ancestors such as <html dir="rtl"> / <body dir="rtl">.
if (currentTextBlock && currentTextBlock->isEmpty()) {
auto& style = currentTextBlock->getBlockStyle();
if (effectiveDirectionDefined) {
style.directionDefined = true;
style.isRtl = (effectiveDirection == CssTextDirection::Rtl);
} else {
style.directionDefined = false;
style.isRtl = false;
}
}
}
void ChapterHtmlSlimParser::flushPendingAnchor() {
if (pendingAnchorId.empty()) return;
// If the pending anchor is a TOC chapter boundary, force a page break after the previous
// block is flushed so the chapter starts on a fresh page.
if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex);
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
}
// Record deferred anchor after previous block is flushed (and any TOC page break)
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
// flush the contents of partWordBuffer to currentTextBlock
@@ -107,6 +177,11 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (isUnderline) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
}
if (effectiveSup) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
} else if (effectiveSub) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUB);
}
// flush the buffer
partWordBuffer[partWordBufferIndex] = '\0';
@@ -129,20 +204,15 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
const auto style = currentTextBlock->getBlockStyle();
currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
flushPendingAnchor();
return;
}
makePages();
}
// Record deferred anchor after previous block is flushed
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
// If the pending anchor is a TOC chapter boundary, force a page break after the previous
// block is flushed so the chapter starts on a fresh page.
flushPendingAnchor();
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
@@ -225,9 +295,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->xpathListItemIndex++;
}
// Extract class, style, and id attributes
// Extract class, style, id, and dir attributes for CSS/RTL processing
std::string classAttr;
std::string styleAttr;
std::string dirAttr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "class") == 0) {
@@ -235,8 +306,29 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer recording until startNewTextBlock, after previous block is flushed to pages
self->pendingAnchorId = atts[i + 1];
// Defer both anchor recording and TOC page breaks until startNewTextBlock,
// after the previous block is flushed to pages via makePages().
//
// Skip IDs on non-navigable inline elements (e.g. <span>): these are never
// link targets in epub content, but reading-system converters can inject tens
// of thousands of them per chapter, exhausting the heap. TOC anchors are
// always recorded regardless of element type, since they drive page breaks.
const char* idValue = atts[i + 1];
const bool isTocAnchor =
std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) {
// Flush a displaced anchor before overwriting. Consecutive non-block elements
// (e.g. <aside id="fn1">text</aside><aside id="fn2">) with no intervening block
// never trigger startNewTextBlock, so fn1 gets silently overwritten. That leaves
// fn1 missing from the anchor map -> getPageForAnchor returns nullopt -> reader
// lands at page 0 (section start) instead of the footnote.
if (!self->pendingAnchorId.empty()) {
self->flushPendingAnchor();
}
self->pendingAnchorId = idValue;
}
} else if (strcmp(atts[i], "dir") == 0) {
dirAttr = atts[i + 1];
}
}
}
@@ -256,6 +348,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
// HTML dir attribute overrides CSS direction (case-insensitive per HTML spec)
if (!dirAttr.empty()) {
if (strcasecmp(dirAttr.c_str(), "rtl") == 0) {
cssStyle.direction = CssTextDirection::Rtl;
cssStyle.defined.direction = 1;
} else if (strcasecmp(dirAttr.c_str(), "ltr") == 0) {
cssStyle.direction = CssTextDirection::Ltr;
cssStyle.defined.direction = 1;
}
}
// Direction is inherited in HTML/CSS. If this element does not define one, carry
// the currently active inherited direction into its computed style.
if (!cssStyle.hasDirection() && self->effectiveDirectionDefined) {
cssStyle.direction = self->effectiveDirection;
cssStyle.defined.direction = 1;
}
// Skip elements with display:none before all fast paths (tables, links, etc.).
if (cssStyle.hasDisplay() && cssStyle.display == CssDisplay::None) {
self->skipUntilDepth = self->depth;
@@ -368,7 +478,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
{
// Resolve the image path relative to the HTML file
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
std::string resolvedPath = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->contentBase + src));
if (ImageDecoderFactory::isFormatSupported(resolvedPath)) {
// Create a unique filename for the cached image
@@ -380,7 +490,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
// Extract image to cache file
FsFile cachedImageFile;
HalFile cachedImageFile;
bool extractSuccess = false;
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
@@ -656,6 +766,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.depth = self->depth;
entry.hasUnderline = true;
entry.underline = true;
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
@@ -740,6 +851,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
@@ -762,6 +874,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS))) {
@@ -784,11 +897,29 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "sup") == 0 || strcmp(name, "sub") == 0) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
StyleStackEntry entry;
entry.depth = self->depth;
if (strcmp(name, "sup") == 0) {
entry.hasSup = true;
entry.sup = true;
} else {
entry.hasSub = true;
entry.sub = true;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
// Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration() ||
cssStyle.hasDirection() || cssStyle.hasVerticalAlign()) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
@@ -808,6 +939,16 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
applyDirectionToEntry(entry, cssStyle);
if (cssStyle.hasVerticalAlign()) {
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
entry.hasSup = true;
entry.sup = true;
} else if (cssStyle.verticalAlign == CssVerticalAlign::Sub) {
entry.hasSub = true;
entry.sub = true;
}
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
}
@@ -1150,7 +1291,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
FsFile file;
HalFile file;
if (!Storage.openFileForRead("EHP", filepath, file)) {
destroyXmlParser(parser);
return false;
+15 -2
View File
@@ -61,6 +61,10 @@ class ChapterHtmlSlimParser {
bool hasBold = false, bold = false;
bool hasItalic = false, italic = false;
bool hasUnderline = false, underline = false;
bool hasDirection = false;
CssTextDirection direction = CssTextDirection::Ltr;
bool hasSup = false, sup = false;
bool hasSub = false, sub = false;
};
std::vector<StyleStackEntry> inlineStyleStack;
std::vector<BlockStyle> blockStyleStack; // accumulated block styles from open ancestor elements
@@ -68,6 +72,10 @@ class ChapterHtmlSlimParser {
bool effectiveBold = false;
bool effectiveItalic = false;
bool effectiveUnderline = false;
bool effectiveDirectionDefined = false;
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
bool effectiveSup = false;
bool effectiveSub = false;
int tableDepth = 0;
int tableRowIndex = 0;
int tableColIndex = 0;
@@ -75,7 +83,8 @@ class ChapterHtmlSlimParser {
// Anchor-to-page mapping: tracks which page each HTML id attribute lands on
int completedPageCount = 0;
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
std::string pendingAnchorId; // deferred until after previous text block is flushed
std::vector<std::string> tocAnchors; // the list of anchors that are TOC chapter boundaries
uint16_t xpathParagraphIndex = 0;
uint16_t xpathListItemIndex = 0;
@@ -89,8 +98,10 @@ class ChapterHtmlSlimParser {
void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPendingAnchor();
void flushPartWordBuffer();
void makePages();
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
void emitHorizontalRule(const BlockStyle& blockStyle);
// XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
@@ -107,6 +118,7 @@ class ChapterHtmlSlimParser {
const std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)>& completePageFn,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const uint8_t imageRendering = 0,
std::vector<std::string> tocAnchors = {},
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
: epub(epub),
@@ -126,7 +138,8 @@ class ChapterHtmlSlimParser {
embeddedStyle(embeddedStyle),
imageRendering(imageRendering),
contentBase(contentBase),
imageBasePath(imageBasePath) {}
imageBasePath(imageBasePath),
tocAnchors(std::move(tocAnchors)) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
+29 -3
View File
@@ -5,12 +5,31 @@
#include <Serialization.h>
#include <XmlParserUtils.h>
#include <cctype>
#include "Epub/BookMetadataCache.h"
namespace {
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
constexpr char MEDIA_TYPE_CSS[] = "text/css";
constexpr char MEDIA_TYPE_IMAGE_PREFIX[] = "image/";
constexpr char itemCacheFile[] = "/.items.bin";
bool startsWithImageMediaType(const std::string& mediaType) {
constexpr size_t prefixLen = sizeof(MEDIA_TYPE_IMAGE_PREFIX) - 1;
if (mediaType.size() < prefixLen) {
return false;
}
for (size_t i = 0; i < prefixLen; ++i) {
const char c = static_cast<char>(std::tolower(static_cast<unsigned char>(mediaType[i])));
if (c != MEDIA_TYPE_IMAGE_PREFIX[i]) {
return false;
}
}
return true;
}
} // namespace
bool ContentOpfParser::setup() {
@@ -167,7 +186,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (strcmp(atts[i], "id") == 0) {
itemId = atts[i + 1];
} else if (strcmp(atts[i], "href") == 0) {
href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
} else if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1];
} else if (strcmp(atts[i], "properties") == 0) {
@@ -189,7 +208,14 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
serialization::writeString(self->tempItemStore, href);
if (itemId == self->coverItemId) {
self->coverItemHref = href;
// Some EPUBs set meta name="cover" to an XHTML wrapper item.
// Only treat it as a cover image when the manifest media-type is image/*.
if (startsWithImageMediaType(mediaType)) {
self->coverItemHref = href;
} else {
LOG_DBG("COF", "Ignoring meta cover item '%s' with non-image media type: %s", itemId.c_str(),
mediaType.c_str());
}
}
if (mediaType == MEDIA_TYPE_NCX) {
@@ -289,7 +315,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (strcmp(atts[i], "type") == 0) {
type = atts[i + 1];
} else if (strcmp(atts[i], "href") == 0) {
guideHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
guideHref = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(self->baseContentPath + atts[i + 1]));
}
}
if (!guideHref.empty()) {
+1 -1
View File
@@ -29,7 +29,7 @@ class ContentOpfParser final : public Print {
XML_Parser parser = nullptr;
ParserState state = START;
BookMetadataCache* cache;
FsFile tempItemStore;
HalFile tempItemStore;
std::string coverItemId;
// Index for fast idref→href lookup (used only for large EPUBs)
+5 -4
View File
@@ -126,13 +126,14 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
// Create TOC entry when closing anchor tag (we have all data now)
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref);
const std::string rawTarget = self->baseContentPath + self->currentHref;
const size_t pos = rawTarget.find('#');
const std::string rawPath = pos == std::string::npos ? rawTarget : rawTarget.substr(0, pos);
std::string href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(rawPath));
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
anchor = FsHelpers::decodeUriEscapes(rawTarget.substr(pos + 1));
}
if (self->cache) {
+5 -4
View File
@@ -145,13 +145,14 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
// This is the safest place to push the data, assuming <navLabel> always comes before <content>.
// NCX spec says navLabel comes before content.
if (!self->currentLabel.empty() && !self->currentSrc.empty()) {
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc);
const std::string rawTarget = self->baseContentPath + self->currentSrc;
const size_t pos = rawTarget.find('#');
const std::string rawPath = pos == std::string::npos ? rawTarget : rawTarget.substr(0, pos);
std::string href = FsHelpers::normalisePath(FsHelpers::decodeUriEscapes(rawPath));
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
anchor = FsHelpers::decodeUriEscapes(rawTarget.substr(pos + 1));
}
if (self->cache) {
+30 -2
View File
@@ -7,6 +7,34 @@
namespace FsHelpers {
namespace {
bool isHexDigit(const char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }
uint8_t hexValue(const char c) {
if (c >= '0' && c <= '9') return static_cast<uint8_t>(c - '0');
if (c >= 'a' && c <= 'f') return static_cast<uint8_t>(10 + (c - 'a'));
return static_cast<uint8_t>(10 + (c - 'A'));
}
} // namespace
std::string decodeUriEscapes(const std::string& path) {
std::string decoded;
decoded.reserve(path.size());
for (size_t i = 0; i < path.size(); i++) {
if (path[i] == '%' && i + 2 < path.size() && isHexDigit(path[i + 1]) && isHexDigit(path[i + 2])) {
const uint8_t value = static_cast<uint8_t>((hexValue(path[i + 1]) << 4) | hexValue(path[i + 2]));
decoded += static_cast<char>(value);
i += 2;
continue;
}
decoded += path[i];
}
return decoded;
}
std::string normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
@@ -59,8 +87,6 @@ void sortFileList(std::vector<std::string>& strs) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
@@ -131,6 +157,8 @@ bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(file
bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); }
bool hasCssExtension(std::string_view fileName) { return checkFileExtension(fileName, ".css"); }
std::string extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
+7
View File
@@ -7,6 +7,8 @@
namespace FsHelpers {
std::string decodeUriEscapes(const std::string& path);
std::string normalisePath(const std::string& path);
void sortFileList(std::vector<std::string>& strs);
@@ -58,6 +60,11 @@ inline bool hasTxtExtension(const String& fileName) {
// Check for .md extension (case-insensitive)
bool hasMarkdownExtension(std::string_view fileName);
// Check for .css extension (case-insensitive)
bool hasCssExtension(std::string_view fileName);
inline bool hasCssExtension(const String& fileName) {
return hasCssExtension(std::string_view{fileName.c_str(), fileName.length()});
}
std::string extractFolderPath(const std::string& filePath);
/**
+2 -2
View File
@@ -21,7 +21,7 @@ Bitmap::~Bitmap() {
delete fsDitherer;
}
uint16_t Bitmap::readLE16(FsFile& f) {
uint16_t Bitmap::readLE16(HalFile& f) {
const int c0 = f.read();
const int c1 = f.read();
const auto b0 = static_cast<uint8_t>(c0 < 0 ? 0 : c0);
@@ -29,7 +29,7 @@ uint16_t Bitmap::readLE16(FsFile& f) {
return static_cast<uint16_t>(b0) | (static_cast<uint16_t>(b1) << 8);
}
uint32_t Bitmap::readLE32(FsFile& f) {
uint32_t Bitmap::readLE32(HalFile& f) {
const int c0 = f.read();
const int c1 = f.read();
const int c2 = f.read();
+4 -4
View File
@@ -64,7 +64,7 @@ class Bitmap {
public:
static const char* errorToString(BmpReaderError err);
explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {}
explicit Bitmap(HalFile& file, bool dithering = false) : file(file), dithering(dithering) {}
~Bitmap();
BmpReaderError parseHeaders();
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
@@ -78,10 +78,10 @@ class Bitmap {
uint16_t getBpp() const { return bpp; }
private:
static uint16_t readLE16(FsFile& f);
static uint32_t readLE32(FsFile& f);
static uint16_t readLE16(HalFile& f);
static uint32_t readLE32(HalFile& f);
FsFile& file;
HalFile& file;
bool dithering = false;
int width = 0;
int height = 0;
+277 -22
View File
@@ -1,5 +1,6 @@
#include "GfxRenderer.h"
#include <BidiUtils.h>
#include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Logging.h>
@@ -12,8 +13,6 @@
namespace {
const char* resolveVisualText(const char* text, std::string& visualBuffer, int paragraphLevel);
/**
* Resolves the requested style to the best available style in the given SD card font.
* Falls back gracefully when the font lacks the requested variant.
@@ -23,6 +22,10 @@ uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style st
}
} // namespace
namespace {
const char* resolveVisualText(const char* text, std::string& visualBuffer, BidiUtils::BidiBaseDir baseDir);
} // namespace
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr;
@@ -88,6 +91,8 @@ void GfxRenderer::begin() {
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
}
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
auto result = fontMap.insert({fontId, font});
if (!result.second) {
@@ -133,7 +138,81 @@ enum class TextRotation { None, Rotated90CW };
// Shared glyph rendering logic for normal and rotated text.
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
template <TextRotation rotation>
// Render a glyph at 50% scale. Used for SUP/SUB style bits.
//
// Each destination pixel represents a 2x2 source block. Drawing when that block
// contains ink preserves thin strokes that nearest-neighbor sampling can skip.
//
// The advance width is also halved in drawText() so layout reserves exactly the right
// horizontal space for the scaled glyph.
static void renderCharScaled(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) return;
const EpdFontData* fontData = fontFamily.getData(style);
const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
if (!bitmap) return;
const int srcW = glyph->width;
const int srcH = glyph->height;
const int dstW = (srcW + 1) / 2; // ceil so odd-width glyphs aren't clipped
const int dstH = (srcH + 1) / 2;
// Scale the glyph bearing by the same factor so the scaled glyph sits at the correct
// pixel offset from the (already-shifted) cursor position.
const int baseX = cursorX + glyph->left / 2;
const int baseY = cursorY - glyph->top / 2;
if (fontData->is2Bit) {
// 2-bit packed format: 4 pixels per byte, MSB first, 2 bits per pixel.
// raw value: 0=white, 1=light-gray, 2=dark-gray, 3=black.
for (int dstY = 0; dstY < dstH; dstY++) {
const int srcY = dstY * 2;
for (int dstX = 0; dstX < dstW; dstX++) {
const int srcX = dstX * 2;
uint8_t coverage = 0;
uint8_t maxRaw = 0;
for (int sampleY = 0; sampleY < 2 && srcY + sampleY < srcH; sampleY++) {
for (int sampleX = 0; sampleX < 2 && srcX + sampleX < srcW; sampleX++) {
const int pos = (srcY + sampleY) * srcW + srcX + sampleX;
const uint8_t byte = bitmap[pos >> 2];
const uint8_t raw = (byte >> ((3 - (pos & 3)) * 2)) & 0x3;
coverage += raw;
if (raw > maxRaw) maxRaw = raw;
}
}
if (maxRaw >= 2 || coverage >= 2) {
renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState);
}
}
}
} else {
// 1-bit packed format: 8 pixels per byte, MSB first.
for (int dstY = 0; dstY < dstH; dstY++) {
const int srcY = dstY * 2;
for (int dstX = 0; dstX < dstW; dstX++) {
const int srcX = dstX * 2;
bool hasInk = false;
for (int sampleY = 0; sampleY < 2 && srcY + sampleY < srcH; sampleY++) {
for (int sampleX = 0; sampleX < 2 && srcX + sampleX < srcW; sampleX++) {
const int pos = (srcY + sampleY) * srcW + srcX + sampleX;
const uint8_t byte = bitmap[pos >> 3];
const uint8_t bit = 7 - (pos & 7);
if ((byte >> bit) & 1) {
hasInk = true;
}
}
}
if (hasInk) {
renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState);
}
}
}
}
}
template <TextRotation rotation = TextRotation::None>
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
@@ -150,6 +229,23 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
const int left = glyph->left;
const int top = glyph->top;
// Tiled-grayscale band culling: if this glyph's physical y-extent is entirely
// outside the active strip, skip it before the expensive bitmap decode. This
// is what makes per-band re-rendering cheap. No-op outside strip mode.
if constexpr (rotation == TextRotation::Rotated90CW) {
const int ob = cursorX + fontData->ascender - top;
const int ib = cursorY - left;
if (!renderer.glyphIntersectsStrip(ob, ib - (width - 1), ob + height - 1, ib)) {
return;
}
} else {
const int gx0 = cursorX + left;
const int gy0 = cursorY - top;
if (!renderer.glyphIntersectsStrip(gx0, gy0, gx0 + width - 1, gy0 + height - 1)) {
return;
}
}
const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
@@ -240,37 +336,65 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
return;
}
// Tiled grayscale: redirect writes to the strip scratch and clip to the
// current band. Single predictable branch on the hot per-pixel path.
uint8_t* target = frameBuffer;
uint32_t rowY = static_cast<uint32_t>(phyY);
if (_stripActive) {
if (phyY < _stripY0 || phyY >= _stripY0 + _stripRows) {
return; // pixel outside the band currently being rendered
}
target = _stripBuf;
rowY = static_cast<uint32_t>(phyY - _stripY0);
}
// Calculate byte position and bit position
const uint32_t byteIndex = static_cast<uint32_t>(phyY) * panelWidthBytes + (phyX / 8);
const uint32_t byteIndex = rowY * panelWidthBytes + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8); // MSB first
if (state) {
frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit
target[byteIndex] &= ~(1 << bitPosition); // Clear bit
} else {
frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit
target[byteIndex] |= 1 << bitPosition; // Set bit
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style,
const BidiUtils::BidiBaseDir baseDir) const {
if (text == nullptr || *text == '\0') {
return 0;
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
std::string visual;
const char* renderedText = resolveVisualText(text, visual, baseDir);
int w = 0, h = 0;
fontIt->second.getTextDimensions(text, &w, &h, style);
fontIt->second.getTextDimensions(renderedText, &w, &h, style);
return w;
}
void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2;
drawText(fontId, x, y, text, black, style);
const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const {
const int x = (getScreenWidth() - getTextWidth(fontId, text, style, baseDir)) / 2;
drawText(fontId, x, y, text, black, style, baseDir);
}
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const {
// cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
std::string visual;
const char* renderedText = resolveVisualText(text, visual, baseDir);
const int yPos = y + getFontAscenderSize(fontId);
int lastBaseX = x;
int lastBaseLeft = 0;
@@ -278,13 +402,8 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
int lastBaseTop = 0;
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
// cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
fontCacheManager_->recordText(renderedText, fontId, style);
return;
}
@@ -295,9 +414,16 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
}
const auto& font = fontIt->second;
const char* textCursor = renderedText;
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&textCursor)))) {
// Skip Hebrew Niqqud (vowel marks)
// Temporary: avoid adding Niqqud to built-in fonts. Remove when custom fonts are supported.
if (cp >= 0x0591 && cp <= 0x05C7) {
continue;
}
if (utf8IsCombiningMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
@@ -308,7 +434,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
continue;
}
cp = font.applyLigatures(cp, text, style);
cp = font.applyLigatures(cp, textCursor, style);
// Differential rounding: snap (previous advance + current kern) as one unit so
// identical character pairs always produce the same pixel step regardless of
@@ -325,11 +451,49 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
lastBaseTop = glyph ? glyph->top : 0;
prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
if (isSupSub) {
// Halve the advance so the cursor advances by the same amount the scaled glyph
// actually occupies, keeping spacing correct without needing a separate smaller font.
prevAdvanceFP = (prevAdvanceFP + 1) / 2;
}
if (isSupSub) {
// yPos already carries the vertical offset applied by TextBlock::render().
renderCharScaled(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
} else {
renderCharImpl<TextRotation::None>(*this, renderMode, font, cp, lastBaseX, yPos, black, style);
}
prevCp = cp;
}
}
namespace {
const char* resolveVisualText(const char* text, std::string& visualBuffer, const BidiUtils::BidiBaseDir baseDir) {
if (!text || *text == '\0') return text;
if (baseDir != BidiUtils::BidiBaseDir::RTL) {
// Byte-level scan: skip BiDi when no RTL script lead bytes are present.
// Hebrew UTF-8 lead bytes: 0xD6-0xD7; Arabic/Syriac: 0xD8-0xDB.
// This covers all RTL content without false negatives and avoids triggering
// the full UAX#9 algorithm for Latin-extended, em-dashes, accented text, etc.
bool hasRtlBytes = false;
for (const unsigned char* q = reinterpret_cast<const unsigned char*>(text); *q; ++q) {
if (*q >= 0xD6 && *q <= 0xDB) {
hasRtlBytes = true;
break;
}
}
if (!hasRtlBytes) return text;
}
if (BidiUtils::applyBidiVisual(text, visualBuffer, static_cast<int>(baseDir)) && !visualBuffer.empty()) {
return visualBuffer.c_str();
}
return text;
}
} // namespace
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
if (x1 == x2) {
@@ -966,9 +1130,47 @@ static unsigned long start_ms = 0;
void GfxRenderer::clearScreen(const uint8_t color) const {
start_ms = millis();
if (_stripActive) {
// Clear only the active band's scratch, not the shared framebuffer.
memset(_stripBuf, color, static_cast<size_t>(panelWidthBytes) * _stripRows);
return;
}
display.clearScreen(color);
}
void GfxRenderer::beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const {
// Band is caller-guaranteed in-bounds (the reader's grayscale loop computes
// it); assert catches future misuse in debug before it mis-renders or wraps
// the downstream uint16_t cast in writeGrayscalePlaneStrip.
assert(scratch != nullptr && stripRows > 0 && stripY0 >= 0 && stripY0 <= static_cast<int>(panelHeight) - stripRows);
_stripBuf = scratch;
_stripY0 = stripY0;
_stripRows = stripRows;
_stripActive = true;
}
void GfxRenderer::endStripTarget() const {
_stripActive = false;
_stripBuf = nullptr;
_stripY0 = 0;
_stripRows = 0;
}
bool GfxRenderer::glyphIntersectsStrip(int x0, int y0, int x1, int y1) const {
if (!_stripActive) {
return true;
}
// Rotate the two opposite bbox corners to physical coords. For 90-degree
// orientations the physical bbox stays axis-aligned, so min/max of the two
// rotated corners' Y bounds the glyph's physical y-extent.
int ax, ay, bx, by;
rotateCoordinates(orientation, x0, y0, &ax, &ay, panelWidth, panelHeight);
rotateCoordinates(orientation, x1, y1, &bx, &by, panelWidth, panelHeight);
const int minY = ay < by ? ay : by;
const int maxY = ay > by ? ay : by;
return !(maxY < _stripY0 || minY >= _stripY0 + _stripRows);
}
void GfxRenderer::invertScreen() const {
for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i];
@@ -1233,9 +1435,21 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
int32_t widthFP = 0;
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style);
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
const auto& font = fontIt->second;
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
widthFP += sdIt->second->getAdvance(cp, styleIdx);
int32_t advFP = sdIt->second->getAdvance(cp, styleIdx);
if (advFP == 0 && !utf8IsCombiningMark(cp)) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
advFP = glyph ? glyph->advanceX : 0;
}
widthFP += isSupSub ? (advFP + 1) / 2 : advFP;
}
return fp4::toPixel(widthFP);
}
@@ -1266,6 +1480,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
const EpdGlyph* glyph = font.getGlyph(cp, style);
prevAdvanceFP = glyph ? glyph->advanceX : 0;
if ((style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
prevAdvanceFP = (prevAdvanceFP + 1) / 2;
}
prevCp = cp;
}
widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance
@@ -1325,6 +1542,12 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
// Skip Hebrew Niqqud (vowel marks)
// Temporary: avoid adding Niqqud to built-in fonts. Remove when custom fonts are supported.
if (cp >= 0x0591 && cp <= 0x05C7) {
continue;
}
if (utf8IsCombiningMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
@@ -1364,12 +1587,44 @@ size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
// unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
void GfxRenderer::displayGrayscaleBase(HalDisplay::RefreshMode fallback) const {
display.displayGrayscaleBase(fallback, fadingFix);
}
void GfxRenderer::preconditionGrayscale() const { display.preconditionGrayscale(); }
void GfxRenderer::preconditionGrayscale(int x, int y, int w, int h) const {
if (w <= 0 || h <= 0) return;
// Rotate the logical rect's opposite corners to physical panel coords; the
// physical bbox stays axis-aligned for all four orientations.
int ax, ay, bx, by;
rotateCoordinates(orientation, x, y, &ax, &ay, panelWidth, panelHeight);
rotateCoordinates(orientation, x + w - 1, y + h - 1, &bx, &by, panelWidth, panelHeight);
int x0 = ax < bx ? ax : bx, x1 = ax > bx ? ax : bx;
int y0 = ay < by ? ay : by, y1 = ay > by ? ay : by;
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 >= panelWidth) x1 = panelWidth - 1;
if (y1 >= panelHeight) y1 = panelHeight - 1;
if (x1 < x0 || y1 < y0) return;
display.preconditionGrayscale(static_cast<uint16_t>(x0), static_cast<uint16_t>(y0),
static_cast<uint16_t>(x1 - x0 + 1), static_cast<uint16_t>(y1 - y0 + 1));
}
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); }
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
void GfxRenderer::writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const {
// Guard the uint16_t casts below: a negative would wrap to a huge length.
assert(yStart >= 0 && numRows > 0 && yStart <= static_cast<int>(panelHeight) - numRows);
display.writeGrayscalePlaneStrip(lsbPlane, scratch, static_cast<uint16_t>(yStart), static_cast<uint16_t>(numRows));
}
bool GfxRenderer::supportsStripGrayscale() const { return display.supportsStripGrayscale(); }
void GfxRenderer::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) {
if (bwBufferChunk) {
+68 -3
View File
@@ -3,6 +3,14 @@
#include <EpdFontFamily.h>
#include <HalDisplay.h>
namespace BidiUtils {
// Paragraph base direction for the Unicode BiDi algorithm (UAX#9).
// AUTO: scan text for first strong directional character (P2/P3 rules)
// LTR: force left-to-right paragraph embedding level
// RTL: force right-to-left paragraph embedding level
enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 };
} // namespace BidiUtils
class FontCacheManager;
class SdCardFont;
@@ -54,6 +62,18 @@ class GfxRenderer {
// as before, concentrated in a single pointer instead of four fields.
mutable FontCacheManager* fontCacheManager_ = nullptr;
// Tiled grayscale strip target. When active, drawPixel()/clearScreen()
// operate on a caller-owned scratch holding one horizontal band of physical
// rows [_stripY0, _stripY0 + _stripRows) (panelWidthBytes wide) instead of
// the shared framebuffer, clipping pixels outside the band. Lets grayscale
// planes render band-by-band straight to the controller without destroying
// the BW framebuffer (no storeBwBuffer). Mutable because the render path is
// const. See beginStripTarget()/endStripTarget().
mutable uint8_t* _stripBuf = nullptr;
mutable int _stripY0 = 0;
mutable int _stripRows = 0;
mutable bool _stripActive = false;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const;
void freeBwBufferChunks();
@@ -84,6 +104,7 @@ class GfxRenderer {
}
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
bool isFontCacheScanning() const;
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
void registerSdCardFont(int fontId, SdCardFont* font) { sdCardFonts_[fontId] = font; }
void unregisterSdCardFont(int fontId) { removeFont(fontId); }
@@ -114,6 +135,31 @@ class GfxRenderer {
void clearScreen(uint8_t color = 0xFF) const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
// Tiled grayscale strip target. While active, drawPixel() and clearScreen()
// operate on `scratch` (panelWidthBytes * stripRows bytes, holding physical
// rows [stripY0, stripY0 + stripRows)) instead of the framebuffer; pixels
// whose physical row falls outside the band are clipped. The clip is applied
// after the orientation rotate, so it is orientation-agnostic. Used to render
// grayscale planes band-by-band without a full second buffer.
void beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const;
void endStripTarget() const;
// Band culling for tiled grayscale. Takes a glyph bounding box in logical
// screen coords and returns false only when a strip is active AND the box's
// physical y-extent lies entirely outside the active band, letting callers
// skip an expensive bitmap decode. Returns true when no strip is active.
// Corners are rotated to physical, so it is orientation-aware.
bool glyphIntersectsStrip(int x0, int y0, int x1, int y1) const;
// Active pixel-write target for raw writers (DirectPixelWriter) that bypass
// drawPixel for speed. When a strip target is active these return the band
// scratch plus its physical-row origin and extent; otherwise the full
// framebuffer ([0, panelHeight)). Writers subtract the origin and clip to the
// extent, so they honor tiled-grayscale banding without per-pixel method calls.
uint8_t* getWriteTarget() const { return _stripActive ? _stripBuf : frameBuffer; }
int getWriteOriginY() const { return _stripActive ? _stripY0 : 0; }
int getWriteRows() const { return _stripActive ? _stripRows : panelHeight; }
// Drawing
void drawPixel(int x, int y, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
@@ -138,11 +184,14 @@ class GfxRenderer {
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR,
BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const;
void drawCenteredText(int fontId, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
EpdFontFamily::Style style = EpdFontFamily::REGULAR,
BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
EpdFontFamily::Style style = EpdFontFamily::REGULAR,
BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const;
int getSpaceWidth(int fontId, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
/// Returns the total inter-word advance: fp4::toPixel(spaceAdvance + kern(leftCp,' ') + kern(' ',rightCp)).
/// Using a single snap avoids the +/-1 px rounding error that arises when space advance and kern are
@@ -169,9 +218,25 @@ class GfxRenderer {
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
RenderMode getRenderMode() const { return renderMode; }
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
// takes the gray region in LOGICAL screen coordinates and rotates it to the
// panel; the no-arg overload settles the full frame. Call after the BW base
// frame is displayed and before the grayscale planes are written.
void preconditionGrayscale() const;
void preconditionGrayscale(int x, int y, int w, int h) const;
// Display the framebuffer as the base frame for a grayscale overlay that
// follows (X3: OEM differential base waveform; others: plain display with
// `fallback`).
void displayGrayscaleBase(HalDisplay::RefreshMode fallback = HalDisplay::HALF_REFRESH) const;
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
// Tiled grayscale (X4): stream one band of a plane straight to controller RAM
// from `scratch` (panelWidthBytes * numRows, physical rows [yStart, yStart+
// numRows)), bypassing the framebuffer. supportsStripGrayscale() gates use.
void writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const;
bool supportsStripGrayscale() const;
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const;

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