Compare commits

...
26 Commits
Author SHA1 Message Date
Zach Nelson 8959836e46 Restore lost block-quote on WARNING
Compile Release / build-release (push) Canceled after 0s
2026-05-15 14:30:38 -05:00
Chun Ming Lee fd79074d43 fix: handle fallbacks for advance table and prewarm (#1929)
## Summary
- Fixes #1928 by having the prewarm and advance table functions resolve
fallback styles
---

### AI Usage
Did you use AI tools to help write this code?  YES - Codex

---------

Co-authored-by: Uri Tauber <uritaube@gmail.com>
# Conflicts:
#	lib/EpdFont/SdCardFont.cpp
2026-05-15 11:44:20 -05:00
Zach Nelson b971f7bda4 correct broken internal anchor 2026-05-15 11:29:46 -05:00
Justin Mitchell c26e410c58 fix: Add documentation for USB-locked Xteink devices (#1990)
Document the Xteink Unlocker tool requirement for third-party purchased
xteink units that ship with USB flashing locked. Include warnings about
bricking risks when flashing unsupported firmwares (e.g. Papyrix) on
locked devices, as they may permanently lock the device with no recovery
path.
# Conflicts:
#	README.md
2026-05-15 11:00:41 -05:00
Zach Nelson 16e5e5f00b Update flasher links for X3 support 2026-05-15 10:31:52 -05:00
Zach Nelson 74e75746df Update to @Uri-Tauber's README.md 2026-05-15 10:17:54 -05:00
Zach Nelson a7586f20a1 fix: Prepare SD card font caches from txt reader (#1973)
## Summary

SD card font fixes:
- `TxtReaderActivity` needs to call `renderer.ensureSdCardFontReady` to
build the advance lookup table to support rendering with SD card fonts.
This revealed that `TxtReaderActivity` was inconsistently performing
layout with `getTextWidth`, when the renderer actually uses
`getTextAdvanceX`, which can lead to minor inconsistencies in alignment.
- Avoid allocating one big `allText` string in
`ParsedText::layoutAndExtractLines`. Instead, pass the vector of word
strings directly to `SdCardFont::buildAdvanceTable`, where the algorithm
just needs to iterate codepoints anyway.

---

### 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: Justin Mitchell <justin@jmitch.com>
# Conflicts:
#	lib/EpdFont/SdCardFont.cpp
2026-05-15 09:56:21 -05:00
Zach Nelson b145e4437c Merge remote-tracking branch 'upstream/master' into release/1.3.0 2026-05-12 12:37:43 -05:00
Danila Yudin 3c34a8310e docs: fix KOReader sync guide link (#1930)
## Summary

* Fix the README link to the KOReader Sync quick setup section in the
user guide.
* Update the fragment from the old 3.6.5 anchor to the current 3.6.7
anchor.

## Additional Context

The README pointed to `#365-koreader-sync-quick-setup`, but
`USER_GUIDE.md` defines this section as `3.6.7 KOReader Sync Quick
Setup`, with the matching anchor `#367-koreader-sync-quick-setup`.

Verified with `rg` that the README now points to the existing heading.
PlatformIO checks were not run because this is a Markdown-only link fix
and `pio` is not installed in this environment.
2026-05-12 12:23:50 -05:00
WuTofu bc6e090aa8 feat: separate into "Download All" and "Update All" in font manager (#1955)
## Summary

* **What is the goal of this PR?**  
Separate the font manager's combined `Download / Update All` action into
separate `Download All` and `Update All` rows

* **What changes are included?**  
- Updated multiple i18n translation files to add `STR_UPDATE_ALL` and
adjust `STR_DOWNLOAD_ALL` text.
- Added separate handlers: `downloadAll()` for fonts that have been
installed and `updateAll()` for fonts with updates.
  - `Download All` and `Update All` are only shown when applicable.

---

### 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-05-12 12:17:01 -05:00
Jan Ivanov 8d1b86a893 feat: add next / prev labels to bmp viewer (#1852)
## Summary

Bind `prev` / `next` functionality to the left and right buttons in BMP
Viewer and adds labels

<img width="718" height="953" alt="image"
src="https://github.com/user-attachments/assets/c6dac14e-14f5-4cbf-9298-772cfc479f33"
/>

## Additional Context

* 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? _**NO**_
2026-05-12 15:06:35 +03:00
Zach Nelson 63d5094f2e Revert "feat: closest-pt size selection instead of ordinal slot" (#1949)
Reverts crosspoint-reader/crosspoint-reader#1912

This was meant to be more robust with partial SD card fonts, but causes
trouble for folks using custom font sizes. We need a better approach to
decouple numeric font sizes from S/M/L/XL settings.
2026-05-11 12:44:52 -05:00
Danila Yudin 24977048c3 docs: fix hyphenation updater script name (#1931)
## Summary

* Rename the hyphenation update script to match the documented
`update_hyphenation.sh` name.
* Update the hyphenation trie documentation command example to use the
renamed script.

## Additional Context

The prose in `docs/hyphenation-trie-format.md` referred to
`update_hyphenation.sh`, but the command example and file on disk used
`update_hypenation.sh`. This made the documented script name
inconsistent with the runnable command.

Verified with `rg` that no references to the misspelled script name
remain. Also ran `bash -n scripts/update_hyphenation.sh` to syntax-check
the renamed script without downloading trie files.
2026-05-11 11:23:05 -05:00
Danila Yudin 63e92ec74e fix: make script help paths lightweight (#1937)
## Summary

Consolidate the script help/startup fixes so standalone helper commands
can show usage without requiring runtime-only dependencies or generating
files.

## Details

Several helper scripts imported optional packages, evaluated newer
annotations, or started generation before users could inspect CLI usage.
On a fresh checkout, this made common help paths fail on missing
packages such as `cairosvg`, `Pillow`, `freetype-py`, `fontTools`,
`pyphen`, `pyserial`, or on Python 3.9 annotation evaluation. A few
generators also treated `--help` as a normal output argument and wrote
files instead of printing usage.

This change keeps runtime dependencies on the code paths that need them,
handles lightweight help/list commands first, and adds explicit `--help`
handling for generator scripts that previously started work.

## Validation

- `python3 <script> --help` across tracked Python scripts, excluding
`scripts/patch_jpegdec.py` because it is a PlatformIO pre-build hook
rather than a standalone CLI
- `python3 scripts/convert_icon.py` still exits with usage for missing
required arguments
- `python3 lib/EpdFont/scripts/fontconvert_sdcard.py --list-presets`
- `python3 -m py_compile` for the changed scripts
2026-05-11 11:21:13 -05:00
KymAndriyandKymAndriy accd50b593 fix: Ukrainian-translation (#1946)
## Summary

Micro fix Ukrainian translation

---

### 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**_

Co-authored-by: KymAndriy <test@notamail.ua>
2026-05-11 10:29:05 -05:00
26250c0b80 fix: Ukrainian translation (#1939)
## Summary

Update Ukrainian translation


### AI Usage

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

---------

Co-authored-by: Kym_Adnriy <kym_andr@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 09:53:22 -05:00
KemoNine 99ac1c5c89 fix: sd font download urls in docs (#1945)
## Summary

SD font binaries are published in their own repo, this updates the docs
to have the proper informatoin.

---

### 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, claude**
2026-05-11 10:21:13 -04:00
KemoNine 90874dae9f fix: sd font folder paths in documentation (#1944)
## Summary

The paths in the sd font documentation were never updated to match the
code's use of `/fonts` and `/.fonts`. This fixes the paths in the docs.

### 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, Claude**
2026-05-11 09:49:32 -04:00
WuTofu a0037ced2b feat: add font family deletion functionality (#1919)
## Summary

* **What is the goal of this PR?**

1. Adds font family deletion support to the font download activity so
users can remove installed font families directly from the download
list.
2. Address an issue where a font family manually deleted in the file
browser by user will show as "update" in the `FontDownloadActivity`
 

* **What changes are included?**
* Updated button hint label to show `Delete` when deletion is available.
  * Added confirmation before deleting a selected installed font family.
* Refreshed font registry in `fetchAndParseManifest` (for the second
goal above)

---

### 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-05-11 15:45:38 +02:00
Uri Tauber 20fee843c7 fix: Missing navigation button labels in Roundedraff theme (#1905) 2026-05-10 21:15:45 +03:00
Uri Tauber 181ed6c488 fix: gracefully resolve fonts missing variants (#1921) 2026-05-10 21:11:11 +03:00
Jack R 3ac1ab13a0 fix: distribute justifyExtra to non-breaking space tokens (#1783) 2026-05-10 21:10:51 +03:00
KemoNine dd06e71b66 fix: remove percent rendering from activities (#1901) 2026-05-10 21:10:23 +03:00
Vincent Politzer 4e5e2fe40a feat: focus reading (#1670)
## Summary

This PR introduces **Focus Reading**, a generic implementation of
artificial fixation points (similar to Bionic Reading) designed to
improve reading speed and focus by bolding the initial characters of
words. This is achieved by dynamically bolding characters during
indexing.

<img width="500" alt="Focus Reading on X3"
src="https://github.com/user-attachments/assets/94a632a5-82da-47be-957c-538b35bf84d9"
/>

### Implementation Details
#### Core Text Engine (`ParsedText`)
- Modified `ParsedText::addWord` to implement a custom bolding
algorithm. It uses a 45% ratio for bolding, with a minimum of 1
character and a maximum of 9.
- UTF-8 Safety: Integrated `utf8NextCodepoint` to ensure character
counting and string slicing occur at safe byte boundaries, preventing
corruption of multi-byte characters (e.g., accented letters or smart
quotes).
- Intelligent Tokenization: This correctly identifies and separates
"word" characters (letters, apostrophes, hyphens) from "non-word"
characters (numbers, brackets, smart quotes).
- Formatting Preservation: The logic ensures that punctuation is not
"stolen" for the bolding count and that existing styles (like italics or
underlines) are preserved across the bold/regular split.
- Processing at indexing stage reduces CPU load at render-time and
ensures layout/fit is unaffected.
- Split details are tracked with `wordIsFocusSuffix`. After splitting
and layout, suffixes are merged back into their preceding word entries
to prevent a doubling of RAM usage.

#### Settings and UI
- Version Management: Bumped `SECTION_FILE_VERSION` to `21`
- Global Settings: Added `focusReadingEnabled` to `CrossPointSettings`.
- User Interface: Added a new toggle in the "Reader" section of the
settings menu, positioned after the "Embedded Style" option.
- Localization: Added the `STR_FOCUS_READING` string

#### Plumbing
- Plumbed the `focusReadingEnabled` boolean through
`EpubReaderActivity`, `Section`, and `ChapterHtmlSlimParser` to ensure
the user's setting reaches the `ParsedText` constructor during chapter
indexing.

## Additional Context

### Files Changed
- `lib/Epub/Epub/ParsedText.h / .cpp`: Core fixation logic and UTF-8
tokenization.
- `lib/Epub/Epub/Section.h / .cpp`: Cache header updates and
invalidation logic.
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h / .cpp`: Plumbing the
setting to text blocks.
- `src/CrossPointSettings.h`: Data persistence for the new setting.
- `src/SettingsList.h`: UI toggle implementation.
- `src/activities/reader/EpubReaderActivity.cpp`: Handling settings
changes during reading sessions.
- `lib/I18n/translations/*.yaml`: UI strings.

---

### 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-10 20:02:14 +02:00
Zach Nelson 2ff63884d6 fix: Restore performance in fontconvert_sdcard.py (#1924)
## Summary

#1910 caused a massive performance degradation in the way rasterized
glyph buffers were handled during pixel iteration. This change restores
the original performance characteristics so the font generation job
finishes in a reasonable amount of time.

---

### 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-10 12:01:02 -05:00
Zach Nelson 74b8cac928 chore: Add verbose mode to build-sd-fonts.py (#1923)
## Summary

One of the recent font conversion script changes obliterated
performance. Add a verbose mode to build-sd-fonts.py to help diagnose
problems. Changed the CI process to use verbose mode.

---

### 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-10 11:55:57 -05:00
72 changed files with 1172 additions and 432 deletions
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
echo "metadata=$(python3 -c 'from cpfont_version import FONTS_MANIFEST_VERSION; print(FONTS_MANIFEST_VERSION)')" >> "$GITHUB_OUTPUT"
- name: Build SD card fonts
run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean
run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean --verbose -j 1
- name: Flatten output for release assets
run: |
@@ -83,7 +83,7 @@ jobs:
--title "$TITLE" \
--notes "Pre-built \`.cpfont\` font files for CrossPoint Reader.
Download individual files or use **Settings > System > Download Fonts** on the device.
Download individual files or use **Settings > System > Manage Fonts** on the device.
See [SD Card Fonts documentation](https://github.com/${{ github.repository }}/blob/main/docs/sd-card-fonts.md) for details."
@@ -103,4 +103,4 @@ jobs:
This is revision **${{ steps.tags.outputs.revision }}** — see [\`${{ steps.tags.outputs.versioned }}\`](https://github.com/${{ env.FONTS_REPO }}/releases/tag/${{ steps.tags.outputs.versioned }}) for the immutable copy.
Download individual files or use **Settings > System > Download Fonts** on the device."
Download individual files or use **Settings > System > Manage fonts** on the device."
+166 -108
View File
@@ -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).
![](./docs/images/cover.jpg)
![CrossPoint Reader running on Xteink device](./docs/images/cover.jpg)
## 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#365-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, its 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.
+33
View File
@@ -0,0 +1,33 @@
# Focus Reading
Focus Reading is a reading aid that bolds the first portion of each word, guiding your eyes to natural fixation points and helping you read faster with less effort. Some readers — particularly those with ADHD — find it helps them stay engaged with the text and reduces mind-wandering. It is inspired by the Bionic Reading technique.
<img src="./images/focus-reading/focus-reading.jpg" height="500" alt="Comparison of the same page with and without Focus Reading enabled" />
*Left: Focus Reading off. Right: Focus Reading on. Both using Literata.*
## Enabling Focus Reading
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.
## Examples
<img src="./images/focus-reading/focus-reading-notoserif.jpg" height="500" alt="Focus Reading with Noto Serif font" />
*Focus Reading with Noto Serif font*
<img src="./images/focus-reading/focus-reading-merriweather.jpg" height="500" alt="Focus Reading with Merriweather font" />
*Focus Reading with Merriweather font*
<img src="./images/focus-reading/focus-reading-atkinson.jpg" height="500" alt="Focus Reading with Atkinson Hyperlegible Next font" />
*Focus Reading with Atkinson Hyperlegible Next font*
## Notes
- Focus Reading only applies to regular body text. Already-bold text (headings, emphasis) is left unchanged.
- The setting is per-device, not per-book — it applies to all books while enabled.
+1 -1
View File
@@ -49,5 +49,5 @@ A convenient script `update_hyphenation.sh` is used to update all languages.
To use it, run:
```sh
./scripts/update_hypenation.sh
./scripts/update_hyphenation.sh
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

+25 -15
View File
@@ -10,7 +10,7 @@ There are three ways to install fonts:
### Option 1: Download from device (recommended)
1. Connect your CrossPoint reader to WiFi
2. Go to **Settings > System > Download Fonts**
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**
@@ -24,28 +24,38 @@ There are three ways to install fonts:
### Option 3: Manual SD card copy
1. Download font files from the
[Releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases/tag/sd-fonts)
2. Copy font family folders to `/.crosspoint/fonts/` on your SD card:
[crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts)
2. Copy font family folders to one of two locations on your SD card:
- `/.fonts/` — hidden directory (preferred; keeps the SD root tidy
when mounted on a desktop)
- `/fonts/` — visible directory (use this if your OS hides dot-files
and you'd rather see the folder in your file manager)
Both roots are always scanned at boot and the results are merged: a
family installed in `/fonts/` shows up even when `/.fonts/` also
exists, and vice versa. The two roots only collide if the same family
name appears in both — in that case the copy in `/.fonts/` wins and
the duplicate in `/fonts/` is ignored.
SD Card Root/
── .crosspoint/
└── fonts/
├── Bookerly-SD/
├── Bookerly-SD_12.cpfont
├── Bookerly-SD_14.cpfont
│ ├── Bookerly-SD_16.cpfont
│ └── Bookerly-SD_18.cpfont
── .fonts/ ← Hidden root (preferred)
└── Literata/
├── Literata_12.cpfont
├── Literata_14.cpfont
├── Literata_16.cpfont
└── Literata_18.cpfont
└── fonts/ ← Visible root (equally valid)
└── Merriweather/
├── Merriweather_12.cpfont
└── ...
3. Insert the SD card and power on your CrossPoint reader
## Available Pre-Built Fonts
| Font | Best For | Languages |
|------|----------|-----------|
| Bookerly-SD | General reading | English, Western European |
| NotoSansExtended | Multi-script reading | European, Greek, Cyrillic, Georgian, Armenian, Ethiopic |
| NotoSansCJK | Chinese/Japanese/Korean | CJK + ASCII |
The current list of pre-built fonts is maintained in the
[crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts).
## Converting Custom Fonts
+121 -67
View File
@@ -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) {
+14 -1
View File
@@ -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
+12 -8
View File
@@ -28,21 +28,25 @@ int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyNam
return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel
}
bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize) {
bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) {
// Unload any previously loaded family first
if (!loadedFamilyName_.empty()) {
unloadAll(renderer);
}
// Pick the single file whose size is closest to targetPtSize. Loading
// only one size bounds resident memory (intervals + kern/ligature tables
// per style) to one file's worth, vs. N_sizes × per-file overhead.
const SdCardFontFileInfo* selected = family.pickClosestSize(targetPtSize);
if (!selected) {
// 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()) {
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());
@@ -66,8 +70,8 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender
renderer.registerSdCardFont(fontId, font);
loaded_.push_back({font, fontId, selected->pointSize});
LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (target=%u)", selected->path.c_str(), selected->pointSize, fontId,
font->styleCount(), targetPtSize);
LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (sizeEnum=%u)", selected->path.c_str(), selected->pointSize,
fontId, font->styleCount(), fontSizeEnum);
EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3));
renderer.insertFont(fontId, fontFamily);
+5 -9
View File
@@ -15,16 +15,12 @@ class SdCardFontManager {
SdCardFontManager(const SdCardFontManager&) = delete;
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
// Load the single size whose pointSize is closest to targetPtSize. 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.
//
// Closest-pt selection is robust against families that don't ship the
// canonical {12,14,16,18}: a family with only [10,14,18] still resolves
// any reasonable target, where ordinal slot-mapping by SMALL..EXTRA_LARGE
// would mis-select.
// 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.
// Returns true on success.
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize);
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
// Unload everything, unregister from renderer.
void unloadAll(GfxRenderer& renderer);
-17
View File
@@ -4,8 +4,6 @@
#include <Logging.h>
#include <algorithm>
#include <climits>
#include <cstdlib>
#include <cstring>
// --- SdCardFontFamilyInfo helpers ---
@@ -40,21 +38,6 @@ std::vector<uint8_t> SdCardFontFamilyInfo::availableSizes() const {
return sizes;
}
const SdCardFontFileInfo* SdCardFontFamilyInfo::pickClosestSize(uint8_t targetPtSize) const {
const SdCardFontFileInfo* selected = nullptr;
int bestDiff = INT_MAX;
for (const auto& f : files) {
int diff = std::abs(static_cast<int>(f.pointSize) - static_cast<int>(targetPtSize));
// Strict < ensures the first scan wins on ties; then tie-break by smaller
// pointSize to make the choice independent of filesystem enumeration order.
if (diff < bestDiff || (diff == bestDiff && selected && f.pointSize < selected->pointSize)) {
bestDiff = diff;
selected = &f;
}
}
return selected;
}
// --- SdCardFontRegistry ---
bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) {
-9
View File
@@ -20,15 +20,6 @@ struct SdCardFontFamilyInfo {
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
bool hasSize(uint8_t size) const;
std::vector<uint8_t> availableSizes() const;
// Pick the file whose pointSize is closest to targetPtSize. On ties (equal
// distance) prefers the smaller pointSize so behaviour is deterministic
// across SD card layouts. Returns nullptr when files is empty.
//
// Robust against families that don't ship the canonical {12,14,16,18} set:
// a family with only [10,14,18] resolves target 12 → 10 (or 14 on tie),
// target 16 → 14 or 18, etc., instead of mis-indexing by ordinal slot.
const SdCardFontFileInfo* pickClosestSize(uint8_t targetPtSize) const;
};
class SdCardFontRegistry {
+75 -14
View File
@@ -17,6 +17,12 @@ Usage:
# Generate only specific families
python3 build-sd-fonts.py --only Literata,IBMPlexMono
# Stream child process output for debugging
python3 build-sd-fonts.py --verbose
# Override the per-family timeout (default: 600s)
python3 build-sd-fonts.py --timeout 1200
"""
import argparse
@@ -25,6 +31,8 @@ import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
@@ -141,7 +149,16 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa
return resolved
def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]:
def _stream_pipe(pipe, prefix: str, dest: list[str]):
"""Read lines from a pipe, print with prefix, and accumulate into dest."""
for line in pipe:
dest.append(line)
print(f" [{prefix}] {line}", end="", flush=True)
def build_family(
family: dict, output_base: Path, verbose: bool = False, timeout: int = 600
) -> tuple[str, bool, str]:
"""Build a single font family. Returns (name, success, message)."""
name = family["name"]
output_dir = output_base / name
@@ -185,18 +202,52 @@ def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]:
cmd.append("--force-autohint")
# Run fontconvert_sdcard.py
start = time.monotonic()
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
)
if result.returncode != 0:
return name, False, result.stderr.strip() or f"Exit code {result.returncode}"
return name, True, ""
except subprocess.TimeoutExpired:
return name, False, "Timed out after 600s"
if verbose:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout_lines: list[str] = []
stderr_lines: list[str] = []
t_out = threading.Thread(
target=_stream_pipe, args=(proc.stdout, name, stdout_lines)
)
t_err = threading.Thread(
target=_stream_pipe, args=(proc.stderr, f"{name}/err", stderr_lines)
)
t_out.start()
t_err.start()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
elapsed = time.monotonic() - start
return name, False, f"Timed out after {elapsed:.0f}s"
finally:
t_out.join()
t_err.join()
if proc.returncode != 0:
err = "".join(stderr_lines).strip()
return name, False, err or f"Exit code {proc.returncode}"
return name, True, ""
else:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
)
if result.returncode != 0:
return name, False, result.stderr.strip() or f"Exit code {result.returncode}"
return name, True, ""
except subprocess.TimeoutExpired as e:
elapsed = time.monotonic() - start
tail = ""
captured = getattr(e, "stderr", None) or getattr(e, "stdout", None)
if captured:
lines = captured.strip().splitlines()
tail = "\n Last output:\n" + "\n".join(f" | {l}" for l in lines[-20:])
return name, False, f"Timed out after {elapsed:.0f}s{tail}"
except Exception as e:
return name, False, str(e)
@@ -252,6 +303,14 @@ def main():
help="Max parallel jobs (default: number of families)"
)
parser.add_argument("--clean", action="store_true", help="Clean output directory before building")
parser.add_argument(
"--verbose", "-v", action="store_true",
help="Stream child process output in real time (useful for debugging timeouts)"
)
parser.add_argument(
"--timeout", type=int, default=600,
help="Per-family timeout in seconds (default: 600)"
)
args = parser.parse_args()
if args.manifest and not args.base_url:
@@ -303,12 +362,14 @@ def main():
# Build phase (parallel)
max_workers = args.jobs or len(families)
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n")
verbose = args.verbose
timeout = args.timeout
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs, timeout {timeout}s) ===\n")
failed = []
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(build_family, family, output_base): family["name"]
executor.submit(build_family, family, output_base, verbose, timeout): family["name"]
for family in families
}
for future in as_completed(futures):
+3 -2
View File
@@ -1,12 +1,10 @@
#!python3
import freetype
import zlib
import sys
import re
import math
import argparse
from collections import namedtuple
from fontTools.ttLib import TTFont
# Force UTF-8 stdout so that `python fontconvert.py … > foo.h` on Windows
# (default cp1252) doesn't emit UTF-16 LE / replacement chars in the generated
@@ -27,6 +25,9 @@ parser.add_argument("--force-autohint", dest="force_autohint", action="store_tru
parser.add_argument("--pnum", dest="pnum", action="store_true", help="Use proportional numerals (pnum OpenType feature) instead of default tabular figures. Reduces visual gaps between digits in running prose.")
args = parser.parse_args()
import freetype
from fontTools.ttLib import TTFont
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
font_stack = [freetype.Face(f) for f in args.fontstack]
+19 -7
View File
@@ -22,7 +22,8 @@ Usage:
"""
import freetype
from __future__ import annotations
import struct
import sys
import os
@@ -31,8 +32,6 @@ import math
import argparse
from collections import namedtuple
from fontTools.ttLib import TTFont
from cpfont_version import CPFONT_VERSION
# --- Unicode interval presets ---
@@ -252,6 +251,8 @@ def extract_kerning_fonttools(font_path, codepoints, ppem):
codepoints. Values are scaled from font design units to integer
pixels at ppem.
"""
from fontTools.ttLib import TTFont
font = TTFont(font_path)
units_per_em = font['head'].unitsPerEm
cmap = font.getBestCmap() or {}
@@ -402,6 +403,8 @@ def extract_ligatures_fonttools(font_path, codepoints):
Returns list of (packed_pair, ligature_codepoint) for the given codepoints.
Multi-character ligatures are decomposed into chained pairs.
"""
from fontTools.ttLib import TTFont
font = TTFont(font_path)
cmap = font.getBestCmap() or {}
@@ -514,6 +517,8 @@ def extract_ligatures_fonttools(font_path, codepoints):
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False):
"""Rasterize all glyphs for one font style. Returns StyleRasterData."""
import freetype
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
style_label = style_names.get(style_id, str(style_id))
@@ -535,14 +540,16 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
return face
return None
# Validate intervals: remove codepoints not present in the font
# Validate intervals: remove codepoints not present in the font.
# Only check glyph existence via get_char_index — do NOT call
# load_glyph here, as that triggers FT_LOAD_RENDER at the target
# DPI and doubles total rasterization time for no benefit.
print(f" [{style_label}] Validating intervals against font...", file=sys.stderr)
validated_intervals = []
for i_start, i_end in intervals:
start = i_start
for code_point in range(i_start, i_end + 1):
f = load_glyph(code_point)
if f is None:
if face.get_char_index(code_point) == 0:
if start < code_point:
validated_intervals.append((start, code_point - 1))
start = code_point + 1
@@ -575,13 +582,18 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F
# pitch == width and a top-down layout — that holds in the common
# case but breaks on padded or flipped bitmaps and corrupts the
# output. Walk by (row, col) using the real pitch instead.
#
# Cache bitmap.buffer in a local — ctypes struct field access
# creates a new Python wrapper object each time, so re-evaluating
# it per pixel is catastrophically slow.
pixels4g = []
px = 0
buf = bitmap.buffer
abs_pitch = abs(bitmap.pitch)
for y in range(bitmap.rows):
row_offset = y * abs_pitch if bitmap.pitch >= 0 else (bitmap.rows - 1 - y) * abs_pitch
for x in range(bitmap.width):
v = bitmap.buffer[row_offset + x]
v = buf[row_offset + x]
if x % 2 == 0:
px = (v >> 4)
else:
@@ -234,6 +234,9 @@ def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <font_headers_directory>", file=sys.stderr)
sys.exit(1)
if sys.argv[1] in ("-h", "--help"):
print(f"Usage: {sys.argv[0]} <font_headers_directory>")
sys.exit(0)
font_dir = sys.argv[1]
if not os.path.isdir(font_dir):
+228 -23
View File
@@ -74,21 +74,170 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
}
// Checks if a UTF-8 codepoint should be counted as part of a word for Focus Reading
bool isWordCharacter(uint32_t cp) {
// ASCII range (Catches 95%+ of characters immediately)
if (cp < 128) {
// Bitwise trick: (cp | 0x20) converts uppercase ASCII to lowercase.
// This checks for A-Z and a-z mathematically, avoiding memory lookups and <cctype>
return ((cp | 0x20) >= 'a' && (cp | 0x20) <= 'z') || cp == '\'';
}
// General Punctuation Block, Currency, Math, Arrows, & Symbols (0x2000 - 0x2BFF)
if (cp >= 0x2000 && cp <= 0x2BFF) {
// Explicitly allow smart quotes, reject all other general punctuation (em-dashes, etc.)
return cp == 0x2018 || cp == 0x2019;
}
// Latin-1 Punctuation Block (0x00A1 - 0x00BF)
if (cp >= 0x00A1 && cp <= 0x00BF) {
// Allow ordinal indicators and micro sign, reject the rest (¡, ¿, «, », etc.)
return cp == 0x00AA || cp == 0x00B5 || cp == 0x00BA;
}
// Rejects Two-em dash, Three-em dash, Double oblique hyphen, etc.
if (cp >= 0x2E00 && cp <= 0x2E7F) return false;
// Rejects Modifier Minus (0x02D7), Small Hyphen (0xFE63), and Fullwidth Hyphen (0xFF0D)
if (cp == 0x02D7 || cp == 0xFE63 || cp == 0xFF0D) return false;
// Assume all other Unicode ranges (accented letters, Cyrillic, Greek, etc.) are valid
return true;
}
} // namespace
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
const bool attachToPrevious) {
if (word.empty()) return;
words.push_back(std::move(word));
EpdFontFamily::Style combinedStyle = fontStyle;
EpdFontFamily::Style baseStyle = fontStyle;
if (underline) {
combinedStyle = static_cast<EpdFontFamily::Style>(combinedStyle | EpdFontFamily::UNDERLINE);
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::UNDERLINE);
}
wordStyles.push_back(combinedStyle);
wordContinues.push_back(attachToPrevious);
}
// Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
words.push_back(std::move(word));
wordStyles.push_back(baseStyle);
wordContinues.push_back(attachToPrevious);
wordIsFocusSuffix.push_back(false);
return;
}
// --- FOCUS READING LOGIC BELOW ---
// Pre-reserve capacity to prevent mid-word heap reallocations.
size_t maxPossibleNewTokens = word.length();
size_t requiredSize = words.size() + maxPossibleNewTokens;
if (words.capacity() < requiredSize) {
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
size_t newCapacity = words.capacity() * 2;
// Ensure the doubled capacity is actually enough for this specific word
if (newCapacity < requiredSize) {
newCapacity = requiredSize;
}
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
if (newCapacity < 16) {
newCapacity = 16;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
}
// Lambda helper to process and push individual sub-segments of the string
// Use std::string_view to avoid heap allocations when slicing
auto processSegment = [&](std::string_view segment, bool isWord, bool attach) {
if (!isWord) {
// Punctuation and Numbers stay regular
words.emplace_back(segment);
wordStyles.push_back(baseStyle);
wordContinues.push_back(attach);
wordIsFocusSuffix.push_back(false);
} else {
size_t charCount = 0;
const unsigned char* countPtr = reinterpret_cast<const unsigned char*>(segment.data());
const unsigned char* countEnd = countPtr + segment.length();
while (countPtr < countEnd) {
utf8NextCodepoint(&countPtr);
charCount++;
}
// Target 45% for 1-bold at 4 chars and 3-bold at 7 chars with floor truncation
constexpr size_t FOCUS_READING_PERCENT = 45;
size_t targetBoldChars = (charCount * FOCUS_READING_PERCENT) / 100;
targetBoldChars = std::clamp<size_t>(targetBoldChars, 1, 9);
if (targetBoldChars >= charCount) {
// Whole segment is bold - no suffix split needed
words.emplace_back(segment);
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordIsFocusSuffix.push_back(false);
} else {
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
for (size_t i = 0; i < targetBoldChars; ++i) {
utf8NextCodepoint(&countPtr);
}
size_t splitByteOffset = countPtr - reinterpret_cast<const unsigned char*>(segment.data());
// Bold prefix
words.emplace_back(segment.substr(0, splitByteOffset));
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordIsFocusSuffix.push_back(false);
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
words.emplace_back(segment.substr(splitByteOffset));
wordStyles.push_back(baseStyle);
wordContinues.push_back(true);
wordIsFocusSuffix.push_back(true);
}
}
};
// Tokenize the string by alternating states (Word vs. Non-Word)
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(word.c_str());
const unsigned char* end = ptr + word.length();
const unsigned char* segmentStart = ptr;
uint32_t firstCp = utf8NextCodepoint(&ptr); // Consume the first char to determine initial state
bool inWordSegment = isWordCharacter(firstCp);
bool isFirstSegment = true;
while (ptr < end) {
const unsigned char* currentCpStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
bool isWordChar = isWordCharacter(cp);
// Whenever the character type flips, slice off the segment we just completed and process it
if (isWordChar != inWordSegment) {
size_t segmentLen = currentCpStart - segmentStart;
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
// Only the very first segment inherits the original attachToPrevious flag.
// Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
// Setup for the next segment
segmentStart = currentCpStart;
inWordSegment = isWordChar;
isFirstSegment = false;
}
}
// Process the final remaining segment
size_t segmentLen = end - segmentStart;
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
@@ -106,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.
@@ -128,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;
@@ -153,6 +288,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);
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
}
}
@@ -436,6 +572,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The hyphen remainder is not a focus suffix - it starts fresh on the next line.
wordIsFocusSuffix.insert(wordIsFocusSuffix.begin() + wordIndex + 1, false);
// Continuation flag handling after splitting a word into prefix + remainder.
//
@@ -500,6 +638,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
renderer.getSpaceAdvance(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
firstCodepoint(words[lastBreakAt + wordIdx]), wordStyles[lastBreakAt + wordIdx - 1]);
} else if (wordIdx > 0 && continuesVec[lastBreakAt + wordIdx]) {
// Non-breaking space tokens (" " with continues=true) are visible, stretchable spaces —
// count them as justifiable gaps so justifyExtra is distributed to them too.
if (words[lastBreakAt + wordIdx] == " ") {
actualGapCount++;
}
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
totalNaturalGaps +=
renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx - 1]),
@@ -541,6 +684,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
advance +=
renderer.getKerning(fontId, lastCodepoint(words[lastBreakAt + wordIdx]),
firstCodepoint(words[lastBreakAt + wordIdx + 1]), wordStyles[lastBreakAt + wordIdx]);
// Non-breaking space tokens are stretchable — expand them during justification like normal spaces.
if (words[lastBreakAt + wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
blockStyle.alignment == CssTextAlign::Justify && !isLastLine) {
advance += justifyExtra;
}
xpos += advance;
} else {
int gap = 0;
@@ -567,6 +715,63 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
}
processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
// Fast path: when no word on this line was split for focus reading, skip the merge work
// entirely and pass empty boundary/suffixX vectors. TextBlock pays zero per-word RAM cost
// for these annotations when the vectors are empty.
bool lineHasFocusSplit = false;
for (size_t i = 0; i < lineWordCount; i++) {
if (wordIsFocusSuffix[lastBreakAt + i]) {
lineHasFocusSplit = true;
break;
}
}
if (!lineHasFocusSplit) {
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
return;
}
// Slow path: merge focus suffix tokens back into their preceding word entry so each
// original word occupies one TextBlock slot. Splits are recorded as per-word annotations
// applied at render time, cutting the token count significantly when the feature is active.
std::vector<std::string> outWords;
std::vector<int16_t> outXPos;
std::vector<EpdFontFamily::Style> outStyles;
std::vector<uint8_t> outBoundaries;
std::vector<uint16_t> outSuffixX;
outWords.reserve(lineWordCount);
outXPos.reserve(lineWordCount);
outStyles.reserve(lineWordCount);
outBoundaries.reserve(lineWordCount);
outSuffixX.reserve(lineWordCount);
for (size_t i = 0; i < lineWordCount; i++) {
if (wordIsFocusSuffix[lastBreakAt + i] && !outWords.empty()) {
// Focus suffix: merge string into the preceding bold-prefix entry.
outWords.back() += lineWords[i];
} else {
// Normal word: check for a following focus suffix to record the byte boundary.
uint8_t boundary = 0;
uint16_t suffixX = 0;
if (i + 1 < lineWordCount && wordIsFocusSuffix[lastBreakAt + i + 1]) {
boundary = static_cast<uint8_t>(std::min(lineWords[i].size(), size_t{255}));
// Suffix x offset = layout-time advance of the bold prefix, already known from xpos table.
suffixX = static_cast<uint16_t>(lineXPos[i + 1] - lineXPos[i]);
}
outWords.push_back(std::move(lineWords[i]));
outXPos.push_back(lineXPos[i]);
// For focus entries with a suffix, strip BOLD from the stored style.
// Render re-applies it to the prefix portion only, via the boundary field.
const EpdFontFamily::Style storedStyle =
boundary > 0 ? static_cast<EpdFontFamily::Style>(lineWordStyles[i] & ~EpdFontFamily::BOLD)
: lineWordStyles[i];
outStyles.push_back(storedStyle);
outBoundaries.push_back(boundary);
outSuffixX.push_back(suffixX);
}
}
processLine(std::make_shared<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
}
+8 -3
View File
@@ -15,10 +15,12 @@ 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 (no space before it)
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
BlockStyle blockStyle;
bool extraParagraphSpacing;
bool hyphenationEnabled;
bool focusReadingEnabled;
void applyParagraphIndent();
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
@@ -35,8 +37,11 @@ class ParsedText {
public:
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
const BlockStyle& blockStyle = BlockStyle())
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
const bool focusReadingEnabled = false, const BlockStyle& blockStyle = BlockStyle())
: blockStyle(blockStyle),
extraParagraphSpacing(extraParagraphSpacing),
hyphenationEnabled(hyphenationEnabled),
focusReadingEnabled(focusReadingEnabled) {}
~ParsedText() = default;
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
+15 -11
View File
@@ -13,8 +13,8 @@ namespace {
constexpr uint8_t SECTION_FILE_VERSION = 23;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) +
sizeof(uint32_t);
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
sizeof(uint32_t) + sizeof(uint32_t);
struct PageLutEntry {
uint32_t fileOffset;
@@ -43,7 +43,8 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool embeddedStyle, const uint8_t imageRendering) {
const bool embeddedStyle, const uint8_t imageRendering,
const bool focusReadingEnabled) {
if (!file) {
LOG_DBG("SCT", "File not open for writing header");
return;
@@ -51,8 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) +
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -64,6 +65,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, hyphenationEnabled);
serialization::writePod(file, embeddedStyle);
serialization::writePod(file, imageRendering);
serialization::writePod(file, focusReadingEnabled);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
@@ -74,7 +76,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering) {
const uint8_t imageRendering, const bool focusReadingEnabled) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -99,6 +101,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
bool fileHyphenationEnabled;
bool fileEmbeddedStyle;
uint8_t fileImageRendering;
bool fileFocusReadingEnabled;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
@@ -108,13 +111,13 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, fileHyphenationEnabled);
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileImageRendering);
serialization::readPod(file, fileFocusReadingEnabled);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering) {
// Explicit close() required: member variable persists beyond function scope
imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) {
file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache();
@@ -148,7 +151,8 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering, const std::function<void()>& popupFn) {
const uint8_t imageRendering, const bool focusReadingEnabled,
const std::function<void()>& popupFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@@ -199,7 +203,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
std::vector<PageLutEntry> lut = {};
// Derive the content base directory and image cache path prefix for the parser
@@ -219,7 +223,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
ChapterHtmlSlimParser visitor(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
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});
},
+4 -3
View File
@@ -18,7 +18,7 @@ class Section {
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
bool embeddedStyle, uint8_t imageRendering);
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
uint32_t onPageComplete(std::unique_ptr<Page> page);
public:
@@ -33,11 +33,12 @@ class Section {
~Section() = default;
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering);
uint8_t imageRendering, bool focusReadingEnabled);
bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, const std::function<void()>& popupFn = nullptr);
uint8_t imageRendering, bool focusReadingEnabled,
const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Look up the page number for an anchor id from the section cache file.
+62 -10
View File
@@ -4,18 +4,44 @@
#include <Logging.h>
#include <Serialization.h>
#include <cstring>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
(uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
// Focus annotations are optional: empty vectors mean no word in this block has a split.
// When present, they must be sized in lockstep with words[].
const bool hasFocus = !wordFocusBoundary.empty();
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(),
(uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size());
return;
}
for (size_t i = 0; i < words.size(); i++) {
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
if (boundary > 0) {
// Focus split: draw bold prefix, then the regular suffix at a pre-computed x offset.
// The bold prefix is bounded to 9 codepoints by the clamp on targetBoldChars in
// ParsedText::addWord; 9 UTF-8 codepoints occupy at most 9 * 4 = 36 bytes, +1 for null = 37.
// suffixX is computed at cache-creation time to avoid font metric lookups at render time.
static constexpr size_t MAX_FOCUS_PREFIX_BYTES = 9 * 4 + 1;
char boldBuf[40];
static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES,
"boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)");
const auto boldStyle = static_cast<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
memcpy(boldBuf, words[i].c_str(), boldLen);
boldBuf[boldLen] = '\0';
renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle);
const int suffixX = wordX + wordFocusSuffixX[i];
renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle);
} else {
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
}
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = words[i];
@@ -42,9 +68,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
}
bool TextBlock::serialize(FsFile& file) const {
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
wordXpos.size(), wordStyles.size());
// Focus annotations are optional; vectors are either empty (no splits in this block)
// or sized in lockstep with words[].
const bool hasFocus = !wordFocusBoundary.empty();
if (words.size() != wordXpos.size() || words.size() != wordStyles.size() ||
(hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) {
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n",
static_cast<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
static_cast<uint32_t>(wordFocusSuffixX.size()));
return false;
}
@@ -53,6 +85,13 @@ bool TextBlock::serialize(FsFile& file) const {
for (const auto& w : words) serialization::writeString(file, w);
for (auto x : wordXpos) serialization::writePod(file, x);
for (auto s : wordStyles) serialization::writePod(file, s);
// Focus block: 1-byte presence flag, followed by per-word vectors only when present.
// Saves 3 bytes/word when focus reading is disabled or no word on this line was split.
serialization::writePod(file, static_cast<uint8_t>(hasFocus ? 1 : 0));
if (hasFocus) {
for (auto b : wordFocusBoundary) serialization::writePod(file, b);
for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx);
}
// Style (alignment + margins/padding/indent)
serialization::writePod(file, blockStyle.alignment);
@@ -76,6 +115,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
std::vector<std::string> words;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<uint8_t> wordFocusBoundary;
std::vector<uint16_t> wordFocusSuffixX;
BlockStyle blockStyle;
// Word count
@@ -94,6 +135,16 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
for (auto& w : words) serialization::readString(file, w);
for (auto& x : wordXpos) serialization::readPod(file, x);
for (auto& s : wordStyles) serialization::readPod(file, s);
// Focus block: presence flag, then vectors only if present. Empty vectors when absent
// signal "no splits in this block" to render() (zero per-word RAM cost).
uint8_t hasFocus;
serialization::readPod(file, hasFocus);
if (hasFocus) {
wordFocusBoundary.resize(wc);
wordFocusSuffixX.resize(wc);
for (auto& b : wordFocusBoundary) serialization::readPod(file, b);
for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx);
}
// Style (alignment + margins/padding/indent)
serialization::readPod(file, blockStyle.alignment);
@@ -109,6 +160,7 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.textIndentDefined);
return std::unique_ptr<TextBlock>(
new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle));
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
blockStyle));
}
+15 -1
View File
@@ -15,14 +15,28 @@ class TextBlock final : public Block {
std::vector<std::string> words;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
// Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold,
// the remainder in the base style. 0 means no split (whole word uses wordStyles[i]).
// N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by
// FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t.
// Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost
// when focus reading is disabled, or on lines that happen to contain no splittable words).
std::vector<uint8_t> wordFocusBoundary;
// Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0.
// Eliminates getTextAdvanceX from the render path. 0 when boundary == 0.
// Empty in lockstep with wordFocusBoundary.
std::vector<uint16_t> wordFocusSuffixX;
BlockStyle blockStyle;
public:
explicit TextBlock(std::vector<std::string> words, std::vector<int16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
std::vector<EpdFontFamily::Style> word_styles, std::vector<uint8_t> focus_boundary,
std::vector<uint16_t> focus_suffix_x, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
wordFocusBoundary(std::move(focus_boundary)),
wordFocusSuffixX(std::move(focus_suffix_x)),
blockStyle(blockStyle) {}
~TextBlock() override = default;
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
@@ -141,7 +141,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle));
wordsExtractedInBlock = 0;
}
@@ -47,6 +47,7 @@ class ChapterHtmlSlimParser {
uint16_t viewportWidth;
uint16_t viewportHeight;
bool hyphenationEnabled;
bool focusReadingEnabled;
const CssParser* cssParser;
bool embeddedStyle;
uint8_t imageRendering;
@@ -101,6 +102,7 @@ class ChapterHtmlSlimParser {
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool focusReadingEnabled,
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,
@@ -116,6 +118,7 @@ class ChapterHtmlSlimParser {
viewportWidth(viewportWidth),
viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled),
focusReadingEnabled(focusReadingEnabled),
completePageFn(completePageFn),
popupFn(popupFn),
cssParser(cssParser),
+30 -4
View File
@@ -10,6 +10,19 @@
#include "FontCacheManager.h"
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.
*/
uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style style) {
return font.resolveStyle(static_cast<uint8_t>(style));
}
} // namespace
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr;
@@ -40,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);
}
@@ -1074,7 +1098,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
// Advance table fast-path for SD card fonts during layout
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
const uint8_t resolvedStyle = resolveSdCardStyle(*sdIt->second, style);
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
@@ -1094,7 +1119,8 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
// so we return just the space advance without kerning.
auto sdIt = sdCardFonts_.find(fontId);
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
const uint8_t resolvedStyle = resolveSdCardStyle(*sdIt->second, style);
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
@@ -1124,7 +1150,7 @@ 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 uint8_t styleIdx = static_cast<uint8_t>(style);
const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style);
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
widthFP += sdIt->second->getAdvance(cp, styleIdx);
}
+2
View File
@@ -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
View File
@@ -259,6 +259,7 @@ STR_SECTION_PREFIX: "Раздзел"
STR_UPLOAD: "Адправіць"
STR_BOOK_S_STYLE: "Стыль кнігі"
STR_EMBEDDED_STYLE: "Убудаваны стыль"
STR_FOCUS_READING: "Фокуснае чытанне"
STR_OPDS_SERVER_URL: "URL OPDS сервера"
STR_SCREENSHOT_BUTTON: "Зрабіць здымак экрана"
STR_IMAGES: "Выявы"
+1
View File
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secció "
STR_UPLOAD: "Puja"
STR_BOOK_S_STYLE: "Estil del llibre"
STR_EMBEDDED_STYLE: "Estil incrustat"
STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_FOOTNOTES: "Notes al peu"
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
+1
View File
@@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Sekce"
STR_UPLOAD: "Nahrát"
STR_BOOK_S_STYLE: "Styl knihy"
STR_EMBEDDED_STYLE: "Vložený styl"
STR_FOCUS_READING: "Soustředěné čtení"
STR_OPDS_SERVER_URL: "URL serveru OPDS"
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
+1
View File
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Afsnit "
STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Bogens stil"
STR_EMBEDDED_STYLE: "Indlejret stil"
STR_FOCUS_READING: "Fokuslæsning"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_FOOTNOTES: "Fodnoter"
STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
+1
View File
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Sectie "
STR_UPLOAD: "Uploaden"
STR_BOOK_S_STYLE: "Stijl van boek"
STR_EMBEDDED_STYLE: "Ingebedde stijl"
STR_FOCUS_READING: "Gefocust lezen"
STR_OPDS_SERVER_URL: "OPDS-server URL"
STR_FOOTNOTES: "Voetnoten"
STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
+5 -3
View File
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Upload"
STR_BOOK_S_STYLE: "Book's Style"
STR_EMBEDDED_STYLE: "Embedded Style"
STR_FOCUS_READING: "Focus Reading"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_SET_SLEEP_COVER: "Set Cover"
STR_FOOTNOTES: "Footnotes"
@@ -310,8 +311,8 @@ STR_DELETE_CONFIRM: "Delete this server?"
STR_OPDS_SERVERS: "OPDS Servers"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
STR_DOWNLOAD_FONTS: "Download Fonts"
STR_FONT_DOWNLOAD: "Font Download"
STR_MANAGE_FONTS: "Manage Fonts"
STR_FONT_BROWSER: "Font Browser"
STR_LOADING_FONT_LIST: "Loading font list..."
STR_NO_FONTS_AVAILABLE: "No fonts available"
STR_FONT_INSTALLED: "Font installed!"
@@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Insufficient SD card space"
STR_FILES_LABEL: "Files: "
STR_SIZE_LABEL: "Size: "
STR_REDOWNLOAD: "Re-download"
STR_DOWNLOAD_ALL: "Download / Update All"
STR_DOWNLOAD_ALL: "Download All"
STR_UPDATE_ALL: "Update All"
STR_ALL_FONTS_INSTALLED: "All fonts installed!"
STR_UPDATE_AVAILABLE: "Update"
STR_CRASH_TITLE: "System Crash"
+1
View File
@@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Osio "
STR_UPLOAD: "Lähetä"
STR_BOOK_S_STYLE: "Kirjan tyyli"
STR_EMBEDDED_STYLE: "Upotettu tyyli"
STR_FOCUS_READING: "Keskittynyt lukeminen"
STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
+1
View File
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Envoyer"
STR_BOOK_S_STYLE: "Style du livre"
STR_EMBEDDED_STYLE: "Style intégré"
STR_FOCUS_READING: "Lecture focalisée"
STR_OPDS_SERVER_URL: "URL serveur OPDS"
STR_FOOTNOTES: "Notes de bas de page"
STR_NO_FOOTNOTES: "Aucune note sur cette page"
+1
View File
@@ -289,6 +289,7 @@ STR_SECTION_PREFIX: "Abschnitt"
STR_UPLOAD: "Hochladen"
STR_BOOK_S_STYLE: "Buch-Stil"
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
STR_FOCUS_READING: "Fokus-Lesen"
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
STR_FOOTNOTES: "Fußnoten"
+1
View File
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Szakasz "
STR_UPLOAD: "Feltöltés"
STR_BOOK_S_STYLE: "Könyv stílusa"
STR_EMBEDDED_STYLE: "Beágyazott stílus"
STR_FOCUS_READING: "Fókuszált olvasás"
STR_OPDS_SERVER_URL: "OPDS szerver URL"
STR_FOOTNOTES: "Lábjegyzetek"
STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
+1
View File
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Sezione "
STR_UPLOAD: "Carica"
STR_BOOK_S_STYLE: "Stile libro"
STR_EMBEDDED_STYLE: "Stile integrato dell'epub"
STR_FOCUS_READING: "Lettura focalizzata"
STR_OPDS_SERVER_URL: "Server OPDS"
STR_FOOTNOTES: "Note a piè pagina"
STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
+1
View File
@@ -258,6 +258,7 @@ STR_SECTION_PREFIX: "Бөлім "
STR_UPLOAD: "Жүктеп салу"
STR_BOOK_S_STYLE: "Кітап стилі"
STR_EMBEDDED_STYLE: "Кірістірілген стиль"
STR_FOCUS_READING: "Зейінді оқу"
STR_OPDS_SERVER_URL: "OPDS сервері URL"
STR_NO_FILES_FOUND: "Файлдар табылмады"
STR_IMAGES: "Суреттер"
+1
View File
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Dalis "
STR_UPLOAD: "Įkelti"
STR_BOOK_S_STYLE: "Knygos stilius"
STR_EMBEDDED_STYLE: "Integruotas stilius"
STR_FOCUS_READING: "Sufokusuotas skaitymas"
STR_OPDS_SERVER_URL: "OPDS URL"
STR_FOOTNOTES: "Išnašos"
STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
+3 -1
View File
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sekcja "
STR_UPLOAD: "Wyślij"
STR_BOOK_S_STYLE: "Styl książki"
STR_EMBEDDED_STYLE: "Style wbudowane w EPUB"
STR_FOCUS_READING: "Czytanie skupione"
STR_OPDS_SERVER_URL: "URL serwera OPDS"
STR_SET_SLEEP_COVER: "Ustaw okładkę"
STR_FOOTNOTES: "Przypisy"
@@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Za mało pamięci na karcie SD"
STR_FILES_LABEL: "Pliki: "
STR_SIZE_LABEL: "Rozmiar: "
STR_REDOWNLOAD: "Re-download"
STR_DOWNLOAD_ALL: "Pobierz / Uaktualnij wszystkie"
STR_DOWNLOAD_ALL: "Pobierz wszystkie"
STR_UPDATE_ALL: "Uaktualnij wszystkie"
STR_ALL_FONTS_INSTALLED: "Wszystkie czcionki zainstalowane!"
STR_UPDATE_AVAILABLE: "Uaktualnij"
STR_CRASH_TITLE: "Awaria systemu"
+1
View File
@@ -264,6 +264,7 @@ STR_SECTION_PREFIX: "Seção"
STR_UPLOAD: "Enviar"
STR_BOOK_S_STYLE: "Estilo do livro"
STR_EMBEDDED_STYLE: "Estilo embutido"
STR_FOCUS_READING: "Leitura focada"
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
STR_SCREENSHOT_BUTTON: "Capturar tela"
STR_TILT_PAGE_TURN: "Virar página por inclinação"
+1
View File
@@ -287,6 +287,7 @@ STR_SECTION_PREFIX: "Secţiune "
STR_UPLOAD: "Încărcare"
STR_BOOK_S_STYLE: "Stilul cărţii"
STR_EMBEDDED_STYLE: "Stil încorporat"
STR_FOCUS_READING: "Lectură concentrată"
STR_OPDS_SERVER_URL: "URL server OPDS"
STR_FOOTNOTES: "Note de subsol"
STR_NO_FOOTNOTES: "Nicio notă de subsol"
+1
View File
@@ -291,6 +291,7 @@ STR_SECTION_PREFIX: "Раздел "
STR_UPLOAD: "Отправить"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Встроенный стиль"
STR_FOCUS_READING: "Фокусное чтение"
STR_OPDS_SERVER_URL: "URL OPDS сервера"
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
STR_AUTO_TURN_ENABLED: "Автоперелистывание: "
+1
View File
@@ -284,6 +284,7 @@ STR_SECTION_PREFIX: "Razdelek "
STR_UPLOAD: "Naloži"
STR_BOOK_S_STYLE: "Slog knjige"
STR_EMBEDDED_STYLE: "Vgrajen slog"
STR_FOCUS_READING: "Fokusirano branje"
STR_OPDS_SERVER_URL: "URL OPDS strežnika"
STR_FOOTNOTES: "Opombe"
STR_NO_FOOTNOTES: "Na tej strani ni opomb"
+1
View File
@@ -288,6 +288,7 @@ STR_SECTION_PREFIX: "Secc.:"
STR_UPLOAD: "Subir"
STR_BOOK_S_STYLE: "Estilo del libro"
STR_EMBEDDED_STYLE: "Estilo integrado"
STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_FOOTNOTES: "Pie de página"
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
+5 -3
View File
@@ -296,6 +296,7 @@ STR_SECTION_PREFIX: "Sektion"
STR_UPLOAD: "Uppladdning"
STR_BOOK_S_STYLE: "Bokstil"
STR_EMBEDDED_STYLE: "Inbäddad stil"
STR_FOCUS_READING: "Fokusläsning"
STR_OPDS_SERVER_URL: "OPDS-serveradress"
STR_SET_SLEEP_COVER: "Ställ in omslag"
STR_FOOTNOTES: "Fotnoter"
@@ -310,8 +311,8 @@ STR_DELETE_CONFIRM: "Vill du ta bort den här servern?"
STR_OPDS_SERVERS: "OPDS-servrar"
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
STR_DOWNLOAD_FONTS: "Ladda ner teckensnitt"
STR_FONT_DOWNLOAD: "Nedladdning av teckensnitt"
STR_MANAGE_FONTS: "Hantera teckensnitt"
STR_FONT_BROWSER: "Teckensnittsbläddrare"
STR_LOADING_FONT_LIST: "Laddar teckensnittslista..."
STR_NO_FONTS_AVAILABLE: "Inga teckensnitt tillgängliga"
STR_FONT_INSTALLED: "Teckensnitt installerat!"
@@ -322,7 +323,8 @@ STR_SD_CARD_FULL: "Otillräckligt utrymme på SD-kortet"
STR_FILES_LABEL: "Filer: "
STR_SIZE_LABEL: "Storlek: "
STR_REDOWNLOAD: "Ladda ner igen"
STR_DOWNLOAD_ALL: "Ladda ner / Uppdatera alla"
STR_DOWNLOAD_ALL: "Ladda ner alla"
STR_UPDATE_ALL: "Uppdatera alla"
STR_ALL_FONTS_INSTALLED: "Alla teckensnitt installerade!"
STR_UPDATE_AVAILABLE: "Uppdatering"
STR_CRASH_TITLE: "Systemkrasch"
+1
View File
@@ -262,6 +262,7 @@ STR_SECTION_PREFIX: "Bölüm "
STR_UPLOAD: "Yükle"
STR_BOOK_S_STYLE: "Kitabın Stili"
STR_EMBEDDED_STYLE: "Gömülü Stil"
STR_FOCUS_READING: "Odaklanmış Okuma"
STR_OPDS_SERVER_URL: "OPDS Sunucu Adresi"
STR_AUTO_TURN_ENABLED: "Otomatik Çevirme Etkin: "
STR_AUTO_TURN_PAGES_PER_MIN: "Otomatik Çevirme (Dakikada Sayfa)"
+36 -1
View File
@@ -87,7 +87,7 @@ STR_TIME_TO_SLEEP: "Перехід в режим сну"
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
STR_REFRESH_FREQ: "Частота оновлення екрану"
STR_KOREADER_SYNC: "Синхронізація KOReader"
STR_CHECK_UPDATES: "Перевірити оновлення"
STR_CHECK_UPDATES: "Перевірити оновлення системи"
STR_LANGUAGE: "Мова"
STR_CLEAR_READING_CACHE: "Очистити кеш книг"
STR_USERNAME: "Ім'я користувача"
@@ -171,6 +171,7 @@ STR_NO_UPDATE: "Оновлень немає"
STR_UPDATE_FAILED: "Оновлення не вдалося"
STR_UPDATE_COMPLETE: "Оновлення завершено"
STR_POWER_ON_HINT: "Натисніть і утримуйте кнопку живлення, щоб увімкнути"
STR_RESTARTING_HINT: "Перезавантаження... Якщо пристрій не перезавантажується, утримуйте кнопку живлення кілька секунд."
STR_NO_ENTRIES: "Записів не знайдено"
STR_DOWNLOADING: "Завантаження..."
STR_DOWNLOAD_FAILED: "Завантаження не вдалося"
@@ -228,6 +229,9 @@ STR_EXAMPLE_BOOK: "Назва книги"
STR_PREVIEW: "Перегляд"
STR_TITLE: "Назва"
STR_BATTERY: "Акумулятор"
STR_XTC_STATUS_BAR: "XTC Рядок прогресу"
STR_BOTTOM: "Низ"
STR_TOP: "Верх"
STR_UI_THEME: "Тема інтерфейсу"
STR_THEME_CLASSIC: "Класична"
STR_THEME_LYRA: "Lyra"
@@ -292,7 +296,9 @@ STR_SECTION_PREFIX: "Розділ "
STR_UPLOAD: "Завантажити"
STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Вбудований стиль"
STR_FOCUS_READING: "Фокусне читання"
STR_OPDS_SERVER_URL: "URL сервера OPDS"
STR_SET_SLEEP_COVER: "Як обкл."
STR_FOOTNOTES: "Примітки"
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
STR_LINK: "[посилання]"
@@ -305,6 +311,22 @@ STR_DELETE_CONFIRM: "Видалити цей сервер?"
STR_OPDS_SERVERS: "Сервери OPDS"
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: "
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
STR_MANAGE_FONTS: "Керування шрифтами"
STR_FONT_BROWSER: "Перегляд шрифтів"
STR_LOADING_FONT_LIST: "Оновлення списку шрифтів..."
STR_NO_FONTS_AVAILABLE: "Шрифти недоступні"
STR_FONT_INSTALLED: "Шрифт встановлено!"
STR_FONT_INSTALL_FAILED: "Не вдалося встановити шрифт"
STR_INSTALLED: "Встановлено"
STR_CONFIRM_DOWNLOAD_PROMPT: "Завантажити?"
STR_SD_CARD_FULL: "Недостатньо місця на SD-карті"
STR_FILES_LABEL: "Файли: "
STR_SIZE_LABEL: "Розмір: "
STR_REDOWNLOAD: "Завантажити повторно"
STR_DOWNLOAD_ALL: "Завантажити все"
STR_UPDATE_ALL: "Оновити все"
STR_ALL_FONTS_INSTALLED: "Всі шрифти встановлено!"
STR_UPDATE_AVAILABLE: "Оновити"
STR_CRASH_TITLE: "Збій Системи"
STR_CRASH_DESCRIPTION: "Дані про збій збережено в crash_report.txt. Додайте цей файл до вашого звіту про помилку."
STR_CRASH_REASON: "Причина збою:"
@@ -325,3 +347,16 @@ STR_KB_HINT_SECONDARY_CHAR: "Затисніть ВИБРАТИ для додат
STR_KB_HINT_UPPER_SECONDARY: "Затисніть ВИБРАТИ для ВЕЛИКИХ літер / символів"
STR_KB_HINT_LOWER_SECONDARY: "Затисніть ВИБРАТИ для малих літер / символів"
STR_KB_HINT_URL_SNIPPETS: "Натисніть URL для вибору шаблонів"
STR_SD_FIRMWARE_UPDATE: "Оновлення системи з SD-карти"
STR_SELECT_FIRMWARE_FILE: "Оберіть файл оновлення (.bin)"
STR_NO_BIN_FILES: "Не знайдено .bin файлів"
STR_VALIDATING_FIRMWARE: "Перевірка цілісності файлу..."
STR_INVALID_FIRMWARE: "Невірний файл оновлення системи"
STR_FIRMWARE_TOO_LARGE: "Файл оновлення системи не вміщується в розділ пам'яті"
STR_FIRMWARE_TOO_SMALL: "Файл оновлення системи занадто малий"
STR_FIRMWARE_UPDATE_PROMPT: "Оновити систему?"
STR_FIRMWARE_FILE_OPEN_FAILED: "Неможливо відкрити файл"
STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!"
STR_RECOVERY_MODE: "Режим відновлення"
STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його"
+12 -6
View File
@@ -1,18 +1,21 @@
import sys
import os
from PIL import Image
import cairosvg
import io
import sys
threshold = 128
USAGE = 'Usage: python scripts/convert_icon.py input.png|input.svg output_name width height'
def svg_to_png_bytes(svg_path, width, height):
import cairosvg
with open(svg_path, 'rb') as f:
svg_data = f.read()
png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=width, output_height=height)
return png_bytes
def load_image(path, width, height):
from PIL import Image
ext = os.path.splitext(path)[1].lower()
if ext == '.svg':
png_bytes = svg_to_png_bytes(path, width, height)
@@ -58,8 +61,11 @@ def image_to_c_array(img, array_name):
return c
def main():
if len(sys.argv) < 5:
print('Usage: python convert_image.py input.png output_name width height')
if any(arg in ('-h', '--help') for arg in sys.argv[1:]):
print(USAGE)
sys.exit(0)
if len(sys.argv) != 5:
print(USAGE)
sys.exit(1)
input_path, output_name, width, height = sys.argv[1:5]
array_name = output_name.capitalize() + 'Icon'
@@ -77,4 +83,4 @@ def main():
print(f'Wrote {output_path}')
if __name__ == '__main__':
main()
main()
+38 -28
View File
@@ -35,6 +35,43 @@ import threading
from collections import deque
from datetime import datetime
DEFAULT_BAUDRATE = 115200
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface"
)
parser.add_argument(
"port",
nargs="?",
default=None,
help="Serial port (leave empty for autodetection)",
)
parser.add_argument(
"--baud",
type=int,
default=DEFAULT_BAUDRATE,
help=f"Baud rate (default: {DEFAULT_BAUDRATE})",
)
parser.add_argument(
"--filter",
type=str,
default="",
help="Only display lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--suppress",
type=str,
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
return parser
if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
build_arg_parser().parse_args()
# Try to import potentially missing packages
PACKAGE_MAPPING: dict[str, str] = {
"serial": "pyserial",
@@ -401,34 +438,7 @@ def main() -> None:
- Screenshot capture capability
- Graceful shutdown on Ctrl-C or window close
"""
parser = argparse.ArgumentParser(
description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface"
)
default_baudrate = 115200
parser.add_argument(
"port",
nargs="?",
default=None,
help="Serial port (leave empty for autodetection)",
)
parser.add_argument(
"--baud",
type=int,
default=default_baudrate,
help=f"Baud rate (default: {default_baudrate})",
)
parser.add_argument(
"--filter",
type=str,
default="",
help="Only display lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--suppress",
type=str,
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
parser = build_arg_parser()
args = parser.parse_args()
port = args.port
if port is None:
+2
View File
@@ -16,6 +16,8 @@ The input directory may be flat (all .cpfont files in one dir) or nested
convention <FamilyName>_<size>.cpfont.
"""
from __future__ import annotations
import argparse
import json
import os
@@ -137,10 +137,15 @@ Also includes:
import io
import os
import sys
import zipfile
import uuid
from datetime import datetime
if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
print(__doc__.strip())
sys.exit(0)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
+3
View File
@@ -326,4 +326,7 @@ def main():
if __name__ == '__main__':
if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
print(__doc__.strip())
sys.exit(0)
main()
+5
View File
@@ -11,9 +11,14 @@ Creates EPUBs with annotated JPEG and PNG images to verify:
"""
import os
import sys
import zipfile
from pathlib import Path
if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
print(__doc__.strip())
sys.exit(0)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
+2
View File
@@ -213,6 +213,8 @@ class CrossPointSettings {
uint8_t fadingFix = 0;
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
uint8_t embeddedStyle = 1;
// Focus Reading - emphasizes the first part of words with bold
uint8_t focusReadingEnabled = 0;
// SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = "";
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
+14 -17
View File
@@ -5,15 +5,10 @@
#include "CrossPointSettings.h"
// Map fontSize enum (SMALL=0, MEDIUM=1, LARGE=2, EXTRA_LARGE=3) to the point
// sizes shipped with the built-in fonts. Used to drive closest-pt selection
// in the SD card font registry (see SdCardFontFamilyInfo::pickClosestSize).
static constexpr uint8_t FONT_SIZE_TO_PT[CrossPointSettings::FONT_SIZE_COUNT] = {12, 14, 16, 18};
static uint8_t targetPtSizeFromSettings() {
static uint8_t fontSizeEnumFromSettings() {
uint8_t e = SETTINGS.fontSize;
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
return FONT_SIZE_TO_PT[e];
return e;
}
void SdCardFontSystem::begin(GfxRenderer& renderer) {
@@ -30,7 +25,7 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
if (SETTINGS.sdFontFamilyName[0] != '\0') {
const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName);
if (family) {
if (manager_.loadFamily(*family, renderer, targetPtSizeFromSettings())) {
if (manager_.loadFamily(*family, renderer, fontSizeEnumFromSettings())) {
LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
@@ -58,7 +53,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
const char* wantedFamily = SETTINGS.sdFontFamilyName;
const std::string& currentFamily = manager_.currentFamilyName();
const uint8_t targetPt = targetPtSizeFromSettings();
const uint8_t sizeEnum = fontSizeEnumFromSettings();
if (wantedFamily[0] == '\0') {
if (!currentFamily.empty()) {
@@ -67,8 +62,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
return;
}
// Reload if family changed OR if the user-selected size now resolves to a
// different on-disk file than what's currently loaded OR if the registry was
// Reload if family changed OR if the user-selected size maps to a
// different file than what's currently loaded OR if the registry was
// just rediscovered (file may have been replaced on disk).
bool familyMatches = (currentFamily == wantedFamily);
if (familyMatches) {
@@ -79,11 +74,13 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
SETTINGS.sdFontFamilyName[0] = '\0';
return;
}
const auto* best = family->pickClosestSize(targetPt);
const uint8_t bestPt = best ? best->pointSize : 0;
if (!registryWasDirty && bestPt == manager_.currentPointSize()) return;
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)%s", wantedFamily, manager_.currentPointSize(), bestPt,
targetPt, registryWasDirty ? " [registry dirty]" : "");
auto sizes = family->availableSizes();
uint8_t idx = sizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
}
if (!currentFamily.empty()) {
@@ -92,7 +89,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
const auto* family = registry_.findFamily(wantedFamily);
if (family) {
if (manager_.loadFamily(*family, renderer, targetPt)) {
if (manager_.loadFamily(*family, renderer, sizeEnum)) {
LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily);
} else {
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
+2
View File
@@ -145,6 +145,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
"paragraphAlignment", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_EMBEDDED_STYLE, &CrossPointSettings::embeddedStyle, "embeddedStyle",
StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_FOCUS_READING, &CrossPointSettings::focusReadingEnabled, "focusReadingEnabled",
StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
StrId::STR_CAT_READER),
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
+4 -4
View File
@@ -609,7 +609,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering)) {
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_DBG("ERS", "Cache not found, building...");
GUI.drawPopup(renderer, tr(STR_INDEXING));
@@ -619,7 +619,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, popupFn)) {
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showPendingSyncSaveError();
@@ -750,7 +750,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering)) {
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
return;
}
@@ -758,7 +758,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering)) {
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
}
}
+16 -4
View File
@@ -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;
}
+118 -17
View File
@@ -11,6 +11,7 @@
#include "MappedInputManager.h"
#include "SdCardFontGlobals.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/ConfirmationActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
@@ -106,6 +107,7 @@ bool FontDownloadActivity::fetchAndParseManifest() {
baseUrl_ = doc["baseUrl"] | "";
families_.clear();
fontInstaller_.refreshRegistry();
JsonArray familiesArr = doc["families"].as<JsonArray>();
families_.reserve(familiesArr.size());
@@ -171,7 +173,7 @@ bool FontDownloadActivity::fetchAndParseManifest() {
void FontDownloadActivity::downloadAll() {
for (size_t i = 0; i < families_.size(); i++) {
if (families_[i].installed && !families_[i].hasUpdate) continue;
if (families_[i].installed) continue;
downloadFamily(families_[i]);
if (state_ == ERROR) return;
}
@@ -182,10 +184,59 @@ void FontDownloadActivity::downloadAll() {
}
}
size_t FontDownloadActivity::totalUninstalledSize() const {
void FontDownloadActivity::updateAll() {
for (size_t i = 0; i < families_.size(); i++) {
if (!families_[i].hasUpdate) continue;
downloadFamily(families_[i]);
if (state_ == ERROR) return;
}
{
RenderLock lock(*this);
state_ = COMPLETE;
}
}
bool FontDownloadActivity::showDownloadAllRow() const {
for (const auto& f : families_) {
if (!f.installed) return true;
}
return false;
}
bool FontDownloadActivity::showUpdateAllRow() const {
for (const auto& f : families_) {
if (f.hasUpdate) return true;
}
return false;
}
int FontDownloadActivity::specialRowCount() const {
return (showDownloadAllRow() ? 1 : 0) + (showUpdateAllRow() ? 1 : 0);
}
bool FontDownloadActivity::isDownloadAllRow(int index) const { return showDownloadAllRow() && index == 0; }
bool FontDownloadActivity::isUpdateAllRow(int index) const {
return showUpdateAllRow() && index == (showDownloadAllRow() ? 1 : 0);
}
int FontDownloadActivity::listItemCount() const {
return families_.empty() ? 0 : static_cast<int>(families_.size()) + specialRowCount();
}
size_t FontDownloadActivity::totalDownloadSize() const {
size_t total = 0;
for (const auto& f : families_) {
if (!f.installed || f.hasUpdate) total += f.totalSize;
if (!f.installed) total += f.totalSize;
}
return total;
}
size_t FontDownloadActivity::totalUpdateSize() const {
size_t total = 0;
for (const auto& f : families_) {
if (f.hasUpdate) total += f.totalSize;
}
return total;
}
@@ -297,6 +348,7 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
fontInstaller_.refreshRegistry();
family.installed = true;
family.hasUpdate = false;
{
RenderLock lock(*this);
@@ -304,6 +356,47 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
}
}
void FontDownloadActivity::promptDeleteSelectedFamily() {
const int pendingDeleteFamilyIndex = familyIndexFromList(selectedIndex_);
if (pendingDeleteFamilyIndex < 0 || pendingDeleteFamilyIndex >= static_cast<int>(families_.size())) {
return;
}
std::string heading = tr(STR_DELETE);
const auto& family = families_[pendingDeleteFamilyIndex];
std::string body = family.name;
startActivityForResult(std::make_unique<ConfirmationActivity>(renderer, mappedInput, heading, body),
[this](const ActivityResult& result) { onDeleteConfirmationResult(result); });
}
void FontDownloadActivity::onDeleteConfirmationResult(const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
auto& family = families_[familyIndexFromList(selectedIndex_)];
if (fontInstaller_.deleteFamily(family.name.c_str()) != FontInstaller::Error::OK) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = "Failed to delete font";
} else {
fontInstaller_.refreshRegistry();
family.installed = false;
family.hasUpdate = false;
}
requestUpdate();
}
bool FontDownloadActivity::isSelectedFamilyDeletable() const {
if (isDownloadAllRow(selectedIndex_) || isUpdateAllRow(selectedIndex_)) return false;
if (selectedIndex_ < specialRowCount() || selectedIndex_ >= listItemCount()) return false;
const auto& family = families_[familyIndexFromList(selectedIndex_)];
return family.installed && !family.hasUpdate;
}
// --- Input handling ---
void FontDownloadActivity::loop() {
@@ -338,12 +431,17 @@ void FontDownloadActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!families_.empty()) {
if (isDownloadAllSelected()) {
if (isDownloadAllRow(selectedIndex_)) {
downloadAll();
} else if (isUpdateAllRow(selectedIndex_)) {
updateAll();
} else {
const auto& family = families_[familyIndexFromList(selectedIndex_)];
auto& family = families_[familyIndexFromList(selectedIndex_)];
if (!family.installed || family.hasUpdate) {
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
downloadFamily(family);
} else {
promptDeleteSelectedFamily();
return;
}
}
requestUpdateAndWait();
@@ -403,7 +501,7 @@ void FontDownloadActivity::render(RenderLock&&) {
renderer.clearScreen();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_DOWNLOAD));
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_BROWSER));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
@@ -422,18 +520,21 @@ void FontDownloadActivity::render(RenderLock&&) {
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
listItemCount(), selectedIndex_,
[this](int index) -> std::string {
if (index == 0) {
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
if (isDownloadAllRow(index)) {
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalDownloadSize()) + ")";
}
if (isUpdateAllRow(index)) {
return std::string(tr(STR_UPDATE_ALL)) + " (" + formatSize(totalUpdateSize()) + ")";
}
return families_[familyIndexFromList(index)].name;
},
[this](int index) -> std::string {
if (index == 0) return "";
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
return families_[familyIndexFromList(index)].description;
},
nullptr,
[this](int index) -> std::string {
if (index == 0) return "";
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
const auto& f = families_[familyIndexFromList(index)];
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
if (f.installed) return tr(STR_INSTALLED);
@@ -441,12 +542,16 @@ void FontDownloadActivity::render(RenderLock&&) {
},
true,
[this](int index) -> bool {
if (index == 0) return false;
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return false;
const auto& f = families_[familyIndexFromList(index)];
return f.installed && !f.hasUpdate;
});
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
const auto labels = mappedInput.mapLabels(tr(STR_BACK),
isSelectedFamilyDeletable() ? tr(STR_DELETE)
: isUpdateAllRow(selectedIndex_) ? tr(STR_UPDATE)
: tr(STR_DOWNLOAD),
tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
} else if (state_ == DOWNLOADING) {
@@ -466,10 +571,6 @@ void FontDownloadActivity::render(RenderLock&&) {
renderer,
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
static_cast<int>(progress * 100), 100);
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
} else if (state_ == COMPLETE) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
+13 -4
View File
@@ -84,10 +84,19 @@ class FontDownloadActivity : public Activity {
bool fetchAndParseManifest();
void downloadFamily(ManifestFamily& family);
void downloadAll();
void updateAll();
static bool computeFileCrc32(const char* path, uint32_t& outCrc);
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
size_t totalUninstalledSize() const;
bool showDownloadAllRow() const;
bool showUpdateAllRow() const;
int specialRowCount() const;
bool isDownloadAllRow(int index) const;
bool isUpdateAllRow(int index) const;
bool isSelectedFamilyDeletable() const;
void promptDeleteSelectedFamily();
void onDeleteConfirmationResult(const ActivityResult& result);
int familyIndexFromList(int listIndex) const { return listIndex - specialRowCount(); }
int listItemCount() const;
size_t totalDownloadSize() const;
size_t totalUpdateSize() const;
static std::string formatSize(size_t bytes);
};
@@ -116,8 +116,8 @@ void OtaUpdateActivity::render(RenderLock&&) {
static_cast<int>(updaterProgress * 100), 100);
y += metrics.progressBarHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, y,
(std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
// Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty
// so the bytes line below stays at the same Y it was at when the activity drew its own percent.
y += height + metrics.verticalSpacing;
renderer.drawCenteredText(
UI_10_FONT_ID, y,
@@ -230,7 +230,8 @@ void SdFirmwareUpdateActivity::render(RenderLock&&) {
Rect{metrics.contentSidePadding, y, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
static_cast<int>(pct), 100);
y += metrics.progressBarHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, y, (std::to_string(pct) + "%").c_str());
// Percent label is drawn by BaseTheme::drawProgressBar; this slot is left intentionally empty
// so the do-not-power-off line below stays at the same Y as before.
y += lineHeight + metrics.verticalSpacing;
renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF));
} else if (state == State::SUCCESS) {
+2 -2
View File
@@ -57,9 +57,9 @@ void SettingsActivity::rebuildSettingsLists() {
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
// Insert "Download Fonts" right after the font family setting so users discover it naturally
// Insert "Manage Fonts" right after the font family setting so users discover it naturally
readerSettings.insert(readerSettings.begin() + 1,
SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
SettingInfo::Action(StrId::STR_MANAGE_FONTS, SettingAction::DownloadFonts));
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
// Update currentSettings pointer and count for the active category
+10 -3
View File
@@ -97,7 +97,12 @@ void BmpViewerActivity::onEnter() {
}
// 4. Prepare Rendering
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", "");
bool hasPrevious = (siblingImages.size() > 1 && currentImageIndex > 0);
bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1);
const auto labels =
mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : ""));
GUI.fillPopupProgress(renderer, popupRect, 50);
@@ -185,7 +190,8 @@ void BmpViewerActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up)) {
if (siblingImages.size() > 1 && currentImageIndex > 0) {
currentImageIndex--;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
@@ -196,7 +202,8 @@ void BmpViewerActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
if (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1) {
currentImageIndex++;
@@ -5,7 +5,6 @@
#include <I18n.h>
#include <algorithm>
#include <cctype>
#include <string>
#include <vector>
@@ -44,18 +43,6 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa
renderer.fillRect(barX, thumbY, barW, thumbH);
}
std::string sanitizeButtonLabel(std::string label) {
// Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs).
while (!label.empty() && !std::isalnum(static_cast<unsigned char>(label[0]))) {
label.erase(0, 1);
}
// Trim any extra left spaces.
while (!label.empty() && label[0] == ' ') {
label.erase(0, 1);
}
return label;
}
} // namespace
int coverWidth = 0;
@@ -412,11 +399,11 @@ void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1,
const bool backDisabled = (btn1 == nullptr || btn1[0] == '\0');
const int leftGroupX = sidePadding;
const int rightGroupX = leftGroupX + groupWidth + groupGap;
const std::string backLabel = backDisabled ? "" : sanitizeButtonLabel(std::string(btn1));
const std::string backLabel = backDisabled ? "" : std::string(btn1);
// Callers should provide the button labels. If a label is not specified, it should render empty.
const std::string selectText = (btn2 && btn2[0] != '\0') ? sanitizeButtonLabel(std::string(btn2)) : "";
const std::string upText = (btn3 && btn3[0] != '\0') ? sanitizeButtonLabel(std::string(btn3)) : "";
const std::string downText = (btn4 && btn4[0] != '\0') ? sanitizeButtonLabel(std::string(btn4)) : "";
const std::string selectText = (btn2 && btn2[0] != '\0') ? std::string(btn2) : "";
const std::string upText = (btn3 && btn3[0] != '\0') ? std::string(btn3) : "";
const std::string downText = (btn4 && btn4[0] != '\0') ? std::string(btn4) : "";
// Ensure button hints always "win" visually even if other elements accidentally render into this area.
renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, false);
@@ -16,7 +16,6 @@ Requirements:
import argparse
import re
from collections import Counter
import pyphen
from pathlib import Path
import zipfile
@@ -75,6 +74,8 @@ def generate_hyphenation_data(
min_prefix: Minimum characters allowed before the first hyphen (default: 2)
min_suffix: Minimum characters allowed after the last hyphen (default: 2)
"""
import pyphen
print(f"Reading from: {input_file}")
# Read the input file