Compare commits
13
Commits
feat-touch
...
1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8959836e46 | ||
|
|
fd79074d43 | ||
|
|
b971f7bda4 | ||
|
|
c26e410c58 | ||
|
|
16e5e5f00b | ||
|
|
74e75746df | ||
|
|
a7586f20a1 | ||
|
|
b145e4437c | ||
|
|
fcb8793640 | ||
|
|
6c97061629 | ||
|
|
9b2d8388ef | ||
|
|
bb3a44f584 | ||
|
|
bd546093cb |
@@ -1,122 +1,169 @@
|
||||
# CrossPoint Reader
|
||||
|
||||
Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink).
|
||||
Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
|
||||
CrossPoint is open-source e-reader firmware - community-built, fully hackable, free forever. It's maintained by a growing community of developers and readers who believe your device should do what you want - not what a manufacturer decided for you.
|
||||
|
||||
CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official
|
||||
Xteink firmware. It aims to match or improve upon the standard EPUB reading experience.
|
||||
**Now running on:** ESP32C3-based Xteink [X4](https://www.xteink.com/products/xteink-x4) and [X3](https://www.xteink.com/products/xteink-x3).
|
||||
|
||||

|
||||

|
||||
|
||||
## Motivation
|
||||
## What can CrossPoint do?
|
||||
|
||||
E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited
|
||||
customisation. The **Xteink X4** is an affordable, e-paper device, however the official firmware remains closed.
|
||||
CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truly unlock the device's
|
||||
potential.
|
||||
- **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.
|
||||
|
||||
CrossPoint Reader aims to:
|
||||
* Provide a **fully open-source alternative** to the official firmware.
|
||||
* Offer a **document reader** capable of handling EPUB content on constrained hardware.
|
||||
* Support **customisable font, layout, and display** options.
|
||||
* Run purely on the **Xteink X4 hardware**.
|
||||
- **Various formats**: native handling for `.epub`, `.xtc/.xtch`, `.txt`, and `.bmp`.
|
||||
|
||||
This project is **not affiliated with Xteink**; it's built as a community project.
|
||||
- **Screenshots.**
|
||||
|
||||
## Features & Usage
|
||||
- **Custom fonts**: install your favorite fonts on the SD card.
|
||||
|
||||
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
|
||||
- [x] Image support within EPUB
|
||||
- [x] Saved reading position
|
||||
- [x] File explorer with file picker
|
||||
- [x] Basic EPUB picker from root directory
|
||||
- [x] Support nested folders
|
||||
- [ ] EPUB picker with cover art
|
||||
- [x] Custom sleep screen
|
||||
- [x] Cover sleep screen
|
||||
- [x] Wifi book upload
|
||||
- [x] Wifi OTA updates
|
||||
- [x] KOReader Sync integration for cross-device reading progress
|
||||
- [x] Configurable font, layout, and display options
|
||||
- [ ] User provided fonts
|
||||
- [ ] Full UTF support
|
||||
- [x] Screen rotation
|
||||
- **Tilt page turn (X3 only)**.
|
||||
|
||||
Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages).
|
||||
- **Library workflow**: folder browser, hidden-file toggle, long-press delete, recent books, SD-cache management.
|
||||
|
||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint, including the
|
||||
[KOReader Sync quick setup](./USER_GUIDE.md#367-koreader-sync-quick-setup).
|
||||
- **Wireless workflows**:
|
||||
|
||||
- File transfer web UI
|
||||
- EPUB Optimizer
|
||||
- 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
|
||||
- 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
|
||||
|
||||
For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document.
|
||||
- **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.
|
||||
|
||||
## Installing
|
||||
- **Localization**: 22 UI languages and counting.
|
||||
|
||||
### Web (latest firmware)
|
||||
### Coming soon:
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
|
||||
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
|
||||
- RTL support — Arabic, Hebrew, and Farsi.
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
|
||||
- Bookmarks.
|
||||
|
||||
### Web (specific firmware version)
|
||||
- Dictionary lookup — inline word lookup without leaving the reader.
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C
|
||||
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
|
||||
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
|
||||
- More themes.
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
|
||||
- Much more! stay tuned.
|
||||
|
||||
### Command line (specific firmware version)
|
||||
---
|
||||
|
||||
## USB-locked devices (Xteink Unlocker)
|
||||
|
||||
Some Xteink units purchased from third-party stores (e.g. AliExpress) ship with USB flashing locked from the factory.
|
||||
If your device is locked, you will need to use the **Xteink Unlocker** tool available at
|
||||
https://crosspointreader.com/#unlock-tool before you can flash CrossPoint.
|
||||
|
||||
**You do not need this tool if you bought your device directly from xteink.com.** Those units are not locked.
|
||||
|
||||
**Not sure if your device is locked?** Power it on, connect the USB-C cable, and try flashing via the web flasher first (see
|
||||
[Install firmware](#install-firmware) below). If the browser's serial device picker does not show your device, try a different
|
||||
USB port or browser before assuming the device is locked. Only reach for the unlocker if the device still doesn't appear.
|
||||
|
||||
> ### ⚠️ WARNING: READ THIS BEFORE USING THE UNLOCKER ⚠️
|
||||
>
|
||||
> **The only officially supported firmwares in the unlock tool are CrossPoint and CrossInk.**
|
||||
>
|
||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
||||
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
||||
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
||||
>
|
||||
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
|
||||
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
|
||||
> Papyrix (or any other unsupported firmware) on a locked device.**
|
||||
|
||||
## Install firmware
|
||||
|
||||
### Web installer (recommended)
|
||||
|
||||
1. Connect your device to your computer via USB-C and wake/unlock the device
|
||||
2. Go to https://crosspointreader.com/#flash-tools, select device (X3 or X4), and choose an official CrossPoint release.
|
||||
|
||||
### Web installer (specific version)
|
||||
|
||||
1. Connect your device to your computer via USB-C and wake/unlock the device
|
||||
2. Download a `firmware.bin` from [Releases](https://github.com/crosspoint-reader/crosspoint-reader/releases), local build, or continuous integration artifact.
|
||||
3. Go to https://crosspointreader.com/#flash-tools, select device (X3 or X4), click "Custom .bin" and upload a `firmware.bin`.
|
||||
|
||||
### Revert to Official Firmware
|
||||
|
||||
To revert to the official firmware, you can also flash the latest official firmware using https://crosspointreader.com/#flash-tools.
|
||||
|
||||
### Command line
|
||||
|
||||
1. Install [`esptool`](https://github.com/espressif/esptool):
|
||||
|
||||
1. Install [`esptool`](https://github.com/espressif/esptool) :
|
||||
```bash
|
||||
pip install esptool
|
||||
```
|
||||
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
|
||||
3. Connect your Xteink X4 to your computer via USB-C.
|
||||
4. Note the device location. On Linux, run `dmesg` after connecting. On MacOS, run :
|
||||
|
||||
2. Download `firmware.bin` from the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases).
|
||||
3. Connect your device via USB-C.
|
||||
4. Find the device port. On Linux, run `dmesg` after connecting. On macOS:
|
||||
|
||||
```bash
|
||||
log stream --predicate 'subsystem == "com.apple.iokit"' --info
|
||||
```
|
||||
5. Flash the firmware :
|
||||
|
||||
5. Flash:
|
||||
|
||||
```bash
|
||||
esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 921600 write_flash 0x10000 /path/to/firmware.bin
|
||||
```
|
||||
Change `/dev/ttyACM0` to the device for your system.
|
||||
|
||||
Adjust `/dev/ttyACM0` to match your system.
|
||||
|
||||
### Manual
|
||||
|
||||
See [Development](#development) below.
|
||||
See [Development quick start](#development-quick-start) below.
|
||||
|
||||
## Development
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [User Guide](./USER_GUIDE.md)
|
||||
- [Web server usage](./docs/webserver.md)
|
||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||
- [Project scope](./SCOPE.md)
|
||||
- [Contributing docs](./docs/contributing/README.md)
|
||||
|
||||
---
|
||||
|
||||
## Development quick start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* **PlatformIO Core** (`pio`) or **VS Code + PlatformIO IDE**
|
||||
* Python 3.8+
|
||||
* USB-C cable for flashing the ESP32-C3
|
||||
* Xteink X4
|
||||
- [pioarduino](https://github.com/pioarduino/pioarduino) or VS Code + pioarduino plugin
|
||||
- Python 3.8+
|
||||
- `clang-format` 21
|
||||
- USB-C cable supporting data transfer
|
||||
|
||||
### Checking out the code
|
||||
### Setup
|
||||
|
||||
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
|
||||
|
||||
```
|
||||
```bash
|
||||
git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader
|
||||
cd crosspoint-reader
|
||||
|
||||
# Or, if you've already cloned without --recursive:
|
||||
# if cloned without --recursive:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### Flashing your device
|
||||
### Build / flash / monitor
|
||||
|
||||
Connect your Xteink X4 to your computer via USB-C and run the following command.
|
||||
|
||||
```sh
|
||||
```bash
|
||||
pio run --target upload
|
||||
```
|
||||
|
||||
### Contributor pre-PR checks
|
||||
|
||||
```bash
|
||||
./bin/clang-format-fix
|
||||
pio check -e default
|
||||
pio run -e default
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
After flashing the new features, it’s recommended to capture detailed logs from the serial port.
|
||||
@@ -126,7 +173,9 @@ First, make sure all required Python packages are installed:
|
||||
```python
|
||||
python3 -m pip install pyserial colorama matplotlib
|
||||
```
|
||||
after that run the script:
|
||||
|
||||
After that run the script:
|
||||
|
||||
```sh
|
||||
# For Linux
|
||||
# This was tested on Debian and should work on most Linux systems.
|
||||
@@ -135,63 +184,72 @@ python3 scripts/debugging_monitor.py
|
||||
# For macOS
|
||||
python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101
|
||||
```
|
||||
|
||||
Minor adjustments may be required for Windows.
|
||||
|
||||
---
|
||||
|
||||
## Internals
|
||||
|
||||
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only
|
||||
has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based
|
||||
on this constraint.
|
||||
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based on this constraint.
|
||||
|
||||
### Data caching
|
||||
|
||||
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
|
||||
.crosspoint/
|
||||
├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_<hash>`
|
||||
│ ├── progress.bin # Stores reading progress (chapter, page, etc.)
|
||||
│ ├── cover.bmp # Book cover image (once generated)
|
||||
│ ├── book.bin # Book metadata (title, author, spine, table of contents, etc.)
|
||||
│ └── sections/ # All chapter data is stored in the sections subdirectory
|
||||
│ ├── 0.bin # Chapter data (screen count, all text layout info, etc.)
|
||||
│ ├── 1.bin # files are named by their index in the spine
|
||||
├── epub_<hash>/ # one directory per book, named by content hash
|
||||
│ ├── progress.bin # reading position (chapter, page, etc.)
|
||||
│ ├── cover.bmp # generated cover image
|
||||
│ ├── book.bin # metadata: title, author, spine, TOC
|
||||
│ └── sections/ # per-chapter layout cache
|
||||
│ ├── 0.bin
|
||||
│ ├── 1.bin
|
||||
│ └── ...
|
||||
│
|
||||
└── epub_189013891/
|
||||
```
|
||||
|
||||
Deleting the `.crosspoint` directory will clear the entire cache.
|
||||
|
||||
Due the way it's currently implemented, the cache is not automatically cleared when a book is deleted and moving a book
|
||||
file will use a new cache directory, resetting the reading progress.
|
||||
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.
|
||||
|
||||
For more details on the internal file structures, see the [file formats document](./docs/file-formats.md).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome!
|
||||
Contributions are welcome. If you're new to the codebase, start with the [contributing docs](./docs/contributing/README.md). For things to work on, check the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas) — leave a comment before starting so we don't duplicate effort.
|
||||
|
||||
If you are new to the codebase, start with the [contributing docs](./docs/contributing/README.md).
|
||||
|
||||
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas).
|
||||
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
|
||||
|
||||
Everyone here is a volunteer, so please be respectful and patient. For more details on our governance and community
|
||||
principles, please see [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
### To submit a contribution:
|
||||
|
||||
1. Fork the repo
|
||||
2. Create a branch (`feature/dithering-improvement`)
|
||||
3. Make changes
|
||||
4. Submit a PR
|
||||
Everyone here is a volunteer, so please be respectful and patient. For governance and community expectations, see [GOVERNANCE.md](./GOVERNANCE.md).
|
||||
|
||||
---
|
||||
|
||||
CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4 hardware**.
|
||||
## Community forks
|
||||
|
||||
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I
|
||||
was making CrossPoint.
|
||||
One of the best things about open source is that anyone can take the code in a different direction. If you need something outside CrossPoint's [scope](./SCOPE.md), check out the community forks:
|
||||
|
||||
- [CrossInk](https://github.com/uxjulia/CrossInk) — Typography and reading tracking: Bionic Reading (bolds word stems to create fixation points), guide dots between words, improved paragraph indents, and replaces the default fonts with ChareInk/Lexend/Bitter.
|
||||
|
||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||
|
||||
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
||||
|
||||
- [crosspoint-reader (jpirnay)](https://github.com/jpirnay/crosspoint-reader) — Faster integration of functionality. Tracks upstream PRs and integrates the good ones ahead of the official merge.
|
||||
|
||||
- [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.
|
||||
|
||||
- ~~[PlusPoint](https://github.com/ngxson/pluspoint-reader) — custom JS apps support.~~ (Unmaintained)
|
||||
|
||||
- [crosspoint-reader-papers3](https://github.com/juicecultus/crosspoint-reader-papers3) — Crosspoint port for M5Stack Paper S3.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
CrossPoint Reader is **not affiliated with Xteink or any device manufacturer**.
|
||||
|
||||
Huge shoutout to [diy-esp32-epub-reader](https://github.com/atomic14/diy-esp32-epub-reader), which inspired this project.
|
||||
|
||||
+121
-67
@@ -9,16 +9,20 @@
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "EpdFontFamily.h"
|
||||
|
||||
static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout");
|
||||
static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout");
|
||||
static_assert(sizeof(EpdKernClassEntry) == 3, "EpdKernClassEntry must be 3 bytes to match .cpfont file layout");
|
||||
static_assert(sizeof(EpdLigaturePair) == 8, "EpdLigaturePair must be 8 bytes to match .cpfont file layout");
|
||||
|
||||
// FNV-1a hash for content-based font ID generation
|
||||
static constexpr uint32_t FNV_OFFSET = 2166136261u;
|
||||
static constexpr uint32_t FNV_PRIME = 16777619u;
|
||||
namespace {
|
||||
|
||||
static uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSET) {
|
||||
// FNV-1a hash for content-based font ID generation
|
||||
constexpr uint32_t FNV_OFFSET = 2166136261u;
|
||||
constexpr uint32_t FNV_PRIME = 16777619u;
|
||||
|
||||
uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSET) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
hash ^= data[i];
|
||||
hash *= FNV_PRIME;
|
||||
@@ -27,16 +31,44 @@ static uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSE
|
||||
}
|
||||
|
||||
// .cpfont magic bytes
|
||||
static constexpr char CPFONT_MAGIC[8] = {'C', 'P', 'F', 'O', 'N', 'T', '\0', '\0'};
|
||||
constexpr char CPFONT_MAGIC[8] = {'C', 'P', 'F', 'O', 'N', 'T', '\0', '\0'};
|
||||
// CPFONT_VERSION is defined as a #define in SdCardFont.h so it can be
|
||||
// stringified into FONT_MANIFEST_URL.
|
||||
static constexpr uint32_t HEADER_SIZE = 32;
|
||||
static constexpr uint32_t STYLE_TOC_ENTRY_SIZE = 32;
|
||||
constexpr uint32_t HEADER_SIZE = 32;
|
||||
constexpr uint32_t STYLE_TOC_ENTRY_SIZE = 32;
|
||||
|
||||
// Helper to read little-endian values from byte buffer
|
||||
static inline uint16_t readU16(const uint8_t* p) { return p[0] | (p[1] << 8); }
|
||||
static inline int16_t readI16(const uint8_t* p) { return static_cast<int16_t>(p[0] | (p[1] << 8)); }
|
||||
static inline uint32_t readU32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); }
|
||||
inline uint16_t readU16(const uint8_t* p) { return p[0] | (p[1] << 8); }
|
||||
inline int16_t readI16(const uint8_t* p) { return static_cast<int16_t>(p[0] | (p[1] << 8)); }
|
||||
inline uint32_t readU32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); }
|
||||
|
||||
// Walks a null-terminated UTF-8 string and appends each unique codepoint to
|
||||
// codepoints[0..cpCount-1] via O(n²) dedup. Returns true if the buffer
|
||||
// reached maxCount (cap hit), false if all codepoints fit.
|
||||
bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& cpCount, uint32_t maxCount) {
|
||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
||||
while (*p) {
|
||||
uint32_t cp = utf8NextCodepoint(&p);
|
||||
if (cp == 0) break;
|
||||
bool found = false;
|
||||
for (uint32_t i = 0; i < cpCount; i++) {
|
||||
if (codepoints[i] == cp) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
if (cpCount >= maxCount) return true;
|
||||
codepoints[cpCount++] = cp;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||
const char* asCStr(const char* s) { return s; }
|
||||
|
||||
} // namespace
|
||||
|
||||
SdCardFont::~SdCardFont() { freeAll(); }
|
||||
|
||||
@@ -587,6 +619,8 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint)
|
||||
|
||||
int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) {
|
||||
if (!loaded_) return -1;
|
||||
styleMask = resolveStyleMask(styleMask);
|
||||
if (styleMask == 0) return 0;
|
||||
|
||||
unsigned long startMs = millis();
|
||||
|
||||
@@ -1015,62 +1049,10 @@ uint16_t SdCardFont::getAdvance(uint32_t codepoint, uint8_t style) const {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) {
|
||||
if (!loaded_) return -1;
|
||||
|
||||
// Note: advance table is preserved across calls. We only fetch codepoints
|
||||
// not already present, then merge them in. Use clearPersistentCache() to
|
||||
// wipe the table when the font/size/family changes.
|
||||
|
||||
unsigned long startMs = millis();
|
||||
|
||||
// Step 1: Extract unique codepoints, capped at MAX_UNIQUE_CODEPOINTS.
|
||||
// The dedup buffer is sized to the cap, not total chars — a large EPUB section
|
||||
// may contain 50K+ characters but real text has far fewer unique codepoints.
|
||||
// 4096 × 4 bytes = 16KB temporary; bounded regardless of input size.
|
||||
static constexpr uint32_t MAX_UNIQUE_CODEPOINTS = 4096;
|
||||
uint32_t* codepoints = new (std::nothrow) uint32_t[MAX_UNIQUE_CODEPOINTS];
|
||||
if (!codepoints) {
|
||||
LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate codepoint buffer (%u bytes)", MAX_UNIQUE_CODEPOINTS * 4);
|
||||
return -1;
|
||||
}
|
||||
uint32_t cpCount = 0;
|
||||
bool hitCap = false;
|
||||
|
||||
// Second pass: collect unique codepoints via O(n²) dedup.
|
||||
// Bounded by uniqueCount × totalChars comparisons. For 2000 unique from 2291 total,
|
||||
// worst case ~4.6M comparisons of uint32_t — ~30ms on 160MHz RISC-V, acceptable
|
||||
// for one-time section indexing.
|
||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(utf8Text);
|
||||
while (*p) {
|
||||
uint32_t cp = utf8NextCodepoint(&p);
|
||||
if (cp == 0) break;
|
||||
|
||||
bool found = false;
|
||||
for (uint32_t i = 0; i < cpCount; i++) {
|
||||
if (codepoints[i] == cp) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
if (cpCount >= MAX_UNIQUE_CODEPOINTS) {
|
||||
hitCap = true;
|
||||
break;
|
||||
}
|
||||
codepoints[cpCount++] = cp;
|
||||
}
|
||||
}
|
||||
if (hitCap) {
|
||||
LOG_ERR("SDCF", "buildAdvanceTable: unique codepoint cap (%u) hit, layout may be approximate",
|
||||
MAX_UNIQUE_CODEPOINTS);
|
||||
}
|
||||
|
||||
// Sort for ordered glyph index mapping and final table output
|
||||
std::sort(codepoints, codepoints + cpCount);
|
||||
|
||||
// Step 2: For each requested style, fetch any codepoints not yet cached and
|
||||
// merge them into the persistent advance table.
|
||||
// Given a sorted array of unique codepoints, resolve glyph indices per style,
|
||||
// batch-read advanceX from SD, and merge into the persistent advance table.
|
||||
// Caller owns the codepoints buffer.
|
||||
int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCount, uint8_t styleMask) {
|
||||
int totalMissed = 0;
|
||||
for (uint8_t si = 0; si < MAX_STYLES; si++) {
|
||||
if (!(styleMask & (1 << si)) || !styles_[si].present) continue;
|
||||
@@ -1165,12 +1147,55 @@ int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) {
|
||||
ADVANCE_CACHE_LIMIT);
|
||||
}
|
||||
|
||||
delete[] codepoints;
|
||||
return totalMissed;
|
||||
}
|
||||
|
||||
template <typename Iter>
|
||||
int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask) {
|
||||
if (!loaded_) return -1;
|
||||
styleMask = resolveStyleMask(styleMask);
|
||||
if (styleMask == 0) return 0;
|
||||
|
||||
unsigned long startMs = millis();
|
||||
|
||||
// +2 reserved slots for space and hyphen injected after the main scan.
|
||||
static constexpr uint32_t MAX_UNIQUE_CODEPOINTS = 4096;
|
||||
uint32_t* codepoints = new (std::nothrow) uint32_t[MAX_UNIQUE_CODEPOINTS + 2];
|
||||
if (!codepoints) {
|
||||
LOG_ERR("SDCF", "buildAdvanceTable: failed to allocate codepoint buffer (%u bytes)", MAX_UNIQUE_CODEPOINTS * 4);
|
||||
return -1;
|
||||
}
|
||||
uint32_t cpCount = 0;
|
||||
bool hitCap = false;
|
||||
|
||||
for (auto it = begin; it != end && !hitCap; ++it) {
|
||||
hitCap = collectUniqueCodepoints(asCStr(*it), codepoints, cpCount, MAX_UNIQUE_CODEPOINTS);
|
||||
}
|
||||
|
||||
if (includeSpace && std::none_of(codepoints, codepoints + cpCount, [](uint32_t c) { return c == ' '; }))
|
||||
codepoints[cpCount++] = ' ';
|
||||
if (includeHyphen && std::none_of(codepoints, codepoints + cpCount, [](uint32_t c) { return c == '-'; }))
|
||||
codepoints[cpCount++] = '-';
|
||||
|
||||
if (hitCap) {
|
||||
LOG_ERR("SDCF", "buildAdvanceTable: unique codepoint cap (%u) hit, layout may be approximate",
|
||||
MAX_UNIQUE_CODEPOINTS);
|
||||
}
|
||||
std::sort(codepoints, codepoints + cpCount);
|
||||
int totalMissed = fetchAdvancesForCodepoints(codepoints, cpCount, styleMask);
|
||||
delete[] codepoints;
|
||||
stats_.prewarmTotalMs = millis() - startMs;
|
||||
return totalMissed;
|
||||
}
|
||||
|
||||
int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask) {
|
||||
return buildAdvanceTableRange(&utf8Text, &utf8Text + 1, false, false, styleMask);
|
||||
}
|
||||
|
||||
int SdCardFont::buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask) {
|
||||
return buildAdvanceTableRange(words.begin(), words.end(), words.size() > 1, includeHyphen, styleMask);
|
||||
}
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
void SdCardFont::logStats(const char* label) {
|
||||
@@ -1190,6 +1215,35 @@ EpdFont* SdCardFont::getEpdFont(uint8_t style) {
|
||||
|
||||
bool SdCardFont::hasStyle(uint8_t style) const { return styles_[style & (MAX_STYLES - 1)].present; }
|
||||
|
||||
uint8_t SdCardFont::resolveStyle(uint8_t style) const {
|
||||
static const uint8_t kFallbacks[MAX_STYLES][MAX_STYLES] = {
|
||||
// REGULAR: REGULAR -> BOLD -> ITALIC -> BOLD_ITALIC
|
||||
{EpdFontFamily::REGULAR, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::BOLD_ITALIC},
|
||||
// BOLD: BOLD -> REGULAR -> BOLD_ITALIC -> ITALIC
|
||||
{EpdFontFamily::BOLD, EpdFontFamily::REGULAR, EpdFontFamily::BOLD_ITALIC, EpdFontFamily::ITALIC},
|
||||
// ITALIC: ITALIC -> REGULAR -> BOLD_ITALIC -> BOLD
|
||||
{EpdFontFamily::ITALIC, EpdFontFamily::REGULAR, EpdFontFamily::BOLD_ITALIC, EpdFontFamily::BOLD},
|
||||
// BOLD_ITALIC: BOLD_ITALIC -> BOLD -> ITALIC -> REGULAR
|
||||
{EpdFontFamily::BOLD_ITALIC, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::REGULAR},
|
||||
};
|
||||
|
||||
const uint8_t styleBits = style & (MAX_STYLES - 1);
|
||||
for (uint8_t candidate : kFallbacks[styleBits]) {
|
||||
if (styles_[candidate].present) return candidate;
|
||||
}
|
||||
return EpdFontFamily::REGULAR;
|
||||
}
|
||||
|
||||
uint8_t SdCardFont::resolveStyleMask(uint8_t styleMask) const {
|
||||
uint8_t resolvedMask = 0;
|
||||
for (uint8_t si = 0; si < MAX_STYLES; si++) {
|
||||
if (styleMask & (1 << si)) {
|
||||
resolvedMask |= static_cast<uint8_t>(1u << resolveStyle(si));
|
||||
}
|
||||
}
|
||||
return resolvedMask;
|
||||
}
|
||||
|
||||
// --- On-demand glyph loading (overflow buffer) ---
|
||||
|
||||
const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "EpdFont.h"
|
||||
#include "EpdFontData.h"
|
||||
@@ -43,10 +45,11 @@ class SdCardFont {
|
||||
int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false);
|
||||
|
||||
// Build a compact advance-only table for layout measurement.
|
||||
// Extracts ALL unique codepoints from utf8Text (no MAX_PAGE_GLYPHS cap),
|
||||
// Extracts ALL unique codepoints from words (no MAX_PAGE_GLYPHS cap),
|
||||
// batch-reads advanceX from SD, stores in a sorted per-style table.
|
||||
// Returns number of codepoints not found in font coverage.
|
||||
int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F);
|
||||
int buildAdvanceTable(const std::vector<std::string>& words, bool includeHyphen, uint8_t styleMask = 0x0F);
|
||||
|
||||
// Look up advanceX for a codepoint from the advance table.
|
||||
// Returns the 12.4 fixed-point advance, or 0 if not found.
|
||||
@@ -71,6 +74,13 @@ class SdCardFont {
|
||||
// Returns true if the given style is present in this font file.
|
||||
bool hasStyle(uint8_t style) const;
|
||||
|
||||
// Resolve requested style bits to the closest present style.
|
||||
uint8_t resolveStyle(uint8_t style) const;
|
||||
|
||||
// Resolve every requested style bit through fallback and return the actual
|
||||
// styles that need cache/advance preparation.
|
||||
uint8_t resolveStyleMask(uint8_t styleMask) const;
|
||||
|
||||
// Number of styles present in this font file.
|
||||
uint8_t styleCount() const { return styleCount_; }
|
||||
|
||||
@@ -229,6 +239,9 @@ class SdCardFont {
|
||||
void applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const;
|
||||
void applyGlyphMissCallback(uint8_t styleIdx);
|
||||
int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const;
|
||||
int fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCount, uint8_t styleMask);
|
||||
template <typename Iter>
|
||||
int buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask);
|
||||
int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly);
|
||||
|
||||
// Global helpers
|
||||
|
||||
@@ -255,20 +255,6 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
// (advanceX only, no bitmaps) for all unique codepoints in this paragraph so
|
||||
// that calculateWordWidths() can measure text without on-demand SD I/O.
|
||||
if (renderer.isSdCardFont(fontId)) {
|
||||
// Reserve upfront so the joined text allocates exactly once. Without this,
|
||||
// paragraphs with many words trigger a chain of vector-like reallocations
|
||||
// inside std::string during layout — visible in prewarm timings for SD fonts.
|
||||
size_t totalSize = hyphenationEnabled ? 1 : 0;
|
||||
if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces
|
||||
for (const auto& w : words) totalSize += w.size();
|
||||
std::string allText;
|
||||
allText.reserve(totalSize);
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
if (i > 0) allText += ' ';
|
||||
allText += words[i];
|
||||
}
|
||||
if (hyphenationEnabled) allText += '-';
|
||||
|
||||
// Style mask: only ask the SD font to load advances for styles actually
|
||||
// used in this paragraph. Style index is the low two bits (regular/bold/
|
||||
// italic/bold-italic); the underline bit is irrelevant to advance metrics.
|
||||
@@ -277,7 +263,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(s) & 0x03));
|
||||
}
|
||||
if (styleMask == 0) styleMask = 0x01; // defensive: regular only
|
||||
renderer.ensureSdCardFontReady(fontId, allText.c_str(), styleMask);
|
||||
renderer.ensureSdCardFontReady(fontId, words, hyphenationEnabled, styleMask);
|
||||
}
|
||||
|
||||
const int pageWidth = viewportWidth;
|
||||
|
||||
@@ -19,23 +19,7 @@ const char* resolveVisualText(const char* text, std::string& visualBuffer, int p
|
||||
* Falls back gracefully when the font lacks the requested variant.
|
||||
*/
|
||||
uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style style) {
|
||||
// Indexed by styleBits (0=REGULAR, 1=BOLD, 2=ITALIC, 3=BOLD_ITALIC)
|
||||
static const uint8_t kFallbacks[4][4] = {
|
||||
// REGULAR: REGULAR → BOLD → ITALIC → BOLD_ITALIC
|
||||
{EpdFontFamily::REGULAR, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::BOLD_ITALIC},
|
||||
// BOLD: BOLD → BOLD_ITALIC → REGULAR → ITALIC
|
||||
{EpdFontFamily::BOLD, EpdFontFamily::BOLD_ITALIC, EpdFontFamily::REGULAR, EpdFontFamily::ITALIC},
|
||||
// ITALIC: ITALIC → REGULAR → BOLD → BOLD_ITALIC (REGULAR before BOLD!)
|
||||
{EpdFontFamily::ITALIC, EpdFontFamily::REGULAR, EpdFontFamily::BOLD, EpdFontFamily::BOLD_ITALIC},
|
||||
// BOLD_ITALIC: BOLD_ITALIC → BOLD → ITALIC → REGULAR
|
||||
{EpdFontFamily::BOLD_ITALIC, EpdFontFamily::BOLD, EpdFontFamily::ITALIC, EpdFontFamily::REGULAR},
|
||||
};
|
||||
|
||||
const uint8_t styleBits = static_cast<uint8_t>(style) & 0x03;
|
||||
for (uint8_t candidate : kFallbacks[styleBits]) {
|
||||
if (font.hasStyle(candidate)) return candidate;
|
||||
}
|
||||
return EpdFontFamily::REGULAR; // no-variant-at-all safety net
|
||||
return font.resolveStyle(static_cast<uint8_t>(style));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -69,11 +53,22 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
|
||||
|
||||
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask) const {
|
||||
auto it = sdCardFonts_.find(fontId);
|
||||
if (it != sdCardFonts_.end()) {
|
||||
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
|
||||
if (missed > 0) {
|
||||
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::ensureSdCardFontReady(int fontId, const std::vector<std::string>& words, bool includeHyphen,
|
||||
uint8_t styleMask) const {
|
||||
auto it = sdCardFonts_.find(fontId);
|
||||
if (it != sdCardFonts_.end()) {
|
||||
// Augment the persistent advance-only table for layout measurement.
|
||||
// The table survives across paragraphs/sections (capped per font), so
|
||||
// repeated indexing of the same SD font amortizes glyph-metric SD reads.
|
||||
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
|
||||
int missed = it->second->buildAdvanceTable(words, includeHyphen, styleMask);
|
||||
if (missed > 0) {
|
||||
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ class GfxRenderer {
|
||||
// (which holds a const GfxRenderer&) before measuring word widths. Safe to call on non-SD fonts (no-op).
|
||||
// styleMask: bitmask of styles to prepare (bit 0=regular, 1=bold, 2=italic, 3=bold-italic).
|
||||
void ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F) const;
|
||||
void ensureSdCardFontReady(int fontId, const std::vector<std::string>& words, bool includeHyphen,
|
||||
uint8_t styleMask = 0x0F) const;
|
||||
|
||||
// Orientation control (affects logical width/height and coordinate transforms)
|
||||
void setOrientation(const Orientation o) { orientation = o; }
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build_cache_dir = .cache
|
||||
extra_configs = platformio.local.ini
|
||||
|
||||
[crosspoint]
|
||||
version = 1.2.0
|
||||
version = 1.3.0
|
||||
|
||||
[base]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
|
||||
|
||||
@@ -192,6 +192,17 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
|
||||
}
|
||||
buffer[chunkSize] = '\0';
|
||||
|
||||
// Prime the SD card font's advance table with this chunk's codepoints.
|
||||
// Without this, every getTextAdvanceX() call in the wrap loop below triggers
|
||||
// on-demand glyph loads through the 8-slot overflow ring buffer, which
|
||||
// thrashes for any text with more than 8 unique chars (i.e. all English),
|
||||
// floods the heap with short-lived bitmap allocations, and eventually
|
||||
// corrupts FreeRTOS state. The advance table persists across calls per
|
||||
// font, so the cost amortizes to ~ASCII-size after the first chunk.
|
||||
if (renderer.isSdCardFont(cachedFontId)) {
|
||||
renderer.ensureSdCardFontReady(cachedFontId, reinterpret_cast<const char*>(buffer), /*styleMask=*/0x01);
|
||||
}
|
||||
|
||||
// Parse lines from buffer
|
||||
size_t pos = 0;
|
||||
|
||||
@@ -231,7 +242,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
|
||||
break;
|
||||
}
|
||||
|
||||
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
int lineWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR);
|
||||
|
||||
if (lineWidth <= viewportWidth) {
|
||||
outLines.push_back(line);
|
||||
@@ -242,7 +253,8 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
|
||||
|
||||
// Find break point
|
||||
size_t breakPos = line.length();
|
||||
while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) {
|
||||
while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, line.substr(0, breakPos).c_str(),
|
||||
EpdFontFamily::REGULAR) > viewportWidth) {
|
||||
// Try to break at space
|
||||
size_t spacePos = line.rfind(' ', breakPos - 1);
|
||||
if (spacePos != std::string::npos && spacePos > 0) {
|
||||
@@ -354,12 +366,12 @@ void TxtReaderActivity::renderPage() {
|
||||
// x already set to left margin
|
||||
break;
|
||||
case CrossPointSettings::CENTER_ALIGN: {
|
||||
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR);
|
||||
x = cachedOrientedMarginLeft + (contentWidth - textWidth) / 2;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::RIGHT_ALIGN: {
|
||||
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR);
|
||||
x = cachedOrientedMarginLeft + contentWidth - textWidth;
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user