Compare 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
Zach Nelson fcb8793640 Merge branch 'master' into release/1.3.0 2026-05-10 11:07:12 -05:00
th0m4sek aba393f900 fix: Polish translation (#1909)
## Summary

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

## 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? _**< YES | PARTIALLY | NO
>**_
2026-05-10 11:02:09 -05:00
jpirnay 45441e0789 feat: closest-pt size selection instead of ordinal slot (#1912)
Add SdCardFontFamilyInfo::pickClosestSize(targetPtSize) and have the
manager and SdCardFontSystem drive size selection from a target point
size derived from the user's font-size enum (SMALL=12, MEDIUM=14,
LARGE=16, EXTRA_LARGE=18) rather than indexing the family's sorted size
list by enum ordinal.

The ordinal-slot approach mis-selects whenever a family doesn't ship the
canonical {12,14,16,18} set: a family with only [10,14,18] would map
SMALL/MEDIUM/LARGE/EXTRA_LARGE to 10/14/18/18 — fine for SMALL but
arbitrary for the rest. Closest-pt always picks the on-disk file nearest
to the user-intended point size, with a deterministic smaller-pt
tie-break.

No change for canonical-sized families.

## Summary

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

## 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? _**< YES | PARTIALLY | NO
>**_
2026-05-10 11:01:24 -05:00
Julia cff54d776c feat: increase default weight of Bitter font for improved rendering (#1922)
## Summary

**What is the goal of this PR?**
* Provide a better default weight of Bitter font for improved rendering

**What changes are included?**
* Changes the default weight for Bitter font that is defined in the
custom font catalog to medium (500) from regular (400) for improved
rendering on e-ink, especially when font anti-aliasing is turned on

## Additional Context

* Bitter font at regular weight can become washed out when AA is on due
to some of the thinner stems and posts of the font. Bumping the weight
up to medium makes it sturdier and holds up better when AA is turned 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-10 10:56:30 -05:00
jpirnay 5917c561b3 fix: build-script bug fixes for fontconvert{,_sdcard}.py (#1910)
Fontconvert_sdcard.py:

* Move face.set_char_size() before any load_glyph() call. The validation
loop runs load_glyph() with FT_LOAD_RENDER, which renders at the
*active* size — calling it before set_char_size() wastes work at the
default size and triggers Invalid_Size_Handle on some fonts.

* Walk bitmap.buffer using bitmap.pitch with negative-pitch handling.
The previous linear iteration assumed pitch == width and a top-down
layout, which silently corrupts output for any padded or bottom-up
bitmap FreeType returns.

* Fix operator-precedence bug in 2-bit tail-padding: px << (4 - … % 4) *
2 evaluated as (px << (4 - … % 4)) * 2 due to << binding tighter than *.
Add the missing outer parens.

* Drop SMP codepoints (> U+FFFF) from kern and ligature tables before
packing — the binary format uses uint16/uint32 codepoint fields and
would raise struct.error otherwise.

* Skip GPOS kern subtables that aren't lookup type 2 (PairPos).
_extract_pairpos_subtable assumes Type-2 layout; cursive attachment and
other types reachable through the kern feature crash inside it.

Fontconvert.py:

* Force UTF-8 stdout so `python fontconvert.py … > foo.h` on Windows
doesn't emit UTF-16 / replacement characters in the generated header.

## Summary

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

## 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? _**< YES | PARTIALLY | NO
>**_
2026-05-10 10:54:04 -05:00
jpirnay 5d5533b3d2 fix: capture instantiateVariableFont return value (#1911)
instantiateVariableFont() returns a *new* TTFont with the requested axes
pinned; the existing call discards that return and proceeds to
font.save() the original (still-variable) font. The "static instance"
written to disk is therefore identical to the source variable font and
the requested axis values are silently ignored.

For sd-fonts.yaml entries that use the `variable: {wght: 400}` form
(e.g. Bitter, Lora) this means every weight ends up rasterised from the
same default-weight glyphs — bold and regular look the same.

Capture the return value, and pass two diagnostically useful kwargs
while we're touching the call:

* updateFontNames=True — rewrite the name table so the saved TTF reports
its actual weight/style instead of retaining the source variable-font
names.
* optimize=False        — skip the gvar interpolation optimisation;
                          fully pinning every axis drops gvar anyway,
                          so the work would be wasted.

The atomic-write scaffolding around the call is left untouched.

## Summary

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

## 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? _**< YES | PARTIALLY | NO
>**_
2026-05-10 10:07:08 -05:00
jpirnay c5d2dc2e00 feat: cap compressed group size at 64 KB (#1913)
Add GROUP_MAX_UNCOMPRESSED_BYTES=65536 on top of the existing script-
based grouping. When adding the next glyph would push a group past the
cap, the group is closed and a new one started with the same script ID.

Without the cap, the size of a script group is bounded only by however
many glyphs the font supplies in that block. Dense scripts (CJK,
Vietnamese precomposed, user-supplied fonts with large Unicode blocks)
can produce a single group whose uncompressed size exceeds what fits in
the embedded decompressor's transient malloc on the ESP32-C3, which
manifests as a runtime allocation failure instead of a build error.

The 64 KB ceiling is large enough to hold any single built-in script
group with headroom and small enough to be a comfortable transient
allocation on-device.

The check uses byte-aligned size (4-pixel-aligned row stride × height),
which is what the decompressor actually consumes — not the packed
on-disk length. A defensive guard rejects any single glyph whose own
size exceeds the cap with a clear error pointing at the offending
codepoint.

## Summary

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

## 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? _**< YES | PARTIALLY | NO
>**_
2026-05-10 10:04:59 -05:00
Uri Tauber 91de6ac278 fix: two roundedraff bugs (#1851) 2026-05-10 07:35:16 +03:00
Stefan Blixten Karlsson c7ad14eb36 fix: swedish translation (#1888)
## Summary

* Add missing swedish translations

---

### 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-09 22:00:45 -05:00
Stefan Blixten Karlsson d3e0aeb62b feat: allow unnamed intervals (#1903)
## Summary

* Previous `fontconvert_sdcard.py` requires that the intervals are named
(i.e. listed in `INTERVAL_PRESETS`, ex. "latin1"), this change will also
allow them to be unnamed (ex. "(0x2100-0x214F)") for a more flexible
usage.


---

### 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-09 21:52:13 -05:00
WuTofu 3efc863038 feat: verify CRC32 checksum for font files (#1904)
## Summary

* **What is the goal of this PR?**  
Add end-to-end integrity verification for downloaded font files by
including CRC32 checksums in the font manifest and validating downloaded
`.cpfont` files on device.

* **What changes are included?**  
- `generate-font-manifest.py`: compute and include `crc32` for each
`.cpfont` asset in the generated `fonts.json` manifest.
- `FontDownloadActivity.h`: extend manifest file metadata with `crc32`
and declare checksum helper.
- `FontDownloadActivity.cpp`: parse `crc32` from manifest, compute CRC32
of downloaded files using `esp_rom_crc32_le`.

---

### 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-09 21:46:59 -05:00
Zach Nelson 6c97061629 Merge branch 'master' into release/1.3.0 2026-05-09 21:19:15 -05:00
pablohc bf30964982 fix: overlap in download font list layout (#1900)
## Summary

Fix font download list layout overlap in Classic and RoundedRaff themes.
The font description was shown as a right-aligned value, causing text to
overlap with the font name on narrow screens.

### Changes
- Move font description from `rowValue` to `rowSubtitle` (second line)
- Show only status ("Installed"/"Update Available") as `rowValue`
- Fix Classic and RoundedRaff themes `rowValue` truncation (was fixed
60px, now dynamic)

## Screenshots

### Classic
| RC 1.3.0 | #1900 |
|-|-|
|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/534434d6-d7a6-4f73-9d80-8fe5d060907c"
/>|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/e616c4b7-43b9-49a8-8aa6-ab19f29865f1"
/>|

### Lyra
| RC 1.3.0 | #1900 |
|-|-|
|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/5b42bad4-2860-4863-a025-5292aa391c1a"
/>|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/4c27dcbb-8e8e-4ef0-8771-f92975210f11"
/>|

### RoundedRaff
| RC 1.3.0 | #1900 |
|-|-|
|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/f0510c6f-7712-4cec-8fc8-4386951b1795"
/>|<img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/017596b8-fe95-4541-884b-5de034c24193"
/>|

---

### 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-09 22:11:13 +02:00
zgredex ceb3fed392 fix: remove duplicate 'Download Fonts' menu entry and improve navigation (#1893)
## Summary
Removes duplicate "Download Fonts" menu entry and adds complete
navigation support to the font download activity.

## Problem
"Download Fonts" was appearing in **both** Reader settings and System
settings, creating a confusing duplicate menu entry.

## Changes

### 1. Remove Duplicate Menu Entry
- Removed `STR_DOWNLOAD_FONTS` from `systemSettings` in
`SettingsActivity.cpp`
- "Download Fonts" now only appears in **Reader settings**, positioned
right after the font family setting
- Rationale: Font settings logically belong together in the Reader
category

### 2. Navigation Improvements
- **Single-item navigation**: Replaced manual bounds checking with
`ButtonNavigator::nextIndex()` and `previousIndex()` methods
  - Navigation now wraps from last item to first (and vice versa)
  
- **Page navigation**: Added continuous navigation handlers
  - Long-pressing up/down buttons now navigates by page
  - Uses `UITheme::getNumberOfItemsPerPage()` for consistent behavior

## Files Changed
- `src/activities/settings/SettingsActivity.cpp`: Removed duplicate
entry
- `src/activities/settings/FontDownloadActivity.cpp`: Navigation
improvements

## Testing
- Build:  Compiled successfully
- Device testing: Recommended before merge
2026-05-09 18:51:36 +02:00
Stefan Blixten Karlsson f03d3a8056 fix: missing requirement (#1896)
## Summary

* Fix missing requirement (needed by
lib/EpdFont/scripts/build-sd-fonts.py)
* Add a root requirements.txt to simplify installation

---

### 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-09 11:37:09 -05:00
Wylan SwetsandClaude Sonnet 4.6 bf894fd343 fix: improve KOSync bidirectional position matching accuracy (#1897)
## Summary

**Goal:** Fix bidirectional KOSync position matching between CrossPoint
and KOReader so that syncing in either direction lands on the correct
page with character-level accuracy.

**Changes included:**

**Download — `toCrossPoint` (server XPath → CrossPoint page)**
- **XPath ancestry mode for structured elements**: The previous
`ParagraphStreamer` only tracked `<p>` elements. Replaced with a full
ancestor-walking mode that correctly resolves XPaths pointing into
`<li>`, `<ul>`, and other structured elements. Char offset within the
target element is bounded to the matched element's content only.
- **Slash-in-attribute-value corrupts depth tracking**:
`processByteInTag()` treated every `/` byte as a self-closing tag
marker, including `/` inside quoted attribute values (e.g.
`xmlns="http://..."`, `src="Links/image.jpg"`). This drove `htmlDepth`
to 0 prematurely, causing the ancestry search to exit far short of the
target paragraph. Fixed with `inAttrQuote` tracking.
- **Off-by-one in page formula**: `intra * totalPages` rounds up
incorrectly for last-page positions. Changed to `intra * (totalPages -
1)` to map the `[0, 1]` intra fraction correctly onto the `[0,
totalPages-1]` page range. Example: page 14 of 17 was returned as 15.

**Upload — `toKOReader` (CrossPoint page → server XPath)**
- **Off-by-one in page-to-intra formula**: Symmetric fix — `pageNumber /
totalPages` changed to `pageNumber / (totalPages - 1)`, with the guard
updated from `> 0` to `> 1` to avoid division by zero.
- **`<li>`-based XPath generation**: When the current page starts on a
list item, `findXPathForProgress` now generates `ul[N]/li[M]` XPaths
rather than falling back to the preceding `<p>`. Requires the new
`listItemIndex` field in `PageLutEntry` (section cache version bumped to
23).
- **Text-node precision with correct `text()[N].M` format**: KOReader
expects `text()[N].M` where `N` is the 1-based index of the specific
text node within the element. The previous attempt generated `text().M`
(no brackets), which caused KOReader to jump to the front of the book.
Implements a per-element text-node index stack in
`XPathProgressResolver` — parallel to the existing element path stack —
that correctly tracks text node indices relative to each element. Empty
text nodes from bare anchor elements (`<a id="anchor"/>`) are
intentionally skipped, matching KOReader's own text node counting
behavior.

**Reviewer-caught bugs**
- **Double `onCloseTag()` on malformed `</br/>`**: Both the `tagIsClose`
path and the self-closing `/` check were firing, double-decrementing
`htmlDepth`. Fixed with a `!tagIsClose` guard.
- **Dangling pointer in `LOG_DBG`**:
`std::to_string(*nextParagraphPage).c_str()` passed a pointer to a
temporary destroyed before the variadic call. Fixed with `snprintf` into
a stack `char[8]` buffer.

## Additional Context

- Section cache version bumped from 22 → 23 due to the new
`listItemIndex` field in `PageLutEntry`. Users upgrading will see a
one-time re-render of all cached sections on first load — no data loss.
- The `textNodeIndexStack` in `XPathProgressResolver` is a
`std::vector<int>` that mirrors the existing `path` and `parentStates`
stacks — same depth, same lifetime. No additional heap pressure beyond
what was already present.
- All fixes verified on device with *Gentle and Lowly* by Dane C.
Ortlund (spine 21, 17 pages). Download syncs land on the correct page;
upload syncs land at the correct paragraph with character-level offset.

## Test plan

- [ ] Download: sync from KOReader → CrossPoint lands on correct page
for `text()[N].M` XPaths
- [ ] Download: ancestry correctly resolves `<li>` positions inbound
from KOReader
- [ ] Upload: sync from CrossPoint → KOReader lands within one page for
mid-paragraph positions
- [ ] Upload: sync from CrossPoint → KOReader correctly targets `<li>`
elements when page starts on a list item
- [ ] Upload: `text()[N].M` format XPaths do not cause KOReader to jump
to front of book
- [ ] Section cache version 23: delete `.crosspoint/` and verify clean
re-parse with no crashes

---

### AI Usage

Did you use AI tools to help write this code? **YES** — developed with
Claude Code (Anthropic).

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:22:24 -04:00
Zach Nelson 628794f8b8 fix: Add common ligatures to SD font conversion ranges 2026-05-09 11:09:58 -05:00
Zach Nelson 9b2d8388ef Merge branch 'master' into release/1.3.0 2026-05-08 22:08:28 -05:00
Wylan SwetsandClaude Sonnet 4.6 e64155ed63 fix: free Epub RAM and simplify KOSync navigation via ActivityManager (#1860)
## Summary

* **What is the goal of this PR?** Fix KOSync failing with "Network
error" on large/complex EPUBs, and simplify the sync navigation flow by
removing the callback/result pattern.

* **What changes are included?**

This PR combines the approaches from #1855 and #1760 into a single,
cleaner solution:

  **Memory fix (from #1855):**
- `EpubReaderActivity` pre-computes the local KOReader position and
chapter name, then explicitly releases `epub` and `section` before
launching `KOReaderSyncActivity`. This frees ~65KB measured on device,
giving the TLS handshake sufficient heap. The root cause was
`MBEDTLS_ERR_X509_ALLOC_FAILED` (-0x2880) when a 3-cert chain consumed
~48KB during the handshake with only ~50KB available.
- `KOReaderSyncActivity` no longer receives a `shared_ptr<Epub>` at
construction — it lazy-loads the Epub after TLS only if remote progress
is found (`ensureEpubLoaded()`).
- Added `MIN_HEAP_FOR_TLS = 55000` guard in `KOReaderSyncClient` —
returns `LOW_MEMORY` early if aggregate free heap is too low before
attempting a TLS connection.

  **Navigation simplification (from #1760):**
- Replaced `startActivityForResult` + callback with
`activityManager.replaceActivity` / `activityManager.goToReader`.
Progress is saved to `progress.bin` before the epub is released
(cancel/upload paths) and in `saveProgressAndReturn` (apply remote
path). The reader re-launches from the saved position naturally via
`goToReader`, eliminating the need to reload the epub in a callback.
- Extracted `ReaderUtils::saveProgress()` as a shared helper used by
both `EpubReaderActivity` and `KOReaderSyncActivity`.
- Added `STR_SAVE_PROGRESS_FAILED` to all 22 language files for the case
where writing the synced position to SD fails.

  **Orientation fix (found during device testing):**
- `EpubReaderActivity::onExit()` resets the renderer to portrait before
destruction. With `replaceActivity` the reader is fully torn down before
KOSync starts, so KOSync was always rendering in portrait even when
reading in landscape. Fixed by calling `ReaderUtils::applyOrientation`
in `KOReaderSyncActivity::onEnter()`.

## Additional Context

Heap measurements on device (large EPUB with complex CSS):

| Metric | Before | After |
|---|---|---|
| Heap before Epub release | 88,156 bytes | — |
| Heap after Epub release | — | 153,892 bytes (+65,736) |
| Heap at TLS handshake | ~50,000 bytes (fails) | ~116,384 bytes
(passes) |
| Min-free-ever during sync session | 2,600 bytes | 33,052 bytes |
| TLS result | `MBEDTLS_ERR_X509_ALLOC_FAILED` | HTTP 200 |

Tested on device: sync from inside a large EPUB in both portrait and
landscape, cancel, apply remote progress, upload local progress.

---

### AI Usage

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:01:31 -05:00
Zach Nelson bb3a44f584 Merge branch 'master' into release/1.3.0 2026-05-08 21:57:15 -05:00
7993b2bb97 feat: add SD card font support with on-device download and web management
Add a complete SD card font subsystem that enables users to install and
use custom fonts beyond the three built-in families. This combines the
back-end firmware support (#1327) with the font configuration, build
pipeline, CI distribution, and user-facing management UI (#1392).

Core font system:
- Custom .cpfont binary format (v4) with multi-style support (regular,
  bold, italic, bold-italic) packed into a single file per size
- On-demand glyph loading from SD card with two-pass prewarm rendering
  to bulk-read glyphs per page, achieving near-flash performance for
  Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower)
- Persistent advance cache for layout measurement without SD I/O
- Overflow ring buffer for glyph cache misses during rendering
- Memory-conscious design: only advance tables kept in RAM; glyph
  bitmaps, kern tables, and ligatures loaded on demand from SD

Font management:
- On-device WiFi download from GitHub Releases with manifest-based
  discovery, install/update detection, and progress UI
- Web interface font upload, listing, and deletion via /fonts page
- Manual SD card copy to /fonts/ or /.fonts/ directories
- Font selection integrated into Settings > Reader > Font Family

Build pipeline:
- Declarative YAML config (sd-fonts.yaml) as single source of truth
  for the 17-family font library (serif, sans, mono, accessibility)
- Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with
  FreeType rasterization, class-based kerning, and ligature extraction
- Parallel build orchestrator with variable font instance extraction
- CI workflow publishing versioned + stable releases to a dedicated
  crosspoint-fonts repository with auto-incrementing revision tags
- Centralized version constants (cpfont_version.py) shared across
  build tooling and CI, with firmware headers as manual sync points

Additional fixes:
- CJK characters no longer get hyphens inserted at line breaks
- Advance table eliminates 30+ second stalls during CJK section
  indexing for paragraphs with >512 unique codepoints

Closes #930

Co-authored-by: Zach Nelson <zach@zdnelson.com>
Co-authored-by: Justin <itsthisjustin@users.noreply.github.com>
Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: mcrosson <kemonine@kemonine.info>
2026-05-08 21:50:06 -05:00
Chun Ming LeeandZach Nelson 29fd29f537 feat: Status bar for XTC files (#1849)
## Summary
Add ability to show a status bar for 1-bit XTC files  (closes #1848) 

Overlays a status bar (similar style to that for epubs) over the image.
Defaults to hidden, users can set to show and either top or bottom of
the screen within "Customize Status Bar" reader settings.

I've leaned toward a simple overlay approach rather than trying anything
clever e.g. resizing the original image or allowing for shifting the
image around. I think it's more straight forward for the user to create
a buffer zone when generating the XTC files.

For 2-bit image files, it'd require more work to handle the multiple
render passes so I'm holding off on that.

Sample from a Japanese text XTC
<img width="400" height="600" alt="image"
src="https://github.com/user-attachments/assets/03ac67cf-acec-4b49-969f-73e3656760ce"
/>

### 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 | PARTIALLY | NO
>**_
YES - Codex

---------

Co-authored-by: Zach Nelson <zach@zdnelson.com>
2026-05-08 14:34:42 -05:00
pablohc b463966045 Revert "feat(sleep-screen): add sleep screen orientation setting" (#1877)
Reverts crosspoint-reader/crosspoint-reader#1748

The images in landscape mode are borked.

Cover:
<img width="696" height="1004" alt="image"
src="https://github.com/user-attachments/assets/354950f3-d3db-4c9d-a771-60f0868666d2"
/>

Sleep Screen:
<img width="688" height="1044" alt="image"
src="https://github.com/user-attachments/assets/3d675b55-93c1-4b5f-9e98-10e18e54c53e"
/>
2026-05-08 12:31:02 -05:00
Zach Nelson a48ad3cd61 chore: Updated docs to reflect DESTRUCTOR_CLOSES_FILE=1 (#1878)
## Summary

Follow-up to 23aad213 which removed redundant FsFile close() calls from
the codebase. This updates CLAUDE.md so AI agents and contributors stop
reintroducing them. Adds the build flag to the Critical Build Flags
section with a clear "do not add file.close() on local FsFile variables"
rule, enumerates the three cases where explicit close is still required
(close before Storage.remove, close before reopening the same variable,
and member variables that outlive function scope), and updates the
HalStorage example, RAII guidance, and Activity Lifecycle sections to be
consistent with the new policy.

---

### 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-08 12:29:21 -05:00
xiro-codesandTravis Davis e3fb3bba37 feat(sleep-screen): add sleep screen orientation setting (#1748)
Co-authored-by: Travis Davis <me@tdavis.dev>
2026-05-08 11:41:06 +03:00
Arthur TazhitdinovandCopilot e8d7153d7f feat: edit wifi networks in webui (#1743)
## Summary

**What is the goal of this PR?**
Add Wi-Fi network management to the Web UI settings page, similar to
existing OPDS server management, so users can view, add, edit, and
delete saved Wi-Fi credentials from the browser. Closes
https://github.com/crosspoint-reader/crosspoint-reader/issues/1544 and
https://github.com/crosspoint-reader/crosspoint-reader/discussions/607

**What changes are included?**

- Added Wi-Fi API endpoints:
- GET /api/wifi Listing saved networks (without exposing plaintext
passwords)
  - POST /api/wifi Creating/updating networks
  - POST /api/wifi/delete Deleting networks by index

- Added a new Wi-Fi Networks management section web UI settings page

<img width="921" height="712" alt="image"
src="https://github.com/user-attachments/assets/42c20a63-f335-4389-8246-d38065d6b65a"
/>


---

### AI Usage

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

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

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-07 21:29:48 -05:00
WuTofu 1e2f6e2d67 feat: enhance long press action to delete both files and directories (#1803)
## Summary

* **What is the goal of this PR?** 
Enhance file browser long-press behavior so the delete action now works
for both files and directories.

* **What changes are included?**
Updated `FileBrowserActivity.cpp` to support for directory deletion on
long-press

---

### 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-07 17:32:30 -05:00
Zach Nelson e841c84194 refactor: Deduplicate Roundraff battery drawing (#1847)
## Summary

Deduplicated battery drawing code for Roundraff theme with other themes.

Now `BaseTheme` provides non-virtual `drawBatteryLeft` and
`drawBatteryRight` methods, which use a shared static
`drawBatteryOutline` implementation, and defer to a virtual
`fillBatteryIcon` implementation. In Classic and Roundraff
`fillBatteryIcon` uses a solid fill, while Lyra and Lyra Extended use a
segmented fill.

The charging indicator inside the battery was missing for Roundraff, but
is now consistent with other themes because of the shared drawing
implementation.

---

### 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-07 16:57:33 -05:00
Zach Nelson 83f0cee565 fix: Roundraff theme home menu offset with no recent books (#1845)
## Summary

With the Roundraff theme selected and no recent books, the home screen
menu shows a "Continue Reading" option and menu handling is offset by
one ("Continue Reading" actually does "Browse Files", "File Transfer"
actually does "Settings", etc.) Simple fix here is to omit the "Continue
Reading" menu item when there are no recent books.

---

### 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-07 08:17:51 -05:00
pablohc c15b5b9bc5 fix: short-press power action triggered after screenshot combo release (#1853)
When Power+Down is released with a slight stagger (Down before Power),
the Power release event leaked through to the activity loop, triggering
short-press Power actions like clip selection. Add a
screenshotComboActive flag that suppresses all input processing until
Power is fully released after a screenshot combo.

## Summary

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

## 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? _**YES**_
2026-05-07 08:10:17 -04:00
Uri Tauber dadce519c4 fix: display empty lines in txt reader (#1841) 2026-05-07 09:07:57 +03:00
Zach Nelson 11bc36ebb5 refactor: Use fixed-size integers for BookMetadataCache data (#1844) 2026-05-07 09:03:51 +03:00
Zach Nelson 395e68ea2b refactor: Simplify isReaderActivity bookkeeping (#1838)
## Summary

Before, any sub-activity of a reader activity needed to override
`isReaderActivity` to maintain correct bookkeeping through
`ActivityManager::isReaderActivity`. We could easily miss this in any
new sub-activities. Instead, simplify so each reader activity correctly
reports and then `ActivityManager` checks for any reader activity in its
stack.

---

### 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-06 09:57:22 -05:00
Zach Nelson cfe3a948a0 refactor: Simplify XtcReaderActivity with detectPageTurn (#1837)
## Summary

Simplify duplicated code in `XtcReaderActivity` to use
`ReaderUtils::detectPageTurn`. This implementation is now shared with
`EpubReaderActivity` and `TxtReaderActivity`. Also deduplicated the
chapter skip time constant.

---

### 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-06 09:57:06 -05:00
Zach Nelson 4ea938b709 chore: Update SDK to fork in CrossPoint org (#1836)
## Summary

Update the Open X4 SDK to point to the CrossPoint organization fork.
This allows us to take SDK changes without being blocked on the upstream
repository.

---

### 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-06 09:56:35 -05:00
WuTofu 939014d996 fix: incorrect y-axis scale factor in jpeg nearest-neighbor downscaler (#1807)
## Summary

* **What is the goal of this PR?**
Fixes a bug in the jpeg nearest-neighbor downscaler where source image
rows containing visible content may be cropped.

* **What changes are included?**
Split the single `fineScaleFP`/`invScaleFP` scale pair in
`JpegToFramebufferConverter.cpp` into separate X
(`fineScaleFPX`/`invScaleFPX`) and Y (`fineScaleFPY`/`invScaleFPY`)
pairs

## Additional Context
The one case that I encountered in my epub:
<img width="480" height="37" alt="img_6_0"
src="https://github.com/user-attachments/assets/0de3778c-cf3d-42dc-a1ba-4ba729f6b1c4"
/>

The image generated from pxc cache with the latest commit:
<img width="464" height="36" alt="img_6_0_master pxc"
src="https://github.com/user-attachments/assets/92ba06e5-be15-4db6-840e-882bdddb1baf"
/>

With this fix, the bottom outline of each character is still there:
<img width="464" height="36" alt="img_6_0_fixed pxc"
src="https://github.com/user-attachments/assets/4246ce55-5a20-480f-862f-7988f804c1fb"
/>

---

### 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-06 08:57:37 -05:00
Pietro CampagnanoandClaude Sonnet 4.6 3e7d63dab9 style: align action buttons vertically with page title (#1795)
## Summary

* **What is the goal of this PR?** Fix the vertical misalignment between
the "File Manager" title and the action buttons (Upload / New Folder /
Delete Selected).
* **What changes are included?**
- `h2`: `margin-top: 0` → `margin: 0` — removes the default browser
`margin-bottom` that was inflating the height of the header-left flex
container, pushing the calculated flex center below the visual midpoint
of the title text
- `.page-header-left`: `align-items: baseline` → `align-items: center` —
ensures the title and breadcrumbs are center-aligned with each other and
with the buttons

### Before
<img width="1198" height="560" alt="Screenshot From 2026-04-30 22-29-28"
src="https://github.com/user-attachments/assets/b9a43069-cd93-4028-ab21-75be253ed1f0"
/>

### After
<img width="1198" height="560" alt="Screenshot From 2026-04-30 22-48-49"
src="https://github.com/user-attachments/assets/b9977c65-e479-4490-8ec3-15ae99cfccd7"
/>


## Additional Context

Small CSS-only change, no JavaScript or HTML structure touched.

---

### AI Usage

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 08:48:31 -05:00
Eliz efa4f71a68 feat: Set sleep cover from BMP viewer (#1104) 2026-05-05 12:23:20 +03:00
Stefan Blixten Karlsson f44722a0f2 fix: swedish translation (#1829) 2026-05-05 12:14:48 +03:00
KymAndriyandKymAndriy 40af42683e refactor: change ukrainian translation to adaptation and add missing lines (#1828)
Co-authored-by: KymAndriy <mail@gmail.com>
2026-05-05 12:13:59 +03:00
Zach Nelson 78625afe76 chore: Added RAM to firmware_size_history.py script (#1830)
## Summary

Added RAM delta output to firmware_size_history.py output, e.g.:

```
 % scripts/firmware_size_history.py --commits adcd796 8e18472
[info] Will restore to 'sd-card-fonts' when finished.
[info] Stashing uncommitted changes...
[info] Building 2 commits...

[1/2] adcd7961c9 feat: self-heal from transient WiFi loss, add dBm indicator during WebServerActivity  (#1780)
  Building (env: default)...
  Flash: 5,751,209, RAM: 97,996 bytes

[2/2] 8e184724d1 Merge branch 'master' into sd-card-fonts
  Building (env: default)...
  Flash: 5,762,395, RAM: 97,988 bytes

[info] Restoring 'sd-card-fonts'...
[info] Restoring stashed changes...

Commit            Flash    Delta          RAM    Delta  Title
──────────  ───────────  ───────  ───────────  ───────  ────────────────────────────────────────
adcd7961c9    5,751,209                97,996           feat: self-heal from transient WiFi loss, add dBm indicator during WebServerActivity  (#1780)
8e184724d1    5,762,395  +11,186       97,988       -8  Merge branch 'master' into sd-card-fonts
```

---

### 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-04 22:11:40 -05:00
Zach Nelson bd546093cb chore: Bump version to 1.3.0 2026-05-04 21:20:17 -05:00
Jeremy Klein adcd7961c9 feat: self-heal from transient WiFi loss, add dBm indicator during WebServerActivity (#1780)
## Summary
* **What is the goal of this PR?**

I was seeing hangs during File Transfer. Troubleshooting via serial
showed it had to do with the SHUTTING_DOWN state. Strengthened the WiFi
State Machine a bit and added self-healing. Additionally, I found it
obnoxious to need to keep referring to serial for my wifi strength, so a
dBm meter is added opposing the SSID when on the File Transfer page.

* **What changes are included?**

The dBm meter was at risk of causing rapid screen updates (if, say, we
hovered right around a threshold), so I've implemented a basic
hysteresis around this. RISING/FALLING would be more canonical variable
names, but are reserved in the framework.

## Additional Context

My X4 has a terrible antenna, apparently, and I was running into this
failure condition pretty regularly. Wifi "bars" were chosen as a
relatively pan-cultural glyph rather than relying on localization.

Tested on hardware at -83 dBm under sustained EPUB upload (60 books, ~30
MB). Transient losses up to ~14s now ride through; pre-fix the same
losses required a power-cycle after as little as a 2s blip.

---

### 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? Nope. Commits are hand
written. Claude was used to build a local test harness for validation
only during iteration.
2026-05-04 21:19:00 -05:00
eunchurn 5717374e4b feat(update): SD-card firmware update + X3 bootloader compatibility (#1786) 2026-05-04 19:51:20 -04:00
sizezero b8a6b58b5e docs: expand first use of OPDS acronym and provide a wikipedia link (#1824)
No description needed beyond title.
2026-05-04 15:57:02 -05:00
Zach Nelson 333286acfc refactor: Use std::size instead of sizeof/sizeof (#1819)
## Summary

Simplify code calculating compile-time array sizes with
`sizeof(array)/sizeof(element)` to use `std::size`.

---

### 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-04 12:41:27 -05:00
Dave AllieandZach Nelson e026bcb9dc fix: Track block style stack for nested styles (#1582)
## Summary

* Add new block style stack to track accumulated styles
* When dropping into nested block, apply new styles on last BlockStyle
in the stack and push to the back
  * When leaving block, pop the stack
* Fix issue where the image % width always went off screen width, use
current container width based on accumulated styles
* Bump section cache version to regenerate pages

## Additional Context

Fixes https://github.com/crosspoint-reader/crosspoint-reader/pull/1581
more correctly, and includes fix from
https://github.com/crosspoint-reader/crosspoint-reader/pull/1580

  Consider:
```html
<div class="c1">          <!-- marginLeft: 10px -->
  <div class="c2">        <!-- marginLeft: 20px -->
    <p class="c3">text</p>  <!-- marginLeft: 5px -->
  </div>
  <p class="c4">text2</p>   <!-- marginLeft: 5px -->
</div>
```

| Element | Expected | Before #1581 (leaked) | After #1581 (reset) |
This PR (style stack) |
| --- | --- | --- | --- | --- |
| `c3` | 10+20+5 = 35px | 35px | 25px | 35px |
| `c4` | 10+5 = 15px | 35px (wrong, leaked c2) | 5px (wrong, lost c1) |
15px |

This PR replaces the reset-on-close approach with a proper block style
stack. When a block element opens, its resolved style is pushed onto the
stack. When it closes, the stack pops back to the parent's style. This
correctly accumulates nested margins/padding while preventing style
leakage to siblings.

---

### AI Usage

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

Did you use AI tools to help write this code? Yes

---------

Co-authored-by: Zach Nelson <zach@zdnelson.com>
2026-05-04 10:34:49 -05:00
Dave AllieandZach Nelson a1007a4660 fix: Track block style stack for nested styles (#1582)
## Summary

* Add new block style stack to track accumulated styles
* When dropping into nested block, apply new styles on last BlockStyle
in the stack and push to the back
  * When leaving block, pop the stack
* Fix issue where the image % width always went off screen width, use
current container width based on accumulated styles
* Bump section cache version to regenerate pages

## Additional Context

Fixes https://github.com/crosspoint-reader/crosspoint-reader/pull/1581
more correctly, and includes fix from
https://github.com/crosspoint-reader/crosspoint-reader/pull/1580

  Consider:
```html
<div class="c1">          <!-- marginLeft: 10px -->
  <div class="c2">        <!-- marginLeft: 20px -->
    <p class="c3">text</p>  <!-- marginLeft: 5px -->
  </div>
  <p class="c4">text2</p>   <!-- marginLeft: 5px -->
</div>
```

| Element | Expected | Before #1581 (leaked) | After #1581 (reset) |
This PR (style stack) |
| --- | --- | --- | --- | --- |
| `c3` | 10+20+5 = 35px | 35px | 25px | 35px |
| `c4` | 10+5 = 15px | 35px (wrong, leaked c2) | 5px (wrong, lost c1) |
15px |

This PR replaces the reset-on-close approach with a proper block style
stack. When a block element opens, its resolved style is pushed onto the
stack. When it closes, the stack pops back to the parent's style. This
correctly accumulates nested margins/padding while preventing style
leakage to siblings.

---

### AI Usage

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

Did you use AI tools to help write this code? Yes

---------

Co-authored-by: Zach Nelson <zach@zdnelson.com>
2026-05-04 10:34:09 -05:00
Zach Nelson 6c4ae7c41a refactor: Avoid vector for page turn rates list (#1818)
## Summary

Small cleanup to avoid a dynamically allocated static structure for auto
page-turn rate values.

---

### 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-04 08:45:44 -05:00
Zach Nelson a5fac320ab refactor: Simplify sort in GfxRenderer::fillPolygon (#1817)
## Summary

Small simplification to node sorting algorithm in
GfxRenderer::fillPolygon.

---

### 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-04 08:45:15 -05:00
MaciekandЕгор Мартынов 2e2ea6a9e8 feat: Page turn button orientation change (#1069)
Co-authored-by: Егор Мартынов <martynovegorOF@yandex.ru>
2026-05-04 08:56:02 +03:00
Julia b692bad10d fix: support legacy XTC file headers where pageTableOffset=48 (#1816)
## Summary

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

Restore compatibility with older XTC files that use the legacy 48-byte
header layout, so they open correctly instead of failing
header/page-table validation.

**What changes are included?**

- Updates the XTC parser to accept legacy files where pageTableOffset
starts at 0x30 instead of the newer full header size.
- Avoids treating legacy header bytes as a valid chapterOffset, so older
files are not misclassified as having chapters.
- Switches XTC file size and seek operations to 64-bit-safe wrapper
calls in HalStorage, which matches the XTC format’s 64-bit offset fields
and keeps page/chapter lookups from narrowing offsets.
- Keeps the existing bounds checks and improves related logging when XTC
page loads fail.

## Additional Context

- This is a narrow compatibility fix for XTC handling. The functional
change is in the XTC parser; the HalStorage changes are supporting
wrapper methods needed by that parser update.
- Risk should be low outside XTC reading, since the HAL changes only
expose existing underlying file APIs rather than changing storage
behavior.

---

### 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-03 20:48:35 -05:00
Zach Nelson aa7a31b3db fix: Read GH release JSON as stream in OTA updater (#1810)
## Summary

The GitHub recent release API returns lots of data, including the full
release notes. For 1.2.0, that data is 30,530 bytes. The existing
OtaUpdater HTTP handler and single-buffer JSON parsing can't reliably
handle that much data in the constrained ESP32 environment.

This change does a few things:
1. Adds a very simple lib/JsonParser/StreamingJsonParser.cpp with
SAX-style callbacks to read JSON data incrementally.
2. Adds a very simple lib/JsonParser/ReleaseJsonParser.cpp building on
StreamingJsonParser, which specifically parses the GitHub release JSON
for the release version, URL, and size.
3. Updates OtaUpdater.cpp to use an instance of ReleaseJsonParser to
incrementally parse the large response it may receive from GitHub.

Building from this commit while overriding my local version to 1.1.9, I
was able to run the OTA update process ~5 times in a row successfully.

Fixes #1561 (second part, after #1805).

---

### 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-03 20:48:06 -05:00
Uri Tauber 22701ccf18 fix: rendering bug of scrollbar in RoundedRaff theme (#1814)
## Summary

Fix small bug I encounter when using RoundedRaff theme.
Without this the scrollbar positioning is a bit off sometimes, and on
the serial output you get hundreds of lines like:
```
[22:31:00] [ERR] [GFX] !! Outside range (472, 834) -> (834, 7)
[22:31:00] [ERR] [GFX] !! Outside range (473, 834) -> (834, 6)
[22:31:00] [ERR] [GFX] !! Outside range (474, 834) -> (834, 5)
[22:31:00] [ERR] [GFX] !! Outside range (471, 835) -> (835, 8)
[22:31:00] [ERR] [GFX] !! Outside range (472, 835) -> (835, 7)
[22:31:00] [ERR] [GFX] !! Outside range (473, 835) -> (835, 6)
[22:31:00] [ERR] [GFX] !! Outside range (474, 835) -> (835, 5)
[22:31:00] [ERR] [GFX] !! Outside range (471, 836) -> (836, 8)
[22:31:00] [ERR] [GFX] !! Outside range (472, 836) -> (836, 7)
[22:31:00] [ERR] [GFX] !! Outside range (473, 836) -> (836, 6)
[22:31:00] [ERR] [GFX] !! Outside range (474, 836) -> (836, 5)
```
2026-05-03 15:54:02 -05:00
Zach Nelson 64ecfe2ef3 refactor: Store only unique localization strings in offset buffers (#1802)
## Summary

Omit any duplicate strings in non-English localization lookup tables.
For non-English lookup offsets, tag bit 15 to indicate the offset
applies to the English localization table.

Saves 18,766 bytes of flash.

---

### 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-02 16:14:12 -05:00
Justin MitchellandJustin Mitchell ba4a361d64 fix: OTA update on x3 and progress bar on x4 and x3 (#1805)
Co-authored-by: Justin Mitchell <1875695+itsthisjustin@users.noreply.github.com>
2026-05-01 19:15:15 -04:00
Pietro CampagnanoandClaude Sonnet 4.6 15ea7027df style: move file type badges into Type column (#1793)
## Summary

The file type was shown twice in the file browser table: as a bold badge
in the **Name** column and as plain text in the **Type** column.

**Before:** badge in Name column + redundant text in Type column
**After:** badge lives exclusively in the Type column

| | Before | After |
|---|---|---|
| Folder | Name: `📁 folder` **FOLDER** · Type: `Folder` | Name: `📁
folder` · Type: **FOLDER** |
| EPUB | Name: `📗 book.epub` **EPUB** · Type: `EPUB` | Name: `📗
book.epub` · Type: **EPUB** |
| Other | Name: `📄 file.txt` · Type: `TXT` | unchanged |

### Before
<img width="1125" height="571" alt="Screenshot From 2026-04-30 22-07-59"
src="https://github.com/user-attachments/assets/794c66d3-5ad5-4536-a142-708989b9dee8"
/>

### After
<img width="1125" height="571" alt="Screenshot From 2026-04-30 22-08-45"
src="https://github.com/user-attachments/assets/f9fb2898-b165-478a-813b-f2290ad3e42c"
/>


## Changes
- Removed `epub-badge` and `folder-badge` from the Name column
- Moved badges into the Type column, replacing the redundant plain text
- Non-EPUB files are unaffected (extension text remains in Type column)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:59:28 -05:00
Zach Nelson b25389b43e refactor: Move language setting into JSON settings (#1796)
## Summary

Another follow up to #1408. That PR revised the language.bin settings
file to version 2. After merging, it occurred to me that it would make
more sense to have the language inside the overall settings.json file,
easy for users to see and change directly.

This change adds migration from language.bin to settings.json. It only
migrates from the pre-#1408 v1 language.bin format, because v2 has only
been in for a couple of commits and I don't think we need long-term
maintenance code to handle it.

#1408 had a potential long-term maintenance issue for migration. It
assumed that the enum order of languages never changes, only grows. So,
e.g. "Portuguese (Portugal)" could never be added next to "Portuguese
(Brazil)", only appended. This change includes a hardcoded frozen
ordering of the language indices as they existed at 2f969a9, and
migrates to storing a language code string in the settings.json instead
for future reordering flexibility.

Similar to the settings.bin to settings.json migration, which renames
settings.bin to settings.bin.bak, this change renames language.bin to
language.bin.bak after adding the language setting to settings.json.

---

### 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-01 09:39:28 -05:00
Zach Nelson ae865f6d08 fix: Use LOG_ macros in loc functions (#1794)
## Summary

Quick follow up to #1408. We overlooked the fact that the change wasn't
using the LOG_ macros. Also, removed redundant file.close() calls, as in
23aad21.

---

### 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-04-30 15:47:39 -05:00
jpirnayandZach Nelson d53c8b0e0e perf: replace i18n pointer tables with offset tables, strip unused strings (#1408)
## Summary

* **What is the goal of this PR?** Reduce the flash footprint of the
i18n string data and tooling improvements to `gen_i18n.py`.

* **What changes are included?**

### 1. `lib/I18n/I18n.cpp` — use offset-based lookup

`I18n::get()` previously dereferenced a `const char* const*` pointer
array. It now uses a two-field `LangStrings` struct (a flat char blob +
a `uint16_t` offset table) generated for each language:

```cpp
// before
const char* const* strings = getStringArray(_language);
return strings[index];

// after
const LangStrings lang = getLanguageStrings(_language);
return lang.data + lang.offsets[index];
```

Lookup cost is unchanged — still O(1), one array load and one addition.

### 2. `scripts/gen_i18n.py` — new generated layout

Each language's string data is now emitted as:

- **`STRINGS_XX_DATA[]`** — a single `const char[]` blob of all strings
concatenated with `\0` separators.
- **`OFFSETS_XX[]`** — a `uint16_t` array of one byte-offset per `StrId`
into the blob.

Previously each language had a `const char* const STRINGS_XX[]` pointer
array (4 bytes/entry on ESP32-C3).

#### Flash savings

| Table type | Size per language | 19 languages |
|---|---|---|
| `const char*` pointer array (before) | `339 × 4 = 1,356 B` | **25,764
B** |
| `uint16_t` offset table (after) | `339 × 2 = 678 B` | **12,882 B** |
| **Saved** | | **12,882 B (~12.6 KB)** |

String data size is unchanged — 133,092 B across 19 languages.

**Total: 158,856 B → 145,974 B** (pointer tables → offset tables).

### 3. Build-time stripping of unused strings

`gen_i18n.py` now scans the `src/` and `lib/` trees for `STR_*`
references and, during a PlatformIO build, automatically omits the 52
strings that are defined in YAML but never referenced in code. This
further reduces the compiled output from 339 → 287 string keys per
language.

---

## `gen_i18n.py` CLI reference

```
python gen_i18n.py [translations_dir [output_dir]] [options]
```

| Argument / Flag | Default | Description |
|---|---|---|
| `translations_dir` | `lib/I18n/translations` | Path to the
per-language YAML files |
| `output_dir` | `lib/I18n/` | Where to write the generated `.h` /
`.cpp` files |
| `--src-dirs DIR [DIR …]` | `src lib` | Directories scanned for `STR_*`
usage |
| `--strip-unused` | off | Remove unreferenced `STR_*` keys from
generated output |
| `--verbose` / `-v` | off | Print per-key INFO/WARNING messages and
`Generated:` lines |

The PlatformIO build (SCons `pre:` hook) calls `main(strip_unused=True)`
automatically, so unused strings are always stripped from firmware
builds without any manual flag.

**Default run output** (no flags) shows a per-language summary table:
```
Language            Code  Own  Fallback  Unused  Data (B)
------------------  ----  ---  --------  ------  --------
English             EN    339  0         52        5,266
Belarusian          BE    310  29        52        9,673
…

  Total: 339  |  Used in code: 287  |  Never used: 52
  Flash (now):    133,092 B strings  +  12,882 B offset tables (uint16_t)  =  145,974 B
  Flash (before): 133,092 B strings  +  25,764 B pointer tables (ptr32)    =  158,856 B
  Saved by offset tables: 12,882 B
```

---

### AI Usage

Did you use AI tools to help write this code? **YES** (GitHub Copilot)

---------

Co-authored-by: Zach Nelson <zach@zdnelson.com>
2026-04-30 13:57:19 -05:00
Jon Stieglitz 2f969a93b4 feat: context-aware screenshot filenames with book title (#1589)
## Summary

I love the new screenshot feature but I can tell that soon I'm gonna
have a folder full of screenshots and not know what's what. It would be
great if the folder could self organize.

When a screenshot is taken while reading, the filename would now include
the book title, chapter (EPUB), page number, and progress percentage.

Example: My-Great-Book_ch3_p5_42pct_12345.bmp

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

## Additional Context

* Non-reader screens keep the existing screenshot-<millis>.bmp naming.

---

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

I reviewed all the changes but I don't have any experience with this
project so this is my best guess
2026-04-30 13:56:45 -05:00
14e1ce2d04 fix: use esp_http_client for KOSync to prevent TLS OOM on ESP32-C3 (#1381)
## Summary

* **What is the goal of this PR?** Fix KOReader sync failing on HTTPS
servers due to TLS out-of-memory on ESP32-C3
* **What changes are included?**
- Replace `WiFiClientSecure`/`HTTPClient` with `esp_http_client`
(ESP-IDF native API) for all KOSync HTTP requests
- Use 2KB TLS buffers instead of the default 16KB — KOSync payloads are
tiny JSON (<1KB), so this is more than sufficient
- Use `esp_crt_bundle_attach` for proper TLS certificate verification
(replaces `setInsecure()`)
- Strip trailing slashes from server URL to prevent double-slash in API
paths (e.g. `https://server.com//users/auth`)
  - Add `lastHttpCode` static field for diagnostics
  - Add free heap logging to help debug memory issues

### Problem

The ESP32-C3 has ~46KB free heap after WiFi is initialized.
`WiFiClientSecure` allocates 16KB for TLS RX + 16KB for TLS TX = 32KB
just for the TLS buffers, leaving almost no room for the actual TLS
handshake (which needs additional dynamic allocations). This causes
KOReader sync to:

1. Fail silently with network errors on HTTPS servers (including the
default `sync.koreader.rocks`)
2. Occasionally crash with heap exhaustion

### Solution

`esp_http_client` (the ESP-IDF native HTTP client) allows configuring
`buffer_size` and `buffer_size_tx` independently. Setting both to 2KB
(total 4KB) leaves plenty of heap for the TLS handshake while still
being sufficient for KOSync's small JSON payloads.

This also fixes the `setInsecure()` anti-pattern —
`esp_crt_bundle_attach` provides proper certificate verification using
the ESP-IDF's built-in CA bundle, so credentials and reading history are
no longer sent over unverified TLS connections.

### Files changed

| File | Change |
|------|--------|
| `lib/KOReaderSync/KOReaderSyncClient.cpp` | Replace
WiFiClientSecure/HTTPClient with esp_http_client; add ResponseBuffer,
base64 encoder, createClient helper |
| `lib/KOReaderSync/KOReaderSyncClient.h` | Add `lastHttpCode` static
field |
| `lib/KOReaderSync/KOReaderCredentialStore.cpp` | Strip trailing
slashes from base URL |

## Additional Context

Tested on a CrossPoint X4 (ESP32-C3 with 4MB flash). Before this change,
KOSync auth to `sync.koreader.rocks` (HTTPS) would fail ~80% of the
time. After: works reliably.

The `base64Encode` helper is needed because `esp_http_client` doesn't
have a built-in `setAuthorization()` method like `HTTPClient` does. This
is used for the HTTP Basic Auth header required by Calibre-Web-Automated
KOSync servers.

Fixes #581

---

### 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**_ AI
assisted with the esp_http_client migration pattern and base64 encoder.
The root cause analysis (TLS buffer OOM) and solution design were
manual.

---------

Co-authored-by: trilwu <trilwu@users.noreply.github.com>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
2026-04-30 11:19:22 -05:00
5d2e5596b4 feat: X3 gyroscope-based tilt page turning via QMI8658 IMU (#1636)
Co-authored-by: justinian <juicecutlus@gmail.com>
Co-authored-by: Vincent Politzer <vincent@skipwithjoy.com>
2026-04-29 15:29:33 -04:00
spfenwick bc9651b664 fix: use same file name as KOReader for OPDS downloads (#1286)
This allows KOSync to work for files downloaded from OPDS when set to
use filename matching

## Summary

* **What is the goal of this PR?** Brings the names of files downloaded
via OPDS into line with the KOReader OPDS plugin so the files sync when
KOSync is set to filename matching
* **What changes are included?** Change the filename generated by the
OPDS browser from "\<title\> - \<author\>" to "\<author\> - \<title\>"
so it matches KOReader.

## Additional Context

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

Both KOReader and crosspoint sanitise the filename by removing special
characters. This step seems to already use the same rules.

---

### 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-04-28 19:29:05 -05:00
Rob Hooperandrhoopr 741dd89ac1 fix: cap per-side horizontal CSS inset at 2em (#1694)
## Summary

- **What is the goal of this PR?** Fix chapter-opener text collapsing to
1-2 words per line in EPUBs that apply large em-based horizontal CSS
insets.
- **What changes are included?** Caps `marginLeft`, `marginRight`,
`paddingLeft`, and `paddingRight` at 2em in `BlockStyle::fromCssStyle`
(`lib/Epub/Epub/blocks/BlockStyle.h`). Vertical margins/padding are
unchanged - the bug is horizontal-only.

## Additional Context

**Repro:** *Mother Night* by Kurt Vonnegut, Chapter 21 ("My best
friend..."). The chapter-opening element's embedded CSS sets a large
horizontal inset.

- Settings: Embedded Style = On, Justify alignment (default).
- Before: 1-2 words per line with a visible river between them.
- After: body text fills the usable page width like every other
paragraph.

**Why the clamp lives in `fromCssStyle`:** This is the single point
where CSS lengths resolve to pixels, so clamping here keeps both
`effectiveWidth` call sites in `ChapterHtmlSlimParser` (`:1131-1133`
makePages, `:841-844` long-block split) consistent with the
`leftInset()` xOffset. A width-only clamp at either call site would
leave text pushed right by a large xOffset.

**Test plan:**
- [x] Flashed to Xteink X4. Chapter 21 of *Mother Night* renders
normally with Embedded Style on.
- Users with cached layouts of affected books need to delete
`.crosspoint/epub_<hash>/sections/` (or the whole `.crosspoint/`) on the
SD card to pick up the fix.

**Before / After:**
<p float="left">
<img height="400" alt="IMG_0320"
src="https://github.com/user-attachments/assets/de8815ae-f2f4-4845-be1f-449c5a25d191"
/>
<img height="400" alt="IMG_0324"
src="https://github.com/user-attachments/assets/95f5f4cb-8aa1-418e-b611-18e0c342cbef"
/>
</p>

---

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

I used Claude Code (Opus 4.7) to evaluate the codebase and find the
relevant code related to this rendering. From there, it was human
designed, reviewed and tested.

Co-authored-by: rhoopr <>
2026-04-27 19:37:41 +03:00
Stefan Blixten Karlsson ef98d44ef3 fix: python requirements files (#1768)
## Summary
The Python scripts in the current repo have many requirements that are
not mentioned in any requirements.txt files, so I have therefore added
them to the directories that need them.

Question: Should these changes maybe moved into a "root"
requirements.txt file?

---
### AI Usage

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

Did you use AI tools to help write this code? _**NO**_
2026-04-27 19:28:53 +03:00
b8a51522ca feat(theme): add roundedraff theme and fix sleep cover crop grid artifacts (#918)
## Summary
- Add new `roundedraff` theme.
- Fix sleep screen artifact where book cover showed grid lines in
`Cover` mode with `Crop`.

## Additional Context
## Key changes
- Added `src/components/themes/roundedraff/` theme implementation.
- Updated sleep cover rendering logic in:
  - `src/activities/boot_sleep/SleepActivity.cpp`
  - `src/activities/boot_sleep/SleepActivity.h`
- Improved cover generation/regeneration paths in:
  - `lib/Epub/Epub.cpp`
  - `lib/Epub/Epub.h`
  - `lib/Txt/Txt.cpp`
  - `lib/Txt/Txt.h`

## Verification
- Sleep screen set to Book cover
- Sleep screen cover mode set to Crop
- Confirmed grid artifacts no longer appear on cover sleep screen.

### AI Usage

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

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

---------

Co-authored-by: CaptainFrito <yzq6x5zypy@privaterelay.appleid.com>
Co-authored-by: Zach Nelson <zach@zdnelson.com>
Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com>
2026-04-27 19:01:22 +03:00
Uri Tauber 33386953d9 feat: enable pio build cache (#1769)
## Summary

* **What is the goal of this PR?** 
Enables the PlatformIO build cache to speed up compilation.
 
## Additional Context

Significant improvement when switching branches, as unchanged files
won't need re-compilation.
See
[Docs](https://docs.platformio.org/en/latest/projectconf/sections/platformio/options/directory/build_cache_dir.html).

Note: May need to manually delete .cache folder if updating toolchain or
framework.

---

### 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-04-27 16:45:38 +03:00
Arthur Tazhitdinov 02822e01b3 feat: include short SHA in CROSSPOINT_VERSION (#1728)
## Summary

* Includes short SHA in version string for better version tracking, so
it looks like `1.1.0-dev-feat-kosync-xpath-05c6cf8`
* Closes
https://github.com/crosspoint-reader/crosspoint-reader/issues/1247

---

### 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-04-27 15:52:09 +03:00
Stefan Blixten Karlsson 198b9d7786 fix: swedish translations (#1762)
## Summary

* Add swedish translation of strings added by "feat: Support for
multiple OPDS servers (#1209)"

---

### 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-04-27 00:01:36 +03:00
Zach Nelson 15e0d39d2c Revert "feat: Add royalty.dev funding integration" (#1745)
Reverts crosspoint-reader/crosspoint-reader#1741

I don't entirely understand Royalty.dev and want to vet this further.
2026-04-22 16:29:09 -05:00
Arthur TazhitdinovandCopilot 6c4e946e27 chore: git pre-commit hook for format fix (#1730)
## Summary

* Adds pre-commit git hook that automatically runs
./bin/clang-format-fix

---

### AI Usage

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:20:03 +03:00
pablohc 907e14da28 fix: pressing space barely moves input cursor (#1729) (#1733)
## Summary

* **What is the goal of this PR?** 
Fix the visual keyboard cursor not advancing when typing a space. Typing
one space leaves the cursor at position 0; typing two spaces advances it
only by one space width. This affects all input types except URL.
* **What changes are included?** 
Replace `getTextWidth()` with `getTextAdvanceX()` in four locations
within `KeyboardEntryActivity::render()` for cursor positioning and
line-wrapping calculations.

## Additional Context

* **Root cause**: `getTextWidth()` returns the bounding-box width of the
drawn glyphs. The space glyph has `width=0` and `height=0` (it's
invisible), so `getTextWidth(" ") == 0`. The trailing `advanceX` of the
last character is only flushed when the *next* character is processed,
so a string ending in space reports zero width. `getTextAdvanceX()`
correctly includes the final glyph's advance, matching how `drawText()`
actually positions characters.

---

### 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-04-22 22:18:26 +03:00
Justin Mitchell b9b795bf45 feat: Add royalty.dev funding integration (#1741)
Adds royalty.dev as a funding platform for contributors by adding custom
funding link and badge to README

A maintainer would need to click the "Are you the maintainer?" button on
https://app.royalty.dev/crosspoint-reader/crosspoint-reader to install
the github app which pulls the PRs in and ranks them.

(Disclosure, Royalty is a product I've recently built and am using for
my own OSS and currently gating custom font builds and nightlys on
crosspointreader.com)

This page currently can collect donations but without the integration it
wont notify contributors and it wont rescore fresh PRs.
2026-04-22 13:32:36 -05:00
Stefan Blixten Karlsson b21b10f4b9 fix: Add swedish keyboard translations (#1726)
## Summary

* Add the swedish translations of the text strings added by #1697 

---

### 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-04-22 11:25:39 -05:00
Arthur TazhitdinovandCopilot 1cf2239742 feat: Support for multiple OPDS servers (#1209)
## Summary

* Add support for configuring and using multiple OPDS servers, replacing
the previous single-server limitation. Closes
https://github.com/crosspoint-reader/crosspoint-reader/issues/1178
* New OpdsServerStore singleton (modeled after WifiCredentialStore) that
persists up to 8 OPDS servers to /.crosspoint/opds.json with MAC-based
password obfuscation.
* One-time migration from legacy single-server fields in
CrossPointSettings to the new store on first boot.
* New OpdsServerListActivity for the device UI — works in two modes: a
settings list (add/edit/delete servers) and a picker (select which
server to browse). When only one server is configured, the picker is
skipped automatically.
* Renamed CalibreSettingsActivity → OpdsSettingsActivity for clarity. It
now edits individual OpdsServer entries (name, URL, username, password,
delete).
* OpdsBookBrowserActivity now receives an OpdsServer at construction and
uses its credentials for all fetches/downloads, and shows the server
name in the header.
* HttpDownloader::fetchUrl and downloadToFile accept optional per-call
username/password parameters instead of reading from global settings.
* REST API endpoints on CrossPointWebServer: GET /api/opds, POST
/api/opds, POST /api/opds/delete — passwords are never exposed over the
API (only a hasPassword flag), and omitting the password field on update
preserves the existing one.
* Web UI (SettingsPage.html) with dynamic OPDS server management cards —
add, edit, save, and delete servers from the browser.
<img width="932" height="906" alt="SCR-20260416-stvu"
src="https://github.com/user-attachments/assets/a8f18d84-4204-46a0-bb31-b73d24b3255f"
/>


## Additional Context

* The OpdsServerStore JSON format and obfuscation scheme are identical
to WifiCredentialStore, so the same JsonSettingsIO infrastructure
handles both.
* The web API uses POST /api/opds/delete instead of DELETE /api/opds
because the ESP32 WebServer doesn't support the DELETE method with a
request body.
* Existing single-server configurations are migrated automatically — no
user action required. After migration the legacy CrossPointSettings
fields are cleared so it only runs once.
* The HttpDownloader changes are backward-compatible: the credential
parameters default to empty strings, so existing callers are unaffected.

---

### AI Usage

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 18:35:41 -05:00
Zach Nelson c5f82709c0 fix: Replaced Bookerly with Noto Serif for licensing reasons (#1736)
## Summary

Fixes #258. Bookerly is not licensed for use in CrossPoint. Switched to
Google's open Noto Serif font.

---

### 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-04-21 17:51:07 -05:00
Pietro CampagnanoandClaude Sonnet 4.6 867fb7cbe2 style: unify page headers across web UI (#1702)
## Summary

* **Goal**: Align `FilesPage` and `SettingsPage` headers with the
existing `HomePage` pattern.
* **Changes**:
- Added `<h1>📚 CrossPoint Reader</h1>` at the top of `<body>` on both
pages.
- Demoted the page-specific `📁 File Manager` heading on `FilesPage` from
`<h1>` to `<h2>` (inside `.page-header`), so every page has a single
`<h1>` — the app name.
- Moved the accent-colored `border-bottom` from `.page-header` to `h1`,
matching the HomePage CSS.

All three pages now share the same top structure:
`<h1>CrossPoint Reader</h1>` → nav links → page content.

## Screenshots

### Home

**Before and after is the same**
<img width="1153" height="388" alt="home before"
src="https://github.com/user-attachments/assets/4eb1c969-89fd-4965-ba17-5ff956576a8b"
/>

### File Manager

**Before**
<img width="1153" height="388" alt="files before"
src="https://github.com/user-attachments/assets/ba689d6b-56b1-44d1-a151-3f67b0fcdb16"
/>

**After**
<img width="1153" height="388" alt="files after"
src="https://github.com/user-attachments/assets/437db5e4-9102-4a19-b049-7a93e4e403b2"
/>

### Settings

**Before**
<img width="1153" height="388" alt="settings before"
src="https://github.com/user-attachments/assets/39a7e489-f0d3-4ac1-a2d7-98028aae65c0"
/>

**After**
<img width="1153" height="388" alt="settings_after"
src="https://github.com/user-attachments/assets/1c479618-ec8b-4dee-b704-3da07e6c279e"
/>



## Additional Context

* Pure markup + CSS, no JavaScript or behavioral changes.
* Improves HTML semantics (single `<h1>` per page, referring to the
app).
* No new CSS duplication introduced — borrows existing styling from
HomePage.

---

### AI Usage

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

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:32:13 -05:00
Zach Nelson 5e26baef63 chore: One Italian translation tweak (#1718)
## Summary

Following up on #1685. One tweaked string confirmed by @alan0ford.

---

### 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-04-21 15:20:41 -05:00
CSCMe 8154f88dbe fix: Erroneous navigation with long filenames in footnote links (#1723)
## Summary

* **What is the goal of this PR?** 
* Increase the allowed link length for footnotes
* **What changes are included?** 
* Un-magic-numbers parts of the footnote parser
* Increases max href length

## Additional Context

* Additional RAM usage was not noticable to me, worst case 16 * 128 * 2
= 4KB instead of previously 16 * 88 * 2 ~ 2.8KB, see #1031
* Motivation: My book navigated me to the start of the footnote chapter,
which required manual navigating for every footnote due to ungodly file
name choices which pushed the required href length to 72, which caused
truncating, which caused Crosspoint to not find the anchor (Sacred and
Terrible Air, available via reddit)

---

### 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-04-21 18:34:17 +03:00
Stefan Blixten Karlsson 56d3ab929c feat: show long branch names (#1727)
## Summary

* This change will make better use of the space available for Title and
Subtitle in the Settings (Theme: Lyra).
Instead of having a fixed maxwidth in pixels for subtitle, it will use
the whole line and shorten the longest one if needed.

So instead of: <img width="480" height="140" alt="image"
src="https://github.com/user-attachments/assets/67e4777b-10d0-4a05-b5c7-fb775776c186"
/>

It will now show: <img width="480" height="142" alt="image"
src="https://github.com/user-attachments/assets/2f143f4e-75d7-45c4-b61a-aad56bdf39fb"
/>


---

### 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-04-21 18:33:33 +03:00
Pietro Campagnano 9dab5f471b style: put page name first in browser titles (#1703)
## Summary

* **Goal**: Make browser tab titles, bookmarks, and history entries
distinguishable across pages (including individual folders in the File
Manager).
* **Changes**:
- Reordered `<title>` so the page name comes first, followed by
`CrossPoint Reader`. Browser tabs usually truncate titles from the
right, and bookmark/history lists are sorted alphabetically by title —
putting the differentiating part first keeps it visible in both cases,
instead of every entry starting with `CrossPoint Reader - …`.
- On `FilesPage`, added a small JS hook that updates `document.title`
with the current subfolder leaf when a `path` query parameter is present
— previously all folders shared the same title.

| URL | Title before | Title after |
|-----|--------------|-------------|
| `/` | `CrossPoint Reader` | `CrossPoint Reader` _(unchanged)_ |
| `/settings` | `CrossPoint Reader - Settings` | `Settings - CrossPoint
Reader` |
| `/files` | `CrossPoint Reader - Files` | `Files - CrossPoint Reader` |
| `/files?path=/Books` | `CrossPoint Reader - Files` | `Books - Files -
CrossPoint Reader` |
| `/files?path=/Books/Fantasy` | `CrossPoint Reader - Files` | `Fantasy
- Files - CrossPoint Reader` |

## Additional Context

* Pure client-side change: `<title>` tags plus ~3 lines of JavaScript on
FilesPage. No network, behavior, or memory impact.
* The static `<title>` tag remains as a fallback if JS fails.
* `HomePage` title left as-is since the app name alone is the standard
convention for a root/home page.

---

### 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-04-21 16:38:20 +03:00
mvidelatraduc ce22deab7c chore: Update spanish.yaml (#1717)
## Summary

Make this more transparent in Spanish:

`STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"`

A weakness of the current translation is that the verb used is "to
read", but it's not clear that it is the user who will be reading a
certain amount of pages per minute. I've replaced it with the verb for
"to turn (pages)" which is the (analogous) physical action happening
regardless of who executes it, and the abbreviated adverb to clarify
that this is an automatic process. Lastly, a clarification of what the
number means in a simple proportion: "X pages in a minute". The Spanish
is very good overall, this one string is very hard to do in such a short
space. Even more so given that the number cannot be accompanied by any
units, so it must be clear from the text alone what it means.

## Additional Context

N/A

---

### 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-04-20 19:29:13 -05:00
Pietro CampagnanoandClaude Sonnet 4.6 e28918b24d fix: pluralize folder/file counts correctly in file list summary (#1701)
## Summary

* **Goal**: Fix incorrect pluralization in the file manager web UI
summary line.
* **Changes**: `FilesPage.html` always rendered the plural forms
("folders"/"files") regardless of count. The summary now selects
singular or plural based on each count.

Example:
- Before: `1 folders, 1 files, 12 KB`
- After: `1 folder, 1 file, 12 KB`

## Additional Context

* Cosmetic-only fix — no behavior, performance, or memory impact.
* Change is fully client-side (JavaScript inside a single HTML
template).
* English-only; web UI localization is out of scope here.

---

### AI Usage

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

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:36:19 -05:00
Brandon PhilipsandClaude Sonnet 4.6 c0ee096841 fix: relative opds paths and query param with copyparty (#1535)
## Summary

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

This PR fixes bugs when using Copyparty as an OPDS server. 

https://github.com/9001/copyparty?tab=readme-ov-file#opds-feeds

OPDS uses a query parameter `?opds` to differentiate between HTML
requests and OPDS requests for the same path. It also uses relative
paths in the responses instead of full paths. Crosspoint didn't handle
these two cases.

* **What changes are included?**

Fixes to the two issues above.

## Additional Context

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

Here is some example XML from my Copyparty instance:
https://gist.github.com/philips/9ecec29dfb69ed0591b032f16e799675

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

Partially. I used Claude code to write the fix. I am not a strong C++
programmer. But, I manually compiled and tested.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:35:17 -05:00
pablohc 1a145fe085 fix: keyboard feedback #1644 (#1697)
Addresses reviewer feedback from #1644:
- **Localize keyboard hint strings** — 13 hardcoded English strings
replaced with \`tr()\` macro (\`STR_KB_HINT_*\`), making them
translatable across all 22 languages (fallback to English when not yet
translated)
- **Deduplicate \`Lyra3CoversMetrics\`** — now derives from
\`LyraMetrics\` via lambda copy, overriding only \`homeCoverTileHeight\`
and \`homeRecentBooksCount\` (eliminates ~30 duplicated metric fields)
- **Unify keyboard drawing in \`BaseTheme\`** — \`drawTextField\` and
\`drawKeyboardKey\` overrides removed from \`LyraTheme\`; variability
controlled via \`keyboardKeyCornerRadius\` metric (0=Base, 6=Lyra).
Unified text field padding to 6, adopted Lyra's secondary label draw
order (main first, then secondary)
- **Add URL-optimized keyboard layout** — \`urlLayout\` with \`:\` and
\`/\` replacing \`=\` and \`,\` for easier URL input without switching
to SYM 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-04-20 12:47:57 -05:00
Justin Mitchellandjpirnay 302dea1eea fix: Switch to xpath map for paragraph level syncing in KOSync (#1686)
Switch KOReader sync progress mapping from chapter matching to
XPath-based mapping.

- resolves KOReader positions using real XHTML ancestry paths
- supports paragraph-based upload mapping with text offsets
where needed
- passes the current paragraph index into sync so uploads map
back to KOReader more accurately

No HTTP client changes are included. No reader-state or resume-flow
changes are included.

---------

Co-authored-by: jpirnay <jens@pirnay.com>
2026-04-20 12:47:41 -05:00
Angandcoderabbitai[bot] e8645ed92e docs: fix typos (#1705)
## Summary

Fix typos found via `codespell -S
*.txt,*.yaml,generate_kerning_ligature_epub.py -L
currenty,flate,ser,localy,logicaly,ans,clen,portugues,notin,curren`

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-20 09:12:40 +03:00
Zach Nelson 64f5ef018a feat: Support for proportional numeral spacing (#1414)
## Summary

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

Reading a book with frequent numbers, I noticed that the spacing between
numeral glyphs was strangely large. This was because Bookerly and Noto
Sans default to tabular figures, where every digit gets an identical
advance width. This is designed for column alignment in spreadsheets,
but in rendering prose it produces visually wide gaps between digits.

This change adds a `--pnum` flag to fontconvert.py that applies the
font's OpenType `pnum` (proportional numerals) feature during
conversion. When active, the converter:
- Parses the GSUB table for pnum SingleSubst lookups
- Resolves substitute glyph indices via fonttools' glyph order
- Loads the proportional alternate glyphs instead of the tabular
defaults
- Includes substitute glyph names in kern pair extraction, so kerning
data that references proportional alternates is captured

Bookerly's proportional alternates also carry digit-digit and
digit-punctuation kerning that the tabular glyphs lack (e.g., at 16pt
7->4 at -1.69px, 7->. at -2.31px, 7->1 at +1.00px).

Noto Sans gains proportional advances but no new kerning (its
proportional glyphs have no kern class data in the font).

OpenDyslexic is unaffected. Its `cmap` already points to proportional
glyphs, so `--pnum` is a no-op. `--pnum` is intentionally omitted from
OpenDyslexic in the build script for deliberately uniform digit spacing
as an accessibility choice.

UI fonts (Ubuntu, notosans_8) also omit `--pnum` to preserve tabular
alignment for page numbers, battery percentages, etc.

| Before | After |
| -- | -- |
| <img
src="https://github.com/user-attachments/files/26042238/screenshot-31673.bmp"
width="300" /> | <img
src="https://github.com/user-attachments/files/26042241/screenshot-124075.bmp"
width="300" /> |

---

### 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-04-18 17:03:04 -05:00
Zach Nelson 3cdfc6c781 chore: Improved Italian translations (#1685)
## Summary

Improved Italian translations provided by @alan0ford, closes #1578.

---

### 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-04-18 16:30:46 -05:00
pablohc 77b2c31635 refactor: redesign on-screen keyboard (#1644)
# Refactor: Redesign On-Screen Keyboard

## Summary

Complete redesign of the on-screen keyboard (used for WiFi password,
KOReader, Calibre URLs) with improved layout, navigation, visual style,
and new input features: **cursor mode** for text navigation, **password
mode** with visibility toggle, and **URL mode** with pre-defined
snippets.

## Screenshots

### Base Theme
|**master** | **PR #1644** |
|----------|-------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/49125857-12d0-4020-b872-05d0ddbf1d94"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/ad16656b-d66e-43dd-8697-2b85f709d7f8"
/> |

### Lyra Theme
| **master** | **PR #1644** |
|----------|-------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/d9901251-9154-48d2-83b4-376d3223d132"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/84b45949-ed61-4924-af1b-d570c4c61e13"
/> |

### Keyboard States

| ABC Mode | Symbol Mode | URL Mode |
|----------|-------------|----------|
| <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/6397a82e-50b8-4d03-92e0-f707ed7c9054"
/> | <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/205d34fe-0413-49e9-9db2-30569c297ba0"
/> | <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/801ddeab-e082-4a22-a9df-c66adb1afc16"
/> |

| Cursor Mode | Password Toggle |
|-------------|-----------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/841773e1-7a3c-45e5-aa89-e5787255608c"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/9487a0b3-3f47-41ed-9cd3-b24e56e342a8"
/> |

## Changes

### Layout (10-column uniform grid)
- Reduced from 13/11/10 columns per row to **10 uniform columns** across
all rows
- Keyboard now uses **90% of screen width** (was ~66%)
- Row 0: Numbers `1-9, 0` with secondary symbols (`!@#$%^&*()`)
- Rows 1-3: Standard QWERTY letters
- Bottom row: `shift` | `#@!` | `___` | `←` | `OK`

### New Symbol Mode (#@!)
- New mode toggle key `#@!` / `abc` switches between letter and symbol
layouts
- Symbol layout: 4 rows (numbers, inverted symbols, paired symbols,
loose symbols)
- Covers all 95 printable ASCII characters
- **No secondary hints, no long-press** in symbol mode (simple and
direct)
- SHIFT key remains visible but **disabled** in symbol mode

### URL Mode
- In `InputType::Url`, the Space key becomes a **URL toggle** button
- Activating URL mode replaces the 4 content rows with a **3×3 grid of
URL snippets**:
  - Col 0 (protocols): `https://`, `http://`, `/opds`
  - Col 1 (hosts/ports): `www.`, `192.168.`, `:8080`
  - Col 2 (domains): `.com`, `.org`, `.net`
- Snippets are inserted as full strings at the cursor position
- URL mode **persists** after inserting a snippet (does not
auto-deactivate)
- Column alignment: col 0 over ABC, col 1 over URL, col 2 over Del
- Up/Down navigation maps `bottomCol - 1` / `urlCol + 1`
- SHIFT disabled in URL mode
- SpecMode (`abc`) exits URL mode back to ABC
- SpecSpace (`URL`) toggles URL mode on/off; selection always stays on
the URL button
- Button styled with `KeyboardKeyType::Mode` for consistent outline

### Cursor Mode
- **Enter**: Long-press Up (500ms) while in keyboard mode
- **Exit**: Short-press Down while in cursor mode (resets
`passwordVisible`, clears toggle position)
- **Navigate**: Left/Right move cursor position within text (one
position per press, no continuous repeat)
- **Visual**:
  - Keyboard mode: underline cursor (2px line + serifs)
  - Cursor mode: inverted block cursor (black fill + white character)
- Block width adapts to the actual character width under cursor (minimum
6px for narrow chars like space)
- Block position includes inter-character kerning offset for correct
alignment (calculated via string-difference: `getTextWidth(before+char)
- getTextWidth(before) - getTextWidth(char)`)
  - End-of-text: thin 6px block
- Password hidden: 3-part drawing (Part 1 + block + Part 3) to prevent
block overflow onto `*` characters
- Toggle position: caret ("I") cursor at saved position, `[abc]`/`[***]`
label with inverted selection
- **Inactive key styling**:
  - BaseTheme: 2px outline rectangle
  - LyraTheme: gray filled rounded rectangle (`Color::LightGray`)
- **Password toggle position**: in cursor mode (Password only), Hold
Right (500ms) enters toggle — caret cursor shows saved position,
`[abc]`/`[***]` label becomes selected. Press Confirm to toggle
`passwordVisible`. Press Left to restore cursor to saved position. Right
from toggle is a no-op. Down from toggle exits to keyboard.
- `cursorPos` persists between keyboard and cursor modes

### Password Mode
- `InputType::Password` enum replaces `bool isPassword` parameter
- Text is masked with `*` except for one revealed character:
  - Keyboard mode: reveals character at `cursorPos - 1`
- Cursor mode: no reveal in display text (block cursor draws actual char
directly)
- **Toggle `[abc]`/`[***]`**: accessible via cursor mode — Hold Right
(500ms) enters toggle position, Confirm toggles visibility, Left exits
back to cursor. Caret ("I") shown at saved position while in toggle.
- `passwordVisible` resets to `false` when exiting cursor mode
- **Long-press Del (1.5s)**: clears all text and resets cursor to 0

### InputType Enum
- Replaced `bool isPassword` constructor parameter with `enum class
InputType { Text, Password, Url }`
- Callers updated: `WifiSelectionActivity`, `KOReaderSettingsActivity`,
`CalibreSettingsActivity`

### Contextual Tips
- `"Tips:"` header followed by context-sensitive hints, centered between
text field underline and keyboard as a block
- ABC mode: `"Hold SELECT for UPPERCASE or secondary char"` (shift ON:
`"lowercase"` variant) + `"Hold DEL to clear all text"` (only if text
not empty)
- ABC + `InputType::Url`: same + `"Press URL for snippets"`
- Symbol mode: `"Hold DEL to clear all text"` (only if text not empty)
- URL mode: `"Press ABC to exit URL mode"` + `"Hold DEL to clear all
text"` (only if text not empty)
- Cursor mode: `"Press DOWN to return to keyboard"`

### Hint Phases (cursor mode, Password only)
- **Phase 1**: `"Hold UP to edit entry"` — shown after 2× DEL press,
auto-hides after 4s, positioned below underline
- **Phase 2**: `"Press < or > to move cursor"` + dynamic password toggle
hint — shown when entering cursor mode, positioned below underline,
visible until exit
- When `!passwordVisible`: `"Hold > then press [abc] to show password"`
  - When `passwordVisible`: `"Hold > then press [***] to hide password"`
  - When in toggle position: `"Press < to return to cursor position"`

### Long-Press Alternative Character
- Holding Confirm (>500ms) inserts the **alternative character** instead
of the primary
- Letters: long-press inserts opposite case (e.g., `a`→`A`, `A`→`a`)
- Numbers/symbols (row 0): long-press inserts secondary (e.g., `0`→`)`,
`)`→`0`)
- Only active in ABC mode; disabled in Symbol mode and URL mode
- **`InputType::Url`**: Hold SELECT on ABC rows 1+ (letters) returns
primary character only (same as short press). Row 0 (symbols) still
returns secondary character on Hold SELECT.

### Shift (2 sticky states)
- Reduced from 3 states (shift/SHIFT/LOCK) to **2 sticky states**
(shift/SHIFT)
- Shift stays active after typing until manually toggled off
- Label: `shift` (off) / `SHIFT` (on)

### SpecialKeyType Enum
- `enum class SpecialKeyType { Shift, Mode, Space, Del, Ok }` replaces
plain `enum` (`SpecShift`, `SpecMode`, etc.) for type safety
- All switch cases updated to `SpecialKeyType::*` with
`static_cast<int>()` for array indexing
- `onExit()` reverted to simple `Activity::onExit()` call (half-refresh
removed)
- **Bottom row column mapping**: navigating up/down between content rows
and bottom row uses `col/2` and `col*2` formulas for consistent
positioning (10 cols ↔ 5 cols)
- **URL mode column mapping**: `bottomCol - 1` / `urlCol + 1` (3 cols ↔
5 cols)
- **Wrap-around**: row 0 → up → bottom row and bottom row → down → row 0
both apply correct column mapping

### Visual Improvements (both Base and Lyra themes)
- **Space key**: underscore-style horizontal line (60% of key width, 3px
thick)
- **Delete key**: arrow `←` drawn with lines (3px thick) instead of
"DEL" text
- **Secondary label** (ABC row 0): small hint in top-right corner with
separation from primary number
- **BaseTheme**: selection uses **inverted fill** (black rect + white
text) instead of `[bracket]` markers
- **BaseTheme**: text field brackets drawn as **stretchable lines** that
adapt to multi-line input (1px normal, 3px cursor mode)
- **LyraTheme**: text field uses **fixed-width underline** (16px
margins, 8px each side) instead of stretchable line (2px normal, 3px
cursor mode)
- **Both themes**: special keys (shift, mode, space, del, OK) have
bordered/bordered-rounded rectangles
- **Font size**: keyboard uses `UI_12_FONT_ID` in both themes (was
`UI_10` in Base)
- **Key height**: 40px in all themes for better proportions
- **Layout unification**: text and password toggle are left-aligned in
all themes (`keyboardCenteredText = false` for Lyra/Lyra3Covers)
- **`primaryOffset` removed**: dead code eliminated from BaseTheme and
LyraTheme `drawKeyboardKey`

### New Theme Metrics
- `keyboardVerticalOffset`: per-theme vertical adjustment of keyboard
position
  - Base: `-13`, Lyra: `-7`
- `keyboardBottomKeySpacing`: independent spacing for bottom row keys
  - Base: `5`, Lyra: `5`
- Bottom-aligned keyboard in both themes for consistent vertical
positioning
- Bottom row total width calculated to match content rows width (10-col
based, consistent across modes)
- 4px extra gap between content rows and bottom row when `bkSpacing > 0`
- `keyboardCenteredText`: `false` for all themes (unified left-aligned
text)

### Defensive Improvements
- **State reset on re-entry**: `onEnter()` resets all mutable state
(`symMode`, `urlMode`, `cursorMode`, `togglePos`, `passwordVisible`,
`shiftState`, `selectedRow`, `selectedCol`, `rightHeld`,
`rightLongHandled`, `savedCursorPos`, `rightStartCursorPos`,
`delPressCount`, `hintVisible`, `hintShowTime`) — prevents stale state
when re-entering the keyboard
- **Bounds checking**: `insertChar`/`insertString` clamp `cursorPos` to
`text.length()` before inserting
- **Empty string guard**: `insertString` returns early on empty string
- **`std::string::npos`**: used instead of `SIZE_MAX` for size_t
sentinel (proper C++ idiom)
- **`<algorithm>` header**: included for `std::max`

## Files Modified

| File | Changes |
|------|---------|
| `src/activities/util/KeyboardEntryActivity.h` | `InputType` enum,
`KeyDef` struct, 10-col layouts, cursor/password/URL/toggle state, hints
(`delPressCount`, `hintVisible`, `hintShowTime`), held vars
(`rightHeld`, `rightLongHandled`, `savedCursorPos`,
`rightStartCursorPos`), `mapColContentBottom` helper |
| `src/activities/util/KeyboardEntryActivity.cpp` | Complete rewrite:
layout rendering, symbol/cursor/password/URL modes, toggle position,
long-press, contextual tips, hint phases, block cursor kerning
alignment, defensive bounds checks, state reset |
| `src/components/themes/BaseTheme.h` | `KeyboardKeyType` enum, new
`drawTextField`/`drawKeyboardKey` signatures, `keyboardVerticalOffset`,
`keyboardBottomKeySpacing` metrics |
| `src/components/themes/BaseTheme.cpp` | Redesigned `drawTextField`
(stretchable brackets), `drawKeyboardKey` (inverted selection,
space/delete graphics, secondary label, inactive selection), removed
`primaryOffset` dead code |
| `src/components/themes/lyra/LyraTheme.h` | Override signatures,
`keyboardVerticalOffset`, `keyboardBottomKeySpacing`,
`keyboardKeyHeight` adjustments |
| `src/components/themes/lyra/LyraTheme.cpp` | `drawTextField` (fixed
underline), `drawKeyboardKey` (rounded rects for special keys,
space/delete graphics, secondary label, inactive selection), removed
`primaryOffset` dead code |
| `src/components/themes/lyra/Lyra3CoversTheme.h` |
`keyboardCenteredText = false`, `keyboardVerticalOffset = -7`, inherits
Lyra overrides |
| `src/activities/network/WifiSelectionActivity.cpp` | `bool isPassword`
→ `InputType::Password` |
| `src/activities/settings/KOReaderSettingsActivity.cpp` | `bool
isPassword` → `InputType::Text`/`InputType::Password`/`InputType::Url` |
| `src/activities/settings/CalibreSettingsActivity.cpp` | `bool
isPassword` → `InputType::Text`/`InputType::Password`/`InputType::Url` |

## Backward Compatibility

- **API change**: Constructor parameter changed from `bool isPassword`
to `InputType inputType` (default `InputType::Text`)
- **All callers updated**: WiFi, KOReader, and Calibre integrations
migrated to new `InputType` enum

## Testing

### Input & Text Handling
- [x] Empty input → press OK (submit empty string)
- [x] Back button → cancel (no text returned)
- [x] Pre-filled initial text (e.g., editing existing WiFi password)
- [x] Password mode: text masked with `*` characters, one character
revealed
- [x] Delete on empty text (no crash)
- [x] Very long text near maxLength limit
- [x] URL with path and port (~60 chars)
- [x] Multi-line text wrapping in input field
- [x] Space insert in middle of text (cursor mode)
- [x] Delete last character repeatedly
- [ ] Type all 95 printable ASCII characters

### Mode Switching
- [x] ABC → #@! preserves typed text and cursor position
- [x] #@! → ABC preserves typed text and cursor position
- [x] Shift state preserved when switching modes
- [x] Switch modes multiple times rapidly

### Shift Behavior
- [x] Shift OFF → type letter → inserts lowercase, shift stays OFF
- [x] Shift ON → type letter → inserts uppercase, shift stays ON
- [x] Shift ON → type number → inserts symbol, shift stays ON
- [x] Shift ON → navigate rows → shift stays ON
- [x] Shift ON → switch to #@! → shift shows "shift" (disabled)
- [x] Shift ON → switch to ABC → shift state preserved
- [x] Shift ON → switch to URL → shift shows "shift" (disabled)
- [x] Shift disabled in URL mode: pressing shift does nothing

### Long-Press
- [x] Long-press letter with shift OFF → inserts uppercase
- [x] Long-press letter with shift ON → inserts lowercase
- [x] Long-press number → inserts secondary symbol
- [x] Long-press symbol (row 0) → inserts opposite (number)
- [x] Long-press key without secondary (e.g., `-`, `=` in rows 2-3) →
inserts primary character on release
- [x] Long-press on special keys (shift, mode, space, del, ok) → no
alternative inserted
- [x] Long-press in #@! mode → no effect (disabled)
- [x] Long-press in URL mode → no effect (disabled)
- [x] Long-press number in row 0 with InputType::Url → inserts secondary
symbol (same as non-URL)
- [x] Short press after cancelled long-press → normal behavior
- [x] Long-press at maxLength → no character inserted
- [x] Long-press Del (1.5s) → clears all text

### Cursor Mode
- [x] Long-press Up → enters cursor mode
- [x] Short-press Down → exits cursor mode (resets passwordVisible)
- [x] Left/Right navigate within text
- [x] Left at position 0 → no movement
- [x] Right at end of text → no movement in Text mode, enters toggle in
Password mode (Hold Right)
- [x] Block cursor visual: correct width for character, thin block at
end
- [x] Underline cursor visual (keyboard mode): correct position with
serifs
- [x] Inactive key styling: outline (Base) or gray fill (Lyra) on
selected key
- [x] Typing with cursor mid-text → inserts at cursor position
- [x] Deleting with cursor mid-text → deletes character before cursor
- [x] Exit cursor mode → type at cursor position (inserts mid-text, not
at end)
- [x] Exit cursor mode from toggle → cursor at saved position (not end
of text)

### Password Mode
- [x] Masked text with one revealed character at `cursorPos - 1`
- [x] Cursor mode: block shows actual character, display text all `*`
- [x] Toggle `[abc]`/`[***]`: Hold Right (500ms) in cursor mode enters
toggle, Confirm toggles visibility, Left exits back to cursor
- [x] Exiting cursor mode resets `passwordVisible` to false
- [x] Long-press Del clears all text

### URL Mode
- [x] URL toggle activates/deactivates URL mode
- [x] URL button stays selected after toggle (both on and off)
- [x] Deactivating URL mode returns to ABC (not SYM)
- [x] 3×3 snippet grid displays correctly
- [x] Column alignment: col 0 over ABC, col 1 over URL, col 2 over Del
- [x] Snippet insertion: inserts full string at cursor position
- [x] URL mode persists after snippet insertion
- [x] Shift disabled in InputType::Url
- [x] SpecMode (`abc`) exits URL mode to ABC
- [x] Up/Down navigation between URL grid and bottom row

### Re-entry State Reset
- [x] Enter keyboard → activate URL mode → exit → re-enter → URL mode
OFF
- [x] Enter keyboard → switch to SYM → exit → re-enter → ABC mode
- [x] Enter keyboard → enter cursor mode → exit → re-enter → keyboard
mode
- [x] Enter keyboard → enter toggle pos → exit → re-enter → togglePos
OFF
- [x] Enter keyboard → activate shift → exit → re-enter → shift OFF
- [x] Enter password keyboard → toggle password visible → exit →
re-enter → password hidden

### Navigation
- [x] Left/right wrap-around within content rows
- [x] Left/right wrap-around within bottom row
- [x] Up from row 0 → bottom row (correct column mapping)
- [x] Down from bottom row → row 0 (correct column mapping)
- [x] Up from bottom row → last content row (correct column)
- [x] Down from last content row → bottom row (correct column)
- [x] Navigate horizontally in bottom row, then up → correct content
column
- [x] Navigate horizontally in bottom row, then down (wrap) → correct
content column

### Visual (both themes)
- [x] Secondary hints only on ABC row 0
- [x] No secondary hints in #@! mode or URL mode
- [x] No secondary hints on letter rows (1-3)
- [x] Space bar: horizontal line centered, not touching edges
- [x] Delete: arrow `←` drawn correctly
- [x] Selected key: inverted colors (black fill, white text)
- [x] All special keys have border rectangles
- [x] Fixed underline in text field (Both themes)
- [x] Mode key label: `#@!` in ABC mode, `abc` in symbol mode, `abc` in
URL mode
- [x] URL key label: `URL` (only in InputType::Url), styled same as
other bottom keys
- [x] Shift label: `shift` when OFF, `SHIFT` when ON, `shift` when
disabled (SYM/URL)
- [x] Both themes: bottom row total width matches content rows width
- [x] URL snippet grid centered over ABC/URL/Del buttons

### Device & Theme Coverage
- [ ] Base Theme on X3
- [x] Base Theme on X4
- [ ] Lyra Theme on X3
- [x] Lyra Theme on X4
- [ ] Lyra Extended Theme on X3
- [x] Lyra Extended Theme on X4

### Toggle Position
- [x] Hold Right > 500ms in cursor mode (Password) → enters toggle,
caret visible at saved position
- [x] Short-press Right in cursor mode (Password) → advances cursor 1
position, does not jump to toggle
- [x] Short-press Left in cursor mode (Password) → moves cursor left 1
position, from toggle returns to saved position
- [x] Confirm in toggle → toggles `passwordVisible`
- [x] Left from toggle → returns to saved position, caret disappears,
block cursor appears
- [x] Right from toggle → no-op
- [x] Down from toggle → exits to keyboard, cursor at saved position
- [x] Hold Right in cursor mode (InputType::Text) → no effect
- [x] Hold Right in cursor mode (InputType::Url) → no effect
- [x] Hold Right < 500ms released in cursor mode (Password) → short
press, advances cursor 1
- [x] No continuous repeat when holding Left or Right in cursor mode

### Caret Visual in Toggle
- [x] In toggle: caret "I" visible at saved cursor position
- [x] Character under cursor visible (no gap) in password not-visible
mode
- [x] Character under cursor visible in password visible mode
- [x] `[abc]`/`[***]` label with inverted selection in toggle

### Contextual Tips
- [x] `"Tips:"` header centered above contextual hints
- [x] Single tip → `"Tips:"` + one line
- [x] Multiple tips → `"Tips:"` + multiple lines, all centered as block
- [x] No tips shown when not applicable (e.g., ABC with empty text and
non-URL)
- [x] `"UPPERCASE"` shown when shift OFF
- [x] `"lowercase"` shown when shift ON
- [x] `"secondary char"` shown for InputType::Url

### Hint Phases
- [x] 2× DEL → Phase 1 appears ("Hold UP to edit entry")
- [x] Phase 1 auto-hides after 4s
- [x] Phase 2 appears when entering cursor mode ("Press < or > to move
cursor")
- [x] Phase 2 shows "Hold > then press [abc] to show password" when
`!passwordVisible`
- [x] Phase 2 shows "Hold > then press [***] to hide password" when
`passwordVisible`
- [x] Phase 2 shows "Press < to return to cursor position" when in
toggle
- [x] Phase 2 disappears when exiting cursor mode

### Long-Press `InputType::Url` Behavior
- [x] Hold SELECT on letter rows (rows 1+) with InputType::Url → same
character as short press
- [x] Hold SELECT on row 0 with InputType::Url → secondary character
works normally

### Number Row Reorder
- [x] Number row order: 1-9, 0 left to right
- [x] `(` and `)` are adjacent (positions 8 and 9) via secondary labels
- [x] Long-press on row 0 returns correct secondary symbols in new order
- [x] SYM row 1: `(` and `)` also adjacent (positions 8 and 9)

### Block Cursor Alignment
- [x] Block cursor correctly positioned for consecutive spaces (kerning
offset applied)
- [x] Block cursor correctly positioned for mixed characters (letters,
numbers, symbols)
- [x] Block width minimum 6px for narrow characters (space) — visible as
block, not thin line
- [x] Password hidden: 3-part drawing prevents block overflow onto `*`
characters
- [x] Password visible: block post-loop draws correctly on continuous
text (no 3-part needed)
- [x] End-of-text block: thin 6px block at correct position

### Integration
- [x] WiFi password entry (connect to network)
- [ ] KOReader username, password, and sync server URL
- [ ] Calibre OPDS URL, username, and password
- [ ] Calibre OPDS URL: empty → opens with "https://" prefilled
- [ ] Calibre OPDS URL: type "http://" or "https://" only → saved as
empty
- [ ] Calibre OPDS URL: type full URL → saved correctly
- [ ] Calibre OPDS URL: existing URL → opens with existing URL (not
"https://" prefill)
2026-04-18 16:12:56 -05:00
Justin Mitchell fedcb2f53d fix: boot looping when opening large XTC files (#1648)
Opening XTC files with a high page count (e.g. *The Magic Mountain* at
4,187 pages) causes an immediate `abort()` crash and reboot loop. The
device becomes unusable until the book is removed from the SD card.

**Crash log:**
```
abort() was called at 0x4214a5fb on core 0
```

### Root cause

During `XtcParser::open()`, the parser calls
`m_pageTable.resize(pageCount)` to load the entire page table into RAM.
Each `PageInfo` entry is 16 bytes, so:

- 4,187 pages x 16 bytes = **66,992 bytes (~65KB)** as a single
contiguous heap allocation

On the ESP32-C3 with ~380KB total RAM (no PSRAM), this allocation fails
after firmware, fonts, and the activity system are already loaded.
Because the firmware is compiled with `-fno-exceptions`, the failed
`new` inside `std::vector::resize()` calls `abort()` instead of
throwing.

This affects any XTC file with roughly 3,000+ pages, depending on heap
state at the time of loading.

## Solution

Replace the bulk page table allocation with on-demand reads from the SD
card. Instead of loading all page table entries into a vector at file
open, we now:

1. Read only the **first** page table entry at open time (to get default
page dimensions)
2. Read a **single** 16-byte entry from the SD card each time a page is
loaded

This reduces page table memory usage from `pageCount * 16` bytes to
**zero bytes**, regardless of how many pages the file contains.

### Changes

| File | What changed |
|------|-------------|
| `XtcParser.h` | Removed `std::vector<PageInfo> m_pageTable`. Added
`readPageTableEntry()` for on-demand reads. |
| `XtcParser.cpp` | Replaced `readPageTable()` with
`readFirstPageInfo()`. Updated `getPageInfo()`, `loadPage()`, and
`loadPageStreaming()` to seek and read individual entries from the file.
|

## Trade-offs

### Performance

Each page turn now requires one additional SD card seek + 16-byte read
to look up the page table entry before reading the page data itself.

- SD card sequential read latency: ~0.1-0.5ms for a 16-byte read
- E-ink full display refresh: ~1,000-2,000ms

I personally can't see any performance difference while reading and the
trade off of not boot looping seems to make this well worth it.

### Memory

| Metric | Before | After |
|--------|--------|-------|
| Page table RAM (4,187 pages) | ~65KB | 0 bytes |
| Page table RAM (1,000 pages) | ~16KB | 0 bytes |
| Page table RAM (max 65,535 pages) | ~1MB (impossible) | 0 bytes |
2026-04-18 14:22:34 -05:00
Zach Nelson a888978f95 chore: Clarify X3 RTC in SCOPE.md (#1687)
## Summary

Clarify that X3 device has a reliable RTC chip, so clock feature can be
in-scope depending on device.

---

### 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-04-17 18:39:06 -05:00
Jensen Kuras 23f60a3407 chore: Updating sleep screen dimensions for X3 (#1688)
## Summary

Updating the docs so it has the accurate dimensions for sleep screens on
the X3 and the X4.

---

### 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-04-17 18:09:21 -05:00
2c5a47f9d7 refactor: change ukrainian translation to adaptation (#1684)
## Summary

* **What is the goal of this PR?** 
Make adaptation instead of pure translation.

* **What changes are included?**
Fix issues where text could not fit in line.
Fix didn't cover `KOReader` settings

**Additional Reviever**
@mirus-ua

---

### 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: Kym_Adnriy <kym_andr@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-17 14:29:16 -05:00
Zach Nelson ce1756e36f refactor: Added shared XML parser teardown helper (#1438)
## Summary

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

Added `destroyXmlParser()` helper to replace the repeated 4-line parser
cleanup block (stop, clear callbacks, free, null) that was copyied
across 6 XML parser files.

---

### 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-04-16 19:42:05 -05:00
Jan IvanovandJan Ivanov 3b12c083bc fix: make footnotes consider orientation for gutters (#1665)
## Summary

* **What is the goal of this PR?** 
Noticed that the footnotes selection screen does not add proper margins
to accommodate screen orientation
 
* **What changes are included?**
Copied some code over from `EpubReaderChapterSelectionActivity` to
calculate the proper margins in `CW` and `Inverted` orientation

| Before | After |
|--------|--------|
| <img width="578" height="435" alt="image"
src="https://github.com/user-attachments/assets/0518a0c6-13d2-48a1-9283-90c83861e4c2"
/> | <img width="578" height="435" alt="image"
src="https://github.com/user-attachments/assets/ac34365c-72d0-4f07-85a6-17e966b28909"
/> |
| <img width="328" height="435" alt="image"
src="https://github.com/user-attachments/assets/0614f19b-1000-4efe-8ef9-b533d2763a53"
/> | <img width="328" height="435" alt="image"
src="https://github.com/user-attachments/assets/ce9add2f-88e8-4032-a59c-efb55f366604"
/> |

---

### 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: Jan Ivanov <jan.ivanov@sirma.com>
2026-04-17 01:37:41 +03:00
zgredexandPatryk Radtke c4f5c8e931 fix: prevent wallpaper clustering with 16-entry recency buffer (#1606)
## Problem

Custom sleep wallpapers feel repetitive — the same image appearing
multiple times in a short session. Only the single most-recently-shown
index was stored (`uint8_t lastSleepImage`), so on collections of 3–5
images, two-pick cycles were common. On larger collections, any image
could reappear within the next few picks.

## Solution

Add a 16-entry circular recency buffer to `CrossPointState` that
excludes recently shown wallpapers from selection.

**Recency buffer** (`recentSleepImages[16]`, `recentSleepPos`,
`recentSleepFill` — 34 bytes DRAM):
- Tracks the last 16 shown wallpaper indices
- On each pick, rerolls up to 20 times if the candidate was recently
shown
- Window auto-shrinks to `numFiles - 1` for small collections
(guarantees a non-repeat is always possible)
- `isRecentSleep()` clamps to `recentSleepFill` to avoid false positives
on unwritten buffer slots
- State persisted to `state.json` so the buffer survives sleep/wake
cycles

**Migration**:
- Binary (`state.bin`): old `lastSleepImage` field seeded into the new
buffer if valid
- JSON (`state.json`): legacy `lastSleepImage` key detected and seeded
into the buffer when upgrading from older firmware

## Index type fix

`randomFileIndex` upgraded from `uint8_t` to `uint16_t` — silently
truncated for collections larger than 255 wallpapers.

## Repeat probability: before vs after

Chance of seeing a recently-shown image on the next pick. After:
`(numFiles - 16) / numFiles` once collection exceeds the window; 0%
while ≤17.

| Collection | Before | After |
|---|---|---|
| 3 | 50% | 0% |
| 5 | 75% | 0% |
| 10 | 89% | 0% |
| 17 | 94% | 0% |
| 18 | 94% | 6% |
| 20 | 95% | 20% |
| 25 | 96% | 36% |
| 30 | 97% | 47% |
| 50 | 98% | 68% |
| 100 | 99% | 84% |

## Memory impact

| Addition | Size |
|---|---|
| `recentSleepImages[16]` | 32 bytes DRAM |
| `recentSleepPos` + `recentSleepFill` | 2 bytes DRAM |
| **Total** | **34 bytes DRAM** |

## Files changed

- `src/CrossPointState.h` — recency buffer fields + `isRecentSleep()` /
`pushRecentSleep()` declarations
- `src/CrossPointState.cpp` — `isRecentSleep()` / `pushRecentSleep()`
implementations + binary migration path
- `src/JsonSettingsIO.cpp` — JSON serialisation of buffer state + JSON
migration
- `src/activities/boot_sleep/SleepActivity.cpp` — retry loop with
recency check

Co-authored-by: Patryk Radtke <patryk@Patryks-MacBook-Pro.local>
2026-04-17 00:05:21 +03:00
jpirnay 0c5dee3c62 refactor: Refactor drawArc / fillArc for faster execution (#1540)
## Summary

* **What is the goal of this PR?** Replace the o(r^2) routines with a
o(r) scanline logic - will make fillArc roughly 50% faster and drawArc
roughly 5x faster. Still probably unnoticeable.
* **What changes are included?**

## Additional Context


---

### 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-04-16 22:57:03 +03:00
Jon Vexler 81ae9dd779 feat: smooth battery percentage for x4 (#1635)
## Summary

Battery percentage is calculated from the voltage, which is not totally
stable. We smooth the battery percentage using a moving average.

## Additional Context
issue discussion:
https://github.com/crosspoint-reader/crosspoint-reader/issues/1444

---

### 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-04-16 00:28:43 -05:00
jpirnay 40e4c96906 refactor: replace picojpeg with JPEGDEC for cover art conversion (#1517)
## Summary

- Removes the vendored `picojpeg` library and rewrites
`JpegToBmpConverter` to use the already-present `JPEGDEC` (bitbank2)
dependency
- Eliminates the redundancy of having two JPEG decoders in the firmware
- All BMP output (headers, fixed-point scaling, Atkinson/Floyd-Steinberg
dithering) is identical to before — cached cover BMPs are unaffected

## Size impact

| | Before | After | Delta |
|---|---|---|---|
| Flash | 5,754,089 bytes (87.8%) | 5,744,777 bytes (87.7%) | **−9,312
bytes** |
| RAM | 95,212 bytes (29.1%) | 92,852 bytes (28.3%) | **−2,360 bytes** |

## Implementation notes

- `bmpDrawCallback` receives MCU-sized blocks from JPEGDEC (up to 16
rows × MCU-width), accumulates them into a pre-allocated `mcuBuf`, and
applies the same scaling + dithering logic once each MCU row is complete
- File I/O uses a file-scope static `FsFile*` (safe in single-threaded
embedded context) via JPEGDEC's open/read/seek callbacks — same pattern
as `JpegToFramebufferConverter`
- Added a 52 KB free-heap guard before allocating the JPEGDEC object
(~17 KB)
- `lib/picojpeg/` deleted (2,087 lines of C removed)

## Test plan

- [ ] Build compiles without warnings
- [ ] Cover art BMP cache regenerates correctly for EPUB books
- [ ] Home screen thumbnails (1-bit BMP path) render correctly
- [ ] Custom-size thumbnails (`jpegFileToBmpStreamWithSize`) render
correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-15 23:20:24 -05:00
Stefan Blixten Karlsson 80772ff6b8 fix: footnote link text (#1666)
## Summary

Previouls where ALL whitespace (and square brackes) removed from the
footnote link text, however some link texts are multiworded, like "`turn
to 252`" which were truncated into "`turnto252`", (an example from the
first book "Flight from the Dark" of the Lone Wolf book series by Joe
Dever, see [link](https://www.projectaon.org/en/Main/FlightFromTheDark))

* This change will only remove whitespaces from the beginning and end of
the string
so "` [ 12 ] `" will become "`12`" just like before, and "` turn to 252
`" will become "`turn to 252`".

---

### 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-04-15 21:47:10 -05:00
Stefan Blixten Karlsson 57fc6555f2 fix: missing swedish translations (#1667)
## Summary

* Add missing swedish translations

---

### 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-04-15 19:05:09 -05:00
jpirnay ed54f97909 fix: Fix ghosting on exit of BMPViewer (#1432)
## Summary

* **What is the goal of this PR?** After displaying an image via the
filebrowser ghosting artifacts remained on the screen
* **What changes are included?**

## 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-04-16 00:52:29 +02:00
jpirnay 45cd00889e chore: clang-format.fix.ps1 script: Add .venv to list of path exclusions (#1515)
## Summary

* **What is the goal of this PR?** Dont format files in .venv directory
* **What changes are included?**

## 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? _**< YES | PARTIALLY | NO
>**_
2026-04-14 18:50:37 -05:00
Егор Мартынов cced77783f feat: add orientation-aware popups for reader activities (#1428)
## Summary

Make popups (like "Going to sleep") respect the current screen
orientation when shown from reader activities.

**What changes are included?**

- Apply reader orientation in SleepActivity before showing popup when
`lastSleepFromReader` is true
- Make popup Y-position proportional to screen height (7.5% for
BaseTheme, 16.5% for LyraTheme) instead of hardcoded pixel values,
ensuring correct positioning in both portrait and landscape modes.
- Add `isReaderActivity()` override to all reader sub-screens (menu,
chapter selection, percent selection, footnotes, QR display, KOReader
sync), so sleep popups rotate correctly when entering sleep from any
reader context.

## Additional Context

<img
src="https://github.com/user-attachments/assets/47d88c2c-ffc5-41a7-b3f2-af272ea0150e"
width="400" height="240">

---

### 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 Opus
4.5)
2026-04-14 17:21:55 -05:00
Zach Nelson 1bd7a1de67 refactor: Deduplicate battery drawing code and fix Lyra charging indicator (#1437)
## Summary

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

Following up on #1427:
- Extracted shared battery drawing logic, including lightning bolt, to
reduce duplication.
- In Lyra with segmented battery the lightning bolt was hard to see, so
when charging Lyra now uses a solid battery fill.

---

### 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-04-14 17:05:06 -05:00
Zach Nelson 23aad213fc refactor: Removed redundant FsFile close() calls (#1434)
## Summary

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

`DESTRUCTOR_CLOSES_FILE=1` is set in platformio.ini, which makes SdFat's
FsBaseFile destructor call close() automatically when a file goes out of
scope.

Three categories of file close calls remain untouched:
1. Close before Storage.remove() on the same path: ScreenshotUtil.cpp
closes the file before deleting it on write error. The remove might fail
if the file is still open.
2. Close before reopening the same variable: Epub.cpp writes a temp
NCX/nav file, closes it, then reopens it for reading. The
RecentBooksStore.cpp close before saveToFile() is the same pattern, it
rewrites the same file.
3. Close on member variables: BookMetadataCache.cpp (bookFile,
spineFile, tocFile), Section.cpp (file), XtcParser.cpp (m_file),
ZipFile.cpp (file). These persist beyond any single function scope, so
the destructor timing doesn't match the intended close point.

---

### 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-04-14 16:41:05 -05:00
Zach NelsonandUri Tauber 075ad7d021 fix: Use font metrics for combining mark positioning (#1310)
## Summary

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

Combining diacritical marks (U+0300–U+036F) were positioned using a
heuristic that centered them at the midpoint of the base glyph's
**advance width**. This worked acceptably for Bookerly but produced
visibly off-center marks for Noto Sans due to a fundamental difference
in how the two fonts design their combining mark metrics.

Builds on the work of #1037.

## The problem

The two built-in body fonts encode combining mark `left` offsets with
very different conventions:

| Mark | Bookerly `left` | Noto Sans `left` |
|---|---|---|
| U+0301 (acute) | -2 | -10 |
| U+0300 (grave) | -5 | -15 |
| U+0302 (circumflex) | -5 | -5 |
| U+0323 (dot below) | -2 | -11 |

Noto Sans uses large negative `left` values because its marks are
designed for placement at the post-advance cursor position, with `left`
pulling the bitmap back over the base glyph. Bookerly uses small offsets
because its marks sit closer to the glyph origin. The old `advance/2`
centering split the difference poorly — it happened to land close to
correct for Bookerly but placed Noto Sans marks roughly 6px left of
center on a typical lowercase letter.

There was also a bug in the vertical gap heuristic. It unconditionally
computed a `raiseBy` value to prevent above-baseline marks from
colliding with tall base glyphs, but it applied the same logic to
**below-baseline** marks like cedilla (U+0327), dot below (U+0323), and
ogonek (U+0328). For those marks, the math produced a large positive
raise (e.g., 24px for dot-below on 'a'), launching them above the
x-height instead of keeping them below the baseline.

## The fix

**Horizontal positioning**: Instead of centering at `advance/2`, align
the mark bitmap's visual midpoint directly over the base glyph bitmap's
visual midpoint. This uses the base glyph's actual `left` and `width`
rather than its advance width, producing correct results regardless of
how the font encodes its mark offsets.

**Vertical positioning**: The raise heuristic now checks `markTop -
markHeight > 0` and skips below-baseline marks entirely, leaving them at
their font-designed position.

**Consolidation**: The shared math is extracted into two `constexpr`
helpers (`combiningMark::centerOver` and
`combiningMark::raiseAboveBase`) in `EpdFontData.h`, eliminating the
previously triplicated inline calculations across `drawText`,
`drawTextRotated90CW`, and `getTextBounds`. The `MIN_COMBINING_GAP_PX`
constant is also centralized as `combiningMark::MIN_GAP_PX`.

| Before | After |
| -- | -- |
| <img
src="https://github.com/user-attachments/files/25752257/before-noto.bmp"
width="250" /> | <img
src="https://github.com/user-attachments/files/25752258/after-noto.bmp"
width="250" /> |
| <img
src="https://github.com/user-attachments/files/25752259/before-bookerly.bmp"
width="250" /> | <img
src="https://github.com/user-attachments/files/25752260/after-bookerly.bmp"
width="250" /> |

---

### 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 to analyze
differences between Noto Sans and Bookerly font metrics**_

---------

Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com>
2026-04-14 14:35:50 -05:00
Xuan-Son Nguyen 243ae8b408 feat: show crash reason on boot (#1453)
## Summary

If the system reboots from crash, display the reason and tell user to
include `crash_report.txt` file.

To test this, simply add an `assert(false)` somewhere inside the code.


![screenshot-74453.bmp](https://github.com/user-attachments/files/26160373/screenshot-74453.bmp)

---

### AI Usage

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

Did you use AI tools to help write this code? **NO**
2026-04-13 15:23:45 -05:00
zgredexandPatryk Radtke 4e9c7a787f feat: show full path bar in file browser (#1411)
![obraz](https://github.com/user-attachments/assets/f848a381-7c45-4724-912b-56168791bcf3)


## Summary

Adds a full path display at the bottom of the file browser with a
separator line matching the header style. Path uses the small font,
left-truncates to always show the deepest folder when path is too long.
Toggleable via Settings > System > Show Full Path (default on).

### AI Usage

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

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

---------

Co-authored-by: Patryk Radtke <patryk@Patryks-MacBook-Pro.local>
2026-04-13 15:12:49 -05:00
Russell Neches cc23aaa9c2 docs: Update README with firmware flashing instructions (#1654)
Added instructions for flashing firmware using esptool.

## Summary

Instructions for flashing a specific firmware from the terminal using
`esptool.py`.

## Additional Context

Not everyone has Chrome or Chromium installed. An alternative to the
web-based firmware utility shouldn't be hard to find.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_ : `dmesg`
command analog for MacOS
2026-04-13 13:50:31 -05:00
Diana 05f8e6e12d fix: webserver /delete API backward compatibility (#1475)
## Summary

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

This fixes #682 by restoring API compatibility for external users, eg
deleting on-device books in bulk via calibre using the crosspoint plugin
works now.

* **What changes are included?**

The `/delete` webserver API now accepts the old `path` argument and maps
it to a JSON array element. It is made an error to provide both
arguments.

## Additional Context

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

I have compiled, flashed, and tested this on master at commit
0245972132. Before this change, calibre
cannot delete books, either individually or in bulk.

After this change, it can do so successfully.

---

### 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-04-13 08:59:07 +03:00
Ian Chasse 405ce0c3c8 feat: Rework "Cover + Custom" sleep screens to show covers only when currently reading (#1256)
## Summary

The cover/custom setting was a bit misleading to me. Usually after you
start reading a book you never see your customs again unless a cover
fails to render for some reason. With this, you can easily show your
custom images by just sleeping from the menus instead of the reader

## 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-04-12 21:03:23 -04:00
CSCMe 9bc5111c77 fix: increase loadable epub size (#1638)
## Summary

* **What is the goal of this PR?** Slightly increase the OOM limit when
loading epubs
* **What changes are included?** Switched vectors for parsing epubs to
deques, allowing use of more memory

## Additional Context

* Increases loadable epub size from 2000+ chapter/ToC entries loadable
to 5000+ chapter/ToC entries
* #1574, but without the complicated stuff

---

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

### Testing
| Commit | Book | Time |
|----------|------|-------|
| 83cd96bc2f | 1000.epub | ~30 sec |
| 83cd96bc2f | 5000.epub | crash |
| PR | 1000.epub | ~29 sec |
| PR | 5000.epub | ~2 min 20 sec |

=> No actual loading time regressions

[tested_epubs.zip](https://github.com/user-attachments/files/26645243/tested_epubs.zip)
2026-04-12 18:41:28 -05:00
bdeshi 9c11f3e4a2 feat: enable manual screen refresh on power button short press (#1626)
## Summary

Adds an option to allow manual screen refresh on power button short
press.

## Additional Context

there's an option to refresh the screen after a set number of pages. but
sometimes a manual refresh is needed to clear up stale ink pixels. this
works everywhere both in and out of reading mode.

resolves #550 

---

### 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**_, to
understand the project structure.
2026-04-12 22:31:26 +03:00
fa2a3d2539 feat: add OPDS search support & next/prev page navigation (#1462)
## Summary

**What is the goal of this PR?**
Adds OPDS search support, allowing users to search a catalog directly
from the book browser when the server exposes an OpenSearch template.

**What changes are included?**
- `OpdsParser`: parses the OpenSearch template URL from feed-level
`<link rel="search">` elements and exposes it via `getSearchTemplate()`
- `OpdsBookBrowserActivity`: fetches and stores the search template
after each feed load; shows a Search hint on the Left button when a
template is available; launches the existing `KeyboardEntryActivity` for
query input; URL-encodes the query and fetches the result feed
- Absolute search result URLs are handled correctly in `fetchFeed`
(skips prepending the server base URL)
- A `consumeConfirm` guard prevents the Confirm release that submits the
keyboard from immediately triggering a book download on the first
browsing frame after search results load

## Additional Context

- Search is silently unavailable if the server does not advertise an
OpenSearch template — no UI change in that case
- Tested against a Calibre-Web OPDS endpoint which exposes `<link
rel="search" type="application/opensearchdescription+xml">`
- The inline URL encoder in `performSearch` was necessary as
`StringUtils` has no such utility; worth considering extracting to
`StringUtils` in a follow-up
- No new dependencies introduced

---

### 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: kira <rammah@tuta.io>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
2026-04-12 12:24:27 -04:00
Mraulio 5c12f2f01e fix: avoid skipping chapter after screenshot (#1625)
## Summary

* **What is the goal of this PR?** 
Fixes skipping chapter when it's enabled in Settings and you take a
Screenshot.
* **What changes are included?**
A simple return if Power and Down were released before the skipChapter.

## Additional Context

* There is an Issue related #1595.
* Checked that it kept the Power button funcionality for Next page and
Suspend.

---

### 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-04-11 20:28:00 +03:00
Jan IvanovandJan Ivanov 8d6b35b8e7 fix: back navigation from BMPViewer (#1597)
## Summary

This fixes navigating back from the BMP Viewer to the FileBrowser which
was broken when moving to the new ActivityManager

This is fixed by making FileBrowserActivity able to take a full file
path on enter and splitting the basePath and fileName from it and
navigating to the correct place.

fixes:
https://github.com/crosspoint-reader/crosspoint-reader/issues/1553

duplicates:
https://github.com/crosspoint-reader/crosspoint-reader/pull/910 to some
extend but mine has the file cursor at the correct file instead of the
first one in the folder

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

---------

Co-authored-by: Jan Ivanov <jan.ivanov@sirma.com>
2026-04-11 20:20:46 +03:00
Justin Mitchell 83cd96bc2f fix: Wild pointer crash in JPEGDEC MCU_SKIP handling (#1627)
Adds a PlatformIO pre-build script to patch JPEGDEC library. When
decoding progressive JPEGs with AC coefficients, MCU_SKIP (-8) causes
array index 0xFFFFF8, creating a wild pointer ~33MB past the sMCUs
array. The patch redirects pMCU to sMCUs[0] when MCU_SKIP is active,
preventing store-access faults while maintaining correct behavior for
JPEG_SCALE_EIGHTH decoding. Devices with larger framebuffers (like the
x3) (792×528 = 52,272 bytes vs 800×480 = 48,000 bytes) have less free
heap, shifting the allocation and changing where the wild pointer lands.

Commit 8628297 guarded the DC coefficient write (pMCU[0]) with if (iMCU
>= 0), which prevents crashes for progressive JPEGs whose first scan is
DC-only (iScanEnd == 0). However, if the first scan includes AC
coefficients (iScanEnd > 0), the AC decode loop still writes through the
wild pointer and crashes.
2026-04-10 21:13:59 +01:00
14ec53a335 feat: Added Slovenian translation (#1551)
Adde

## Summary

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

Adding Slovenian translation

* **What changes are included?**

Just new slovenian.yaml file with translated strings

## Additional Context

I kindly ask to include it in next release.

---

### 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: Andrej Kralj <andrej.kralj@gmail.com>
Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com>
2026-04-10 11:00:32 -05:00
Zach Nelson 5ba85290ab refactor: Deduplicated BMP header writing in Xtc (#1439)
## Summary

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

Replaced manual 1-bit BMP header logic in `Xtc::generateCoverBmp()` and
`Xtc::generateThumbBmp()` with calls to the existing `createBmpHeader()`
utility. Added a `BmpRowOrder` enum and param to support the top-down
row order of XTC cover images.

---

### 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-04-10 00:35:43 +03:00
Uri Tauber 104f391a29 fix: two small memory leaks (#1628)
## Summary

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

Fix two issues pointed out by `CodeRabbit` in #1433:

1. Free the output buffer in `readFileToMemory` on early error paths
(prevents memory leaks).
2. Check `out.write()` return value in the STORED path (same as in the
DEFLATED path).

I can confirm both issues were real (not hallucinations). Both fixes are
minimal — no behavior change on success.

---

### AI Usage

Did you use AI tools to help write this code? _**< NO >**_
2026-04-09 22:16:19 +03:00
Zach Nelson b3b43bb373 refactor: RAII scoped open/close for ZipFile (#1433)
## Summary

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

Added `ScopedOpenClose` RAII guard to eliminate repetitive
`wasOpen`/`close()` boilerplate across all ZipFile methods.

---

### 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-04-09 21:28:31 +03:00
CaptainFrito 5349e81723 feat: Display file extensions in File Browser (#1019)
## Summary

![IMG_8136
Medium](https://github.com/user-attachments/assets/e33a8a71-4126-4bdc-8d3a-32132acba712)
![IMG_8137
Medium](https://github.com/user-attachments/assets/2289fbee-71e8-446c-ad28-9dd4a9a21396)
![IMG_8138
Medium](https://github.com/user-attachments/assets/0e912f72-9be1-4708-a909-4dab192d1aea)


## Additional Context

Do we want a setting to toggle this?

---

### AI Usage

Did you use AI tools to help write this code? _**NO**_
2026-04-09 09:54:46 -05:00
Justinian 825ef56ad8 feat: X3 grayscale antialiasing improvements (#1607)
## Summary
Improves text antialiasing quality on the Xteink X3 (SSD1677) display to
bring it closer to X4 rendering quality. Addresses white lines through
letter strokes and ghosting artifacts during page turns and screen
transitions.

## Changes

### Display Driver (open-x4-sdk)
- Dedicated X3 grayscale LUTs with tuned VDL drive strengths for dark
gray (2 time units) and light gray (3 time units), with active GND hold
on non-gray transitions to prevent floating source crosstalk
- Tight scan timing: TP2/TP3 reduced to 1 (total gate-on 7 units vs 17),
minimizing parasitic charge leakage that caused white lines through
letter strokes
- Fast diff BB reinforcement: Added mild VDH reinforcing pulse to
lut_x3_bb_full so black pixels are actively driven during differential
refreshes, clearing gray residue/ghosting
- displayGrayBuffer() updated to use the dedicated gray LUT bank instead
of full refresh LUTs for X3

### Rendering Pipeline
- Re-enabled light gray rendering for X3 text and images, now safe with
dedicated gray LUTs providing proper 4-level gray
- Removed isLightGrayRestricted() gating that was limiting X3 to 3-level
gray
- Runtime display dimensions in DirectPixelWriter and ScreenshotUtil,
replaced hardcoded constants with runtime getters to support X3 792x528
resolution

### Note
The open-x4-sdk submodule references a commit on
juicecultus/community-sdk. A corresponding PR to
open-x4-epaper/community-sdk should be merged first so the submodule ref
resolves on upstream.

## Testing
Tested on physical X3 hardware. White lines through letters
significantly reduced, in-book ghosting improved via BB reinforcement,
antialiasing visually closer to X4 quality.

## AI Disclosure
Yes, AI was used to assist with the development of these changes.

---------
2026-04-08 21:58:59 -04:00
jpirnay ed0811c898 fix: Fix failing very first wifi connection attempt (#1521)
## Summary

* **What is the goal of this PR?** The very first Wifi connection
attempt with saved credentials failed, subsequent attempts succeeded.
This PR fixes the first-attempt-issue.
* **What changes are included?**

## Additional Context
Claude analysis for WifiSelectionActivity

In attemptConnection() ,there's no WiFi.disconnect() before WiFi.begin()
— unlike the scan path earlier which does do a disconnect first.

The root cause on ESP32 is the built-in auto-connect feature: the ESP32
WiFi stack saves credentials to NVS flash and automatically starts
trying to connect on boot before your application code runs. When your
attemptConnection() then calls WiFi.begin(), the stack is already in a
transitional CONNECTING state, and the new begin() call either gets
ignored or collides with the in-progress attempt.

The fix is to add WiFi.disconnect(true) + a short delay in
attemptConnection() before calling WiFi.begin(), and optionally call
WiFi.persistent(false) to stop the ESP32 from auto-connecting on its
own.

---

### 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-04-08 21:26:12 +03:00
nscheung d29b8ee2f9 feat: Adjust Navigation at End of Book (#1425)
## Summary

* **What is the goal of this PR? (e.g., Implements the new feature for
file uploading.)**
Currently, pressing forward at the end of a book loops back to the last
page. This change will instead sends you to the home page instead.

* **What changes are included?**
Applies the change to the three supported format: EPUB, XTC, TXT

## Additional Context

* This is more of a QOL improvement than a new feature. If there's
interest, we could extend this to track a completed state for ebooks.

---

### 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-04-08 21:09:28 +03:00
martin brook b898d53f7b chore: drop JPEGDEC patch in favour of upstream fix (#1465)
## Summary

The progressive JPEG fixes (AC table skip, MCU_SKIP guard) from PR #1136
have been fixed upstream in bitbank2/JPEGDEC@8628297. Pin to that commit
and remove the pre-build patch script.

## Additional Context

Tested on Strange Pictures epub

---

### 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-04-08 09:22:25 +01:00
CSCMe c656673b9a refactor: logPrintf and predefined log level strings (#1546)
## Summary

* More consistent string formatting in logPrintf
* Moved [ and ] from log level strings into new format string
* Behaviour change: Early exit if user string format fails

## Additional Context

* Should not have performance implications, debug monitor etc work as
before
* Clamp may be unnecessary due to information snprintf currently never
being able to exceed max buffer length, but not having it would bug me

---

### 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, to reason
about correctness**_
2026-04-07 22:25:08 -05:00
Zach Nelson 1398aeb1ed fix: Use differential rounding for consistent inter-glyph spacing (#1413)
## Summary

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

A tweak to the fixed-point x-advance and kerning calculations to ensure
that the spacing between any two glyphs is always calculated
consistently.

I noticed that sometimes I'd see common character pairs like "oo" more
than once on a page, and the distance between the two snapped to
different pixels depending on the running accumulated error for the line
of text.

This change uses a differential rounding approach where each glyph's
x-advance plus the kerning relative to the next glyph are combined in
fixed-point precision, then snapped to a pixel to draw the next glyph.
This results in a consistent inter-glyph spacing any time the same two
glyphs show up adjacent to each other, regardless of the accumulated
error across the line.

---

### 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-04-07 22:20:50 -05:00
Justin Mitchell 6cd19f5619 fix: epub images not rendering correctly on x3 (#1572)
Replace hardcoded DISPLAY_WIDTH, DISPLAY_HEIGHT, and DISPLAY_WIDTH_BYTES
constants with runtime values from display object to support multiple
device models (X3 and X4) with different screen dimensions.
2026-04-07 19:16:59 -05:00
Mirus cff3e12a0a fix: Update Ukrainian translations for footnotes (issue 1409) (#1585)
## Summary

* **What is the goal of this PR?** 
Solve the issue
https://github.com/crosspoint-reader/crosspoint-reader/issues/1409
* **What changes are included?**
Updated translation for footnotes

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

Did you use AI tools to help write this code? _**NO**_
2026-04-07 09:21:32 -05:00
Andrei Ignatev fa3c7d96a0 fix: correct Russian auto-turn translations (#1566)
## Summary

* **What is the goal of this PR?**
Fix inaccurate Russian UI text for the reader auto-turn feature and make
the affected Russian labels consistent with how adjacent UI strings are
formatted.

* **What changes are included?**
Updated Russian translations in `lib/I18n/translations/russian.yaml`:
- changed `Auto Turn` text from wording that implied screen rotation to
wording that means automatic page turning
- adjusted a few Russian prefix/separator strings to include spacing
where the UI concatenates labels with dynamic values

## Screenshots

| Before| After |
|--------|--------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/db416dd2-6174-4a46-bd3a-6f52d1cc01cb"
/>| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/dd9055e0-d4f1-459d-bd40-60fc4970d458"
/>|



---

### 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-04-07 09:21:12 -05:00
Zach Nelson f429f9035c refactor: Use default member initializers for JpegContext and PngContext (#1435)
## Summary

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

Replace verbose constructor initializer lists with in-class default
member initializers in JpegContext and PngContext

---

### 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-04-07 09:13:21 -05:00
Zach Nelson 11984f8fef refactor: Use C++20 'requires' in ActivityResult constructor (#1420)
## Summary

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

Replace SFINAE std::enable_if_t with a C++20 `requires` clause for
clearer constraint expression and better compiler diagnostics on
mismatch.

---

### 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-04-07 08:30:52 -05:00
Zach Nelson 1c13331189 fix: Support hyphenation for EPUBs using ISO 639-2 language codes (#1461)
## Summary

EPUBs that use ISO 639-2 three-letter language codes in their
`dc:language` metadata (e.g. `<dc:language>eng</dc:language>`) got no
hyphenation. The hyphenator registry only matched ISO 639-1 two-letter
codes (`"en"`, `"fr"`, etc.), so `"eng"` produced a null hyphenator and
every word in the book was treated as unhyphenatable.
Added a normalization step in `hyphenatorForLanguage` that maps ISO
639-2 codes (both bibliographic and terminological variants) to their
two-letter equivalents before the registry lookup.

Discovered via *Project Hail Mary* (Random House), which uses
`<dc:language>eng</dc:language>`.

---

### 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-04-07 00:30:24 +01:00
312 changed files with 132165 additions and 120056 deletions
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Run formatter from repository root regardless of current directory.
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "${REPO_ROOT}"
# Capture the files already staged for commit so we only re-stage those
# paths after formatting.
staged_files=()
while IFS= read -r -d '' file; do
staged_files+=("${file}")
done < <(git diff --cached --name-only -z --diff-filter=ACMR)
# Intentionally format all currently modified tracked C/C++ files.
# The helper handles no-op cases and exits 0 when nothing matches.
echo "Running clang-format fix before commit..."
./bin/clang-format-fix
# Ensure formatting changes are included in the pending commit without
# staging unrelated tracked modifications from other files in the
# working tree.
if ((${#staged_files[@]})); then
git add -- "${staged_files[@]}"
fi
+106
View File
@@ -0,0 +1,106 @@
name: Build & Publish SD Card Fonts
# Fonts change rarely — run manually when font sources or the conversion
# pipeline are updated. Publishes .cpfont files + fonts.json manifest as
# GitHub Release assets on the crosspoint-fonts repo so font releases don't
# clutter the firmware releases page.
#
# Requires a repository secret FONTS_REPO_TOKEN — a fine-grained PAT (or
# classic PAT) with contents:write permission on the target fonts repo.
on:
workflow_dispatch:
env:
FONTS_REPO: crosspoint-reader/crosspoint-fonts
jobs:
build-fonts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install font tools
run: pip install freetype-py fonttools pyyaml
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libfreetype6-dev
- name: Read version constants
id: versions
run: |
cd lib/EpdFont/scripts
echo "binary=$(python3 -c 'from cpfont_version import CPFONT_VERSION; print(CPFONT_VERSION)')" >> "$GITHUB_OUTPUT"
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 --verbose -j 1
- name: Flatten output for release assets
run: |
mkdir -p dist
find lib/EpdFont/scripts/output -name '*.cpfont' -exec cp {} dist/ \;
- name: Compute release tags
id: tags
env:
GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }}
run: |
BASE="sd-fonts-m${{ steps.versions.outputs.metadata }}-b${{ steps.versions.outputs.binary }}"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
# Find the highest existing revision for this m/b pair
LAST=$(gh release list --repo "${{ env.FONTS_REPO }}" \
--json tagName --jq \
'[.[] | select(.tagName | startswith("'"${BASE}-r"'")) | .tagName | split("-r")[1] | tonumber] | max // 0')
NEXT=$((LAST + 1))
echo "revision=$NEXT" >> "$GITHUB_OUTPUT"
echo "versioned=${BASE}-r${NEXT}" >> "$GITHUB_OUTPUT"
- name: Generate manifest
run: |
python3 scripts/generate-font-manifest.py \
--input dist \
--base-url "https://github.com/${{ env.FONTS_REPO }}/releases/download/${{ steps.tags.outputs.base }}/" \
--output dist/fonts.json \
--descriptions-from lib/EpdFont/scripts/sd-fonts.yaml
- name: Publish versioned release to fonts repo
env:
GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }}
run: |
VERSIONED="${{ steps.tags.outputs.versioned }}"
TITLE="SD Card Fonts (${VERSIONED#sd-fonts-})"
gh release create "$VERSIONED" dist/* \
--repo "${{ env.FONTS_REPO }}" \
--title "$TITLE" \
--notes "Pre-built \`.cpfont\` font files for CrossPoint Reader.
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."
- name: Update stable tag for device downloads
env:
GH_TOKEN: ${{ secrets.FONTS_REPO_TOKEN }}
run: |
BASE="${{ steps.tags.outputs.base }}"
# Delete the old stable release for this m/b pair (the versioned releases are kept)
gh release delete "$BASE" --repo "${{ env.FONTS_REPO }}" --yes 2>/dev/null || true
gh release create "$BASE" dist/* \
--repo "${{ env.FONTS_REPO }}" \
--title "SD Card Fonts (${BASE#sd-fonts-})" \
--notes "Current font build for manifest v${{ steps.versions.outputs.metadata }}, binary format v${{ steps.versions.outputs.binary }}. Devices with this firmware version download from this release.
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 > Manage fonts** on the device."
+4
View File
@@ -15,3 +15,7 @@ build
.history/
/.venv
*.local*
*.cpfont
lib/EpdFont/scripts/downloaded_fonts/
lib/EpdFont/scripts/instanced_fonts/
lib/EpdFont/scripts/output/
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "open-x4-sdk"]
path = open-x4-sdk
url = https://github.com/open-x4-epaper/community-sdk.git
url = https://github.com/crosspoint-reader/community-sdk.git
+15 -6
View File
@@ -104,8 +104,17 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
-DUSE_UTF8_LONG_NAMES=1 // SD card long filename support
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 // Avoid zlib name conflicts
-DXML_GE=0 // Disable XML general entities (security)
-DDESTRUCTOR_CLOSES_FILE=1 // FsFile destructor auto-closes (SdFat)
```
**DESTRUCTOR_CLOSES_FILE implications**:
- SdFat's `FsBaseFile` destructor calls `close()` automatically when the object goes out of scope
- **Do NOT add explicit `file.close()` calls** for local `FsFile` variables — the destructor handles it
- Explicit `close()` is still required in these cases:
1. **Close before delete**: Must close before `Storage.remove()` on the same path
2. **Close before reopen**: Must close before reopening the same `FsFile` variable (e.g., write then reopen for read, or rewrite the same path)
3. **Member variables**: `FsFile` members persist beyond any single function scope, so close at the intended release point (e.g., in `onExit()`)
**SINGLE_BUFFER_MODE implications**:
- Only ONE framebuffer exists (not double-buffered)
- Grayscale rendering requires temporary buffer allocation (`renderer.storeBwBuffer()`)
@@ -145,11 +154,11 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
FsFile file;
if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
// Read from file
file.close(); // Explicit close required
// No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
}
```
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`.
**Usage**: See example above. Uses `FsFile` (SdFat), NOT Arduino `File`. Do NOT add `file.close()` for local variables (see DESTRUCTOR_CLOSES_FILE above).
---
@@ -167,7 +176,7 @@ if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
### Memory Safety and RAII
* Smart Pointers: Prefer std::unique_ptr. Avoid std::shared_ptr (unnecessary atomic overhead for a single-core RISC-V).
* RAII: Use destructors for cleanup, but call file.close() or vTaskDelete() explicitly for deterministic resource release.
* RAII: Use destructors for cleanup. Call `vTaskDelete()` explicitly for deterministic task release. Do NOT call `file.close()` on local `FsFile` variables — `DESTRUCTOR_CLOSES_FILE=1` handles it at scope exit (see Critical Build Flags).
### ESP32-C3 Platform Pitfalls
@@ -376,13 +385,13 @@ void enterNewActivity(Activity* activity) {
- Activity navigation = `delete` old activity + `new` create next activity
- Any memory allocated in `onEnter()` MUST be freed in `onExit()`
- FreeRTOS tasks MUST be deleted in `onExit()` before activity destruction
- File handles MUST be closed in `onExit()`
- Member `FsFile` handles MUST be closed in `onExit()` (local `FsFile` variables auto-close via destructor)
**Activity Pattern**:
```cpp
void onEnter() { Activity::onEnter(); /* alloc: buffer, tasks */ render(); }
void loop() { mappedInput.update(); /* handle input */ }
void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::onExit(); }
void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Activity::onExit(); }
```
**Critical**: Free resources in reverse order. Delete tasks BEFORE activity destruction.
@@ -405,7 +414,7 @@ void onExit() { /* free: vTaskDelete, free buffer, close files */ Activity::on
**Source**: [src/main.cpp:40-115](../src/main.cpp)
**All fonts are loaded as global static objects** at firmware startup:
- Bookerly: 12, 14, 16, 18pt (4 styles each: regular, bold, italic, bold-italic)
- Noto Serif: 12, 14, 16, 18pt (4 styles each: regular, bold, italic, bold-italic)
- Noto Sans: 12, 14, 16, 18pt (4 styles each)
- OpenDyslexic: 8, 10, 12, 14pt (4 styles each)
- Ubuntu UI fonts: 10, 12pt (2 styles)
+177 -101
View File
@@ -1,104 +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 truely 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.
---
## 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):
```bash
pip install esptool
```
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:
```bash
esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 921600 write_flash 0x10000 /path/to/firmware.bin
```
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.
@@ -108,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.
@@ -117,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 goverance 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.
+6 -2
View File
@@ -27,6 +27,12 @@ usability over "swiss-army-knife" functionality.
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
without breaking focus.
* **Clock Display (device dependent):**
| Device | Scope |
| -- | -- |
| X3 | The X3 uses a dedicated DS3231 RTC, which maintains accurate time across sleep cycles and can be treated as a reliable wall clock. |
| X4 | The X4 relies on the ESP32-C3's internal RTC, which drifts significantly during deep sleep. NTP sync could correct this, with an appropriate user experience around connecting to the internet on wake or on demand. This causes some tension with the **Active Connectivity** section below, so please open a discussion about this UX if it's a feature you would find useful. |
### Out-of-Scope
@@ -43,8 +49,6 @@ usability over "swiss-army-knife" functionality.
*These features align with CrossPoint's goals but are impractical on the current hardware or produce poor UX.*
* **Clock Display:** The ESP32-C3's RTC drifts significantly during deep sleep; making the clock untrustworthy after any sleep cycle. NTP sync could help, but CrossPoint doesn't connect to the internet on every boot.
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text — resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
## 3. Idea Evaluation
+48 -5
View File
@@ -20,7 +20,9 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.2 Reader](#362-reader)
- [3.6.3 Controls](#363-controls)
- [3.6.4 System](#364-system)
- [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup)
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [3.7 Sleep Screen](#37-sleep-screen)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
@@ -155,7 +157,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
#### 3.6.2 Reader
- **Reader Font Family**: Choose the font used for reading:
- "Bookerly" (default) - Amazon's reading font
- "Noto Serif" (default) - Google's serif font
- "Noto Sans" - Google's sans-serif font
- "Open Dyslexic" - Font designed for readers with dyslexia
- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium" (default), "Large", or "X Large".
@@ -194,12 +196,52 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates.
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication.
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
- **Clear Reading Cache**: Clear the internal SD card cache.
- **Check for updates**: Check for Crosspoint firmware updates over WiFi.
- **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information).
#### 3.6.5 KOReader Sync Quick Setup
#### 3.6.5 OPDS Servers (Multiple Libraries)
CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs.
1. Open **Settings -> System -> OPDS Servers**.
2. Select **Add Server** to create a new entry, or select an existing server to edit it.
3. Configure these fields:
- **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog").
- **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`).
- **Username / Password**: Optional credentials for authenticated servers.
4. Use **Delete Server** inside a server entry to remove it.
Behavior notes:
- You can store up to 8 OPDS servers.
- OPDS authentication supports HTTP Basic auth. If you use Calibre Content Server with authentication enabled, set it to Basic (not Digest).
You can also manage OPDS servers from the web interface while in File Transfer mode:
1. Connect to the device web UI.
2. Open `http://<device-ip>/settings`.
3. Use the **OPDS Servers** card to add, edit, or delete entries.
For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds).
#### 3.6.6 Web Settings (WiFi + OPDS)
While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**.
1. On device: open **File Transfer** and connect to WiFi.
1. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password).
1. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
Behavior notes:
- Passwords are never shown back in the web UI after saving.
- Leaving Password blank while editing keeps the existing saved password unchanged.
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow.
#### 3.6.7 KOReader Sync Quick Setup
CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials.
@@ -336,7 +378,8 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
> [!TIP]
> For best results:
> - Use uncompressed BMP files with 24-bit color depth
> - Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
---
+7
View File
@@ -14,6 +14,7 @@ fi
set -euo pipefail
GIT_LS_FILES_FLAGS=""
# -g scopes formatting to tracked files currently modified in git status.
if [[ "${1:-}" == "-g" ]]; then
GIT_LS_FILES_FLAGS="--modified"
fi
@@ -21,6 +22,7 @@ fi
CLANG_FORMAT_VERSION_RAW="$(${CLANG_FORMAT_BIN} --version)"
CLANG_FORMAT_MAJOR="$(printf '%s\n' "${CLANG_FORMAT_VERSION_RAW}" | grep -oE '[0-9]+' | head -n1)"
# Guard against local binaries older than the repo formatting config.
if [[ -z "${CLANG_FORMAT_MAJOR}" || "${CLANG_FORMAT_MAJOR}" -lt 21 ]]; then
echo "Error: ${CLANG_FORMAT_BIN} is too old: ${CLANG_FORMAT_VERSION_RAW}"
echo "This repository's .clang-format requires clang-format 21 or newer."
@@ -37,9 +39,14 @@ fi
# --exclude-standard: ignores files in .gitignore
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
# Keep the no-match case non-fatal: grep returns 1 when no files match,
# which is expected when there are no modified C/C++ files.
set +o pipefail
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
| grep -E '\.(c|cpp|h|hpp)$' \
| grep -v -E '^lib/EpdFont/builtinFonts/' \
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
| grep -v -E '^lib/uzlib/' \
| xargs -r "${CLANG_FORMAT_BIN}" -style=file -i
# Restore strict pipeline failure handling for the rest of the script.
set -o pipefail
+1
View File
@@ -97,6 +97,7 @@ $exclude = @(
'lib\Epub\Epub\hyphenation\generated'
'lib\uzlib'
'.pio'
'.venv'
)
function Test-Excluded($fullPath) {
@@ -6,6 +6,7 @@ This page defines the expected local workflow before opening a pull request.
- Fork the repository to your own GitHub account
- Clone your fork locally and add the upstream repository if needed
- Enable repo hooks once per clone: `git config core.hooksPath .githooks && chmod +x .githooks/pre-commit`
- Branch from `master`
- Keep each PR focused on one fix or feature area
+7
View File
@@ -53,6 +53,13 @@ If you already cloned without submodules:
git submodule update --init --recursive
```
Enable the repository-managed Git hooks (required once per clone):
```sh
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
```
## Build
```sh
+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

+104
View File
@@ -0,0 +1,104 @@
# SD Card Fonts
CrossPoint supports loading additional fonts from the SD card, including fonts
with extended Unicode coverage (CJK, Cyrillic, Greek, etc.).
## Installing Fonts
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 > Manage Fonts**
3. Browse available font families and tap to download
4. Downloaded fonts appear immediately in **Settings > Reader > Font Family**
### Option 2: Upload via web browser
1. Connect your CrossPoint reader to WiFi
2. Open the web interface in your browser (shown on the WiFi screen)
3. Navigate to the **Fonts** tab
4. Upload `.cpfont` files using the upload form
### Option 3: Manual SD card copy
1. Download font files from the
[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/
├── .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
The current list of pre-built fonts is maintained in the
[crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts).
## Converting Custom Fonts
To convert your own TrueType/OpenType fonts:
### Prerequisites
pip install freetype-py fonttools
### Single font (one style)
python3 lib/EpdFont/scripts/fontconvert_sdcard.py \
MyFont-Regular.ttf \
--intervals latin-ext \
--sizes 12,14,16,18 \
--style regular \
--name MyFont \
--output-dir ./MyFont/
### Multi-style font
python3 lib/EpdFont/scripts/fontconvert_sdcard.py \
--regular MyFont-Regular.ttf \
--bold MyFont-Bold.ttf \
--italic MyFont-Italic.ttf \
--bolditalic MyFont-BoldItalic.ttf \
--intervals latin-ext \
--sizes 12,14,16,18 \
--name MyFont \
--output-dir ./MyFont/
### Available Unicode interval presets
| Preset | Coverage |
|--------|----------|
| `ascii` | U+0020-U+007E (Basic Latin) |
| `latin-ext` | European languages (Latin + Extended-A/B) |
| `greek` | Greek + Extended Greek |
| `cyrillic` | Cyrillic + Supplement |
| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth |
| `hangul` | Korean Hangul syllables |
| `builtin` | Matches built-in Bookerly coverage exactly |
Combine presets with commas: `--intervals latin-ext,greek,cyrillic`
Install custom fonts via WiFi upload or manual SD card copy.
+2
View File
@@ -26,6 +26,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
## Italian
- [andreaturchet](https://github.com/andreaturchet)
- [fragolinux](https://github.com/fragolinux)
- [alan0ford](https://github.com/alan0ford)
## Russian
- [madebyKir](https://github.com/madebyKir)
@@ -52,6 +53,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an
## Ukrainian
- [mirus-ua](https://github.com/mirus-ua)
- [KymAndriy](https://github.com/KymAndriy)
## Belarusian
- [Dexif](https://github.com/dexif)
+42 -29
View File
@@ -15,11 +15,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
return;
}
int32_t cursorXFP = fp4::fromPixel(startX); // 12.4 fixed-point accumulator
int lastBaseX = startX;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int lastBaseLeft = 0;
int lastBaseWidth = 0;
int lastBaseTop = 0;
constexpr int MIN_COMBINING_GAP_PX = 1;
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
@@ -31,24 +31,29 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
const EpdGlyph* glyph = getGlyph(cp);
if (!glyph) {
prevCp = 0;
// Keep cursor movement stable when a base glyph is missing, but don't attach subsequent
// combining marks to stale base metrics.
if (!isCombining) {
lastBaseX += fp4::toPixel(prevAdvanceFP); // flush pending advance before resetting
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
}
continue;
}
int raiseBy = 0;
if (isCombining) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int raiseBy = isCombining ? combiningMark::raiseAboveBase(glyph->top, glyph->height, lastBaseTop) : 0;
if (!isCombining && prevCp != 0) {
cursorXFP += getKerning(prevCp, cp); // 4.4 fixed-point kern
const auto kernFP = getKerning(prevCp, cp); // 4.4 fixed-point kern
lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP);
}
const int cursorXPixels = fp4::toPixel(cursorXFP); // snap 12.4 fixed-point to nearest pixel
const int glyphBaseX = isCombining ? (lastBaseX + fp4::toPixel(lastBaseAdvanceFP / 2)) : cursorXPixels;
const int glyphBaseX =
isCombining ? combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width)
: lastBaseX;
const int glyphBaseY = startY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
@@ -57,10 +62,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorXPixels;
lastBaseAdvanceFP = glyph->advanceX; // 12.4 fixed-point
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
cursorXFP += glyph->advanceX; // 12.4 fixed-point advance
prevAdvanceFP = glyph->advanceX; // 12.4 fixed-point
prevCp = cp;
}
}
@@ -148,24 +153,32 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const {
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const int count = data->intervalCount;
if (count == 0) return nullptr;
if (count == 0 && !data->glyphMissHandler) return nullptr;
const EpdUnicodeInterval* intervals = data->intervals;
const auto* end = intervals + count;
if (count > 0) {
const EpdUnicodeInterval* intervals = data->intervals;
const auto* end = intervals + count;
// upper_bound: range lookup. Finds the first interval with first > cp, so the
// interval just before it is the last one with first <= cp. That's the only
// candidate that could contain cp. Then we verify cp <= candidate.last.
const auto it = std::upper_bound(
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
// upper_bound: range lookup. Finds the first interval with first > cp, so the
// interval just before it is the last one with first <= cp. That's the only
// candidate that could contain cp. Then we verify cp <= candidate.last.
const auto it = std::upper_bound(
intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; });
if (it != intervals) {
const auto& interval = *(it - 1);
if (cp <= interval.last) {
return &data->glyph[interval.offset + (cp - interval.first)];
if (it != intervals) {
const auto& interval = *(it - 1);
if (cp <= interval.last) {
return &data->glyph[interval.offset + (cp - interval.first)];
}
}
}
// Codepoint not in interval table — try on-demand loading (SD card fonts).
if (data->glyphMissHandler) {
const EpdGlyph* loaded = data->glyphMissHandler(data->glyphMissCtx, cp);
if (loaded) return loaded;
}
if (cp != REPLACEMENT_GLYPH) {
return getGlyph(REPLACEMENT_GLYPH);
}
+49 -4
View File
@@ -7,10 +7,12 @@
/// Font metrics use "fixed-point 4" (4 fractional bits, i.e. 1/16-pixel
/// resolution). Both the 12.4 glyph advances (uint16_t) and the 4.4 kern
/// values (int8_t) share the same 4 fractional bits, so they can be freely
/// added into a single int32_t accumulator during text layout. The
/// accumulator is snapped to the nearest whole pixel only at render time,
/// which avoids the per-character rounding errors that plagued integer-only
/// layout.
/// added before snapping to whole pixels.
///
/// Rendering and measurement use "differential rounding": each glyph step
/// (previous advance + current kern) is combined in fixed-point and snapped
/// to a pixel as one unit. This guarantees identical character pairs always
/// produce the same pixel spacing, regardless of position on the line.
///
/// The helpers below eliminate the raw bit-shifts that would otherwise be
/// scattered across every layout / measurement call site.
@@ -28,6 +30,37 @@ constexpr int toPixel(int32_t fp) { return static_cast<int>((fp + HALF) >> FRAC_
constexpr float toFloat(int32_t fp) { return fp / static_cast<float>(1 << FRAC_BITS); }
} // namespace fp4
/// Helpers for positioning Unicode combining marks (U+0300 ff.) over a
/// preceding base glyph without GPOS anchor tables.
namespace combiningMark {
constexpr int MIN_GAP_PX = 1;
/// Compute the cursor-X at which to render a combining mark so its bitmap
/// is visually centered over the base glyph's bitmap.
constexpr int centerOver(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos + baseLeft + baseWidth / 2 - markWidth / 2 - markLeft;
}
/// Rotated-90CW variant of centerOver. In the rotated coordinate system
/// renderCharImpl uses (cursorY - left) instead of (cursorX + left), so
/// every left/width term inverts sign.
constexpr int centerOverRotated90CW(int baseCursorPos, int baseLeft, int baseWidth, int markLeft, int markWidth) {
return baseCursorPos - baseLeft - baseWidth / 2 + markWidth / 2 + markLeft;
}
/// For combining marks that sit entirely above the baseline, compute how many
/// pixels to raise the mark so there is at least MIN_GAP_PX between its bottom
/// edge and the top of the base glyph. Returns 0 for marks that extend to or
/// below the baseline (e.g. cedilla, dot-below, ogonek).
constexpr int raiseAboveBase(int markTop, int markHeight, int baseTop) {
if (markTop - markHeight <= 0) return 0;
const int gap = markTop - markHeight - baseTop;
return (gap < MIN_GAP_PX) ? (MIN_GAP_PX - gap) : 0;
}
} // namespace combiningMark
/// Fixed-point conventions used by EpdGlyph and EpdFontData:
/// advanceX: 12.4 unsigned fixed-point in uint16_t (use fp4::toPixel)
/// kernMatrix: 4.4 signed fixed-point in int8_t (use fp4::toPixel)
@@ -96,4 +129,16 @@ typedef struct {
uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols)
const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none)
uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs
/// On-demand glyph loading for fonts that don't keep all glyphs in RAM (e.g. SD card fonts).
/// Called by getGlyph() when a codepoint is not found in the interval table.
/// Returns a valid EpdGlyph* with correct metadata, or nullptr to fall back to the
/// replacement glyph. The returned pointer is valid until the next glyphMissHandler
/// call that causes a ring-buffer eviction — callers must consume it (measure or draw)
/// before requesting another missed glyph.
const EpdGlyph* (*glyphMissHandler)(void* ctx, uint32_t codepoint);
/// Context pointer for glyphMissHandler (typically SdCardFont*). Also used by
/// GfxRenderer::getGlyphBitmap() to retrieve overflow bitmaps via SdCardFont.
void* glyphMissCtx;
} EpdFontData;
+37
View File
@@ -284,6 +284,43 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
}
}
// Add ligature output glyphs: if both input codepoints of a ligature pair are
// in the needed set, the output glyph will be queried during rendering.
if (fontData->ligaturePairs && fontData->ligaturePairCount > 0) {
for (uint32_t li = 0; li < fontData->ligaturePairCount && glyphCount < MAX_PAGE_GLYPHS; li++) {
uint32_t leftCp = fontData->ligaturePairs[li].pair >> 16;
uint32_t rightCp = fontData->ligaturePairs[li].pair & 0xFFFF;
int32_t leftIdx = findGlyphIndex(fontData, leftCp);
int32_t rightIdx = findGlyphIndex(fontData, rightCp);
if (leftIdx < 0 || rightIdx < 0) continue;
// Check if both inputs are in neededGlyphs
bool hasLeft = false, hasRight = false;
for (uint16_t i = 0; i < glyphCount; i++) {
if (neededGlyphs[i] == static_cast<uint32_t>(leftIdx)) hasLeft = true;
if (neededGlyphs[i] == static_cast<uint32_t>(rightIdx)) hasRight = true;
if (hasLeft && hasRight) break;
}
if (!hasLeft || !hasRight) continue;
int32_t outIdx = findGlyphIndex(fontData, fontData->ligaturePairs[li].ligatureCp);
if (outIdx < 0) continue;
// Deduplicate
bool found = false;
for (uint16_t i = 0; i < glyphCount; i++) {
if (neededGlyphs[i] == static_cast<uint32_t>(outIdx)) {
found = true;
break;
}
}
if (!found) {
neededGlyphs[glyphCount++] = static_cast<uint32_t>(outIdx);
}
}
}
if (glyphCount == 0) return 0;
// Step 2: Compute total buffer size and collect unique groups
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "EpdFont.h"
#include "EpdFontData.h"
// On-disk binary format version for .cpfont files. Defined as a preprocessor
// macro (rather than a constexpr) so it can be stringified into the SD-fonts
// release URL — see FONT_MANIFEST_URL in FontDownloadActivity.h. No integer
// suffix because stringification would include it (e.g. `4U` → `"4U"`).
//
// The canonical version for the build tooling lives in
// lib/EpdFont/scripts/cpfont_version.py. This firmware-side copy must be
// bumped manually when the firmware is updated to support a new format.
// Reader enforcement: SdCardFont::load().
#define CPFONT_VERSION 4
class SdCardFont {
public:
static constexpr uint16_t MAX_PAGE_GLYPHS = 512;
static constexpr uint8_t MAX_STYLES = 4;
SdCardFont() = default;
~SdCardFont();
// Owns raw buffers freed in dtor — no shallow-copy semantics. Make any
// accidental pass-by-value or move a compile-time error.
SdCardFont(const SdCardFont&) = delete;
SdCardFont& operator=(const SdCardFont&) = delete;
SdCardFont(SdCardFont&&) = delete;
SdCardFont& operator=(SdCardFont&&) = delete;
// Load .cpfont file: reads header + intervals into RAM, records file layout offsets.
// Supports v4 (multi-style) format.
// Returns true on success.
bool load(const char* path);
// Pre-read glyphs needed for the given UTF-8 text from SD card.
// styleMask: bitmask of styles to prewarm (bit 0=regular, 1=bold, 2=italic, 3=bolditalic).
// Default 0x0F = all present styles.
// When metadataOnly=true, only glyph metrics are loaded (no bitmap data).
// Returns number of glyphs that couldn't be loaded (0 on full success).
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 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.
uint16_t getAdvance(uint32_t codepoint, uint8_t style) const;
// Returns true if advance table is populated for at least one style.
bool hasAdvanceTable() const;
// Free mini data for all styles, restore stub EpdFontData.
// Also clears the temporary advance table (built per layout pass) but
// preserves the persistent advance cache (reused across passes).
void clearCache();
// Drop the persistent advance cache. Call when unloading the SD font or
// when font/size/family/glyph-table state changes.
void clearPersistentCache();
// Returns pointer to the managed EpdFont for a given style.
// Returns nullptr if the style is not present.
EpdFont* getEpdFont(uint8_t style = 0);
// 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_; }
// Returns true if the glyph pointer points into the overflow buffer.
bool isOverflowGlyph(const EpdGlyph* glyph) const;
// Returns the bitmap for an on-demand-loaded (overflow) glyph.
const uint8_t* getOverflowBitmap(const EpdGlyph* glyph) const;
// Extract SdCardFont* from an opaque glyphMissCtx pointer.
// Used by GfxRenderer::getGlyphBitmap() to recover the SdCardFont from EpdFontData::glyphMissCtx.
static SdCardFont* fromMissCtx(void* ctx);
struct Stats {
uint32_t prewarmTotalMs = 0;
uint32_t sdReadTimeMs = 0;
uint32_t seekCount = 0;
uint32_t uniqueGlyphs = 0;
uint32_t bitmapBytes = 0;
};
void logStats(const char* label = "SDCF");
void resetStats();
const Stats& getStats() const { return stats_; }
// Content hash of the file header + style TOC entries (computed during load).
// Used to generate deterministic font IDs for section cache invalidation.
uint32_t contentHash() const { return contentHash_; }
private:
// Per-style metadata (parsed from file header/TOC)
struct CpFontHeader {
uint32_t intervalCount = 0;
uint32_t glyphCount = 0;
uint8_t advanceY = 0;
int16_t ascender = 0;
int16_t descender = 0;
bool is2Bit = false;
uint16_t kernLeftEntryCount = 0;
uint16_t kernRightEntryCount = 0;
uint8_t kernLeftClassCount = 0;
uint8_t kernRightClassCount = 0;
uint8_t ligaturePairCount = 0;
};
// All per-style data: file offsets, intervals, kern/lig, prewarm cache, EpdFont
struct PerStyle {
CpFontHeader header{};
// File layout offsets for this style's data sections
uint32_t intervalsFileOffset = 0;
uint32_t glyphsFileOffset = 0;
uint32_t kernLeftFileOffset = 0;
uint32_t kernRightFileOffset = 0;
uint32_t kernMatrixFileOffset = 0;
uint32_t ligatureFileOffset = 0;
uint32_t bitmapFileOffset = 0;
// Full intervals loaded from file (kept in RAM for codepoint lookup)
EpdUnicodeInterval* fullIntervals = nullptr;
// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
// style's matrix is ~36-42KB contiguous, and 4 styles' worth won't fit
// alongside bitmaps + framebuffer on a 380KB device. Only kernLeftClasses
// and kernRightClasses (small codepoint→classId tables, ~3KB each) stay
// resident; the matrix is reconstructed per-page as miniKernMatrix.
EpdKernClassEntry* kernLeftClasses = nullptr;
EpdKernClassEntry* kernRightClasses = nullptr;
EpdLigaturePair* ligaturePairs = nullptr;
bool kernLigLoaded = false;
// Stub EpdFontData returned when not prewarmed
EpdFontData stubData{};
// Mini EpdFontData built during prewarm
EpdFontData miniData{};
EpdUnicodeInterval* miniIntervals = nullptr;
EpdGlyph* miniGlyphs = nullptr;
uint8_t* miniBitmap = nullptr;
uint32_t miniIntervalCount = 0;
uint32_t miniGlyphCount = 0;
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
// used on the current page to renumbered class IDs (1..miniKern*ClassCount).
// miniKernMatrix is a small miniKernLeftClassCount × miniKernRightClassCount
// flat matrix. Typical Latin page: ~25×25 matrix = ~625 bytes per style vs
// ~36KB for the full Literata matrix — ~50× reduction.
EpdKernClassEntry* miniKernLeftClasses = nullptr;
EpdKernClassEntry* miniKernRightClasses = nullptr;
uint16_t miniKernLeftEntryCount = 0;
uint16_t miniKernRightEntryCount = 0;
uint8_t miniKernLeftClassCount = 0;
uint8_t miniKernRightClassCount = 0;
int8_t* miniKernMatrix = nullptr;
// The EpdFont whose data pointer we manage
EpdFont epdFont{&stubData};
bool present = false;
};
PerStyle styles_[MAX_STYLES] = {};
uint8_t styleCount_ = 0;
char filePath_[128] = {};
// Overflow context: glyphMissHandler needs to know which style it's serving
struct OverflowContext {
SdCardFont* self;
uint8_t styleIdx;
};
OverflowContext overflowCtx_[MAX_STYLES] = {};
// Shared on-demand overflow buffer (ring buffer of glyphs loaded via glyphMissHandler)
static constexpr uint32_t OVERFLOW_CAPACITY = 8;
struct OverflowEntry {
EpdGlyph glyph;
uint8_t* bitmap = nullptr;
uint32_t codepoint = 0;
uint8_t styleIdx = 0;
};
OverflowEntry overflow_[OVERFLOW_CAPACITY] = {};
uint32_t overflowCount_ = 0;
uint32_t overflowNext_ = 0;
// Compact advance-only table for layout measurement (per-style).
// Built by buildAdvanceTable(), queried by getAdvance().
struct AdvanceEntry {
uint32_t codepoint;
uint16_t advanceX; // 12.4 fixed-point
};
// Per-style advance table. Sorted by codepoint for binary lookup.
// Bounded to ADVANCE_CACHE_LIMIT entries; persists across layout passes
// (across calls to clearCache()) so repeated indexing of the same font
// amortizes SD reads. Cleared only on font unload or clearPersistentCache().
static constexpr uint32_t ADVANCE_CACHE_LIMIT = 768;
AdvanceEntry* advanceTable_[MAX_STYLES] = {};
uint32_t advanceTableSize_[MAX_STYLES] = {};
bool advanceTableLookup(uint8_t styleIdx, uint32_t codepoint, uint16_t* outAdvance) const;
// Merge sortedNew (sorted by codepoint, no overlap with existing) into the
// advance table for styleIdx, preserving sort order; cap-truncates the tail.
void mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sortedNew, uint32_t newCount);
Stats stats_;
uint32_t contentHash_ = 0;
bool loaded_ = false;
// Per-style helpers
void freeStyleMiniData(PerStyle& s);
void freeStyleAll(PerStyle& s);
void freeStyleKernLigatureData(PerStyle& s);
void freeStyleMiniKern(PerStyle& s);
bool loadStyleKernLigatureData(PerStyle& s);
bool buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount);
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
void freeAll();
void clearOverflow();
static void computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset);
// Static callback for EpdFontData::glyphMissHandler (per-style via OverflowContext)
static const EpdGlyph* onGlyphMiss(void* ctx, uint32_t codepoint);
};
+98
View File
@@ -0,0 +1,98 @@
#include "SdCardFontManager.h"
#include <EpdFontFamily.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <SdCardFontRegistry.h>
SdCardFontManager::~SdCardFontManager() {
for (auto& lf : loaded_) {
delete lf.font;
}
}
// FNV-1a continuation: seeds with contentHash, then hashes family name + point size.
// Produces a deterministic ID that is stable across load/unload cycles and reboots,
// and changes when font content changes (different header/TOC = different contentHash).
int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize) {
static constexpr uint32_t FNV_PRIME = 16777619u;
uint32_t hash = contentHash;
while (*familyName) {
hash ^= static_cast<uint8_t>(*familyName++);
hash *= FNV_PRIME;
}
hash ^= pointSize;
hash *= FNV_PRIME;
int id = static_cast<int>(hash);
return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel
}
bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum) {
// Unload any previously loaded family first
if (!loadedFamilyName_.empty()) {
unloadAll(renderer);
}
// Select by ordinal position: sort available sizes, then map the font size
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the
// family has fewer sizes than 4, clamp to the last available size.
auto sizes = family.availableSizes();
if (sizes.empty()) {
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());
return false;
}
if (!font->load(selected->path.c_str())) {
LOG_ERR("SDMGR", "Failed to load %s", selected->path.c_str());
delete font;
return false;
}
int fontId = computeFontId(font->contentHash(), family.name.c_str(), selected->pointSize);
// Guard against collision with built-in font IDs (astronomically unlikely
// with FNV-1a hashes, but provides a safety net)
if (renderer.getFontMap().count(fontId) != 0) {
LOG_ERR("SDMGR", "Font ID %d collides with existing font, skipping %s", fontId, selected->path.c_str());
delete font;
return false;
}
renderer.registerSdCardFont(fontId, font);
loaded_.push_back({font, fontId, selected->pointSize});
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);
loadedFamilyName_ = family.name;
loadedPointSize_ = selected->pointSize;
return true;
}
void SdCardFontManager::unloadAll(GfxRenderer& renderer) {
renderer.clearSdCardFonts();
for (auto& lf : loaded_) {
renderer.removeFont(lf.fontId);
delete lf.font;
}
loaded_.clear();
loadedFamilyName_.clear();
loadedPointSize_ = 0;
}
int SdCardFontManager::getFontId(const std::string& familyName) const {
if (familyName != loadedFamilyName_ || loaded_.empty()) return 0;
return loaded_.front().fontId;
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
class GfxRenderer;
class SdCardFont;
struct SdCardFontFamilyInfo;
class SdCardFontManager {
public:
SdCardFontManager() = default;
~SdCardFontManager();
SdCardFontManager(const SdCardFontManager&) = delete;
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by
// ordinal position in the family's sorted size list. Only one .cpfont file
// is loaded; other sizes remain on disk. This keeps resident interval +
// kern/ligature tables to one size's worth of memory.
// Returns true on success.
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
// Unload everything, unregister from renderer.
void unloadAll(GfxRenderer& renderer);
// Look up the font ID for the loaded family. Returns 0 if nothing loaded
// or familyName doesn't match.
int getFontId(const std::string& familyName) const;
// Get name of currently loaded family (empty if none).
const std::string& currentFamilyName() const { return loadedFamilyName_; };
// Point size that was actually loaded (closest match to targetPtSize).
// 0 if nothing loaded.
uint8_t currentPointSize() const { return loadedPointSize_; };
private:
struct LoadedFont {
SdCardFont* font; // heap-allocated, owned
int fontId;
uint8_t size;
};
static int computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize);
std::string loadedFamilyName_;
uint8_t loadedPointSize_ = 0;
std::vector<LoadedFont> loaded_;
};
+230
View File
@@ -0,0 +1,230 @@
#include "SdCardFontRegistry.h"
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cstring>
// --- SdCardFontFamilyInfo helpers ---
const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t style) const {
for (const auto& f : files) {
if (f.pointSize == size && f.style == style) return &f;
}
return nullptr;
}
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
for (const auto& f : files) {
if (f.pointSize == size) return true;
}
return false;
}
std::vector<uint8_t> SdCardFontFamilyInfo::availableSizes() const {
std::vector<uint8_t> sizes;
for (const auto& f : files) {
bool found = false;
for (uint8_t s : sizes) {
if (s == f.pointSize) {
found = true;
break;
}
}
if (!found) sizes.push_back(f.pointSize);
}
std::sort(sizes.begin(), sizes.end());
return sizes;
}
// --- SdCardFontRegistry ---
bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) {
// V4 naming: <name>_<size>.cpfont (e.g. Bookerly-SD_14.cpfont)
// Use an ends-with check rather than strstr() so that in-progress downloads
// like "Foo_14.cpfont.tmp" or backups like "Foo_14.cpfont~" aren't accepted.
static constexpr char kExt[] = ".cpfont";
static constexpr size_t kExtLen = sizeof(kExt) - 1;
const size_t nameLen = strlen(filename);
if (nameLen <= kExtLen) return false;
if (strcmp(filename + nameLen - kExtLen, kExt) != 0) return false;
const char* ext = filename + nameLen - kExtLen;
size_t baseLen = ext - filename;
if (baseLen == 0 || baseLen > 127) return false;
char base[128];
memcpy(base, filename, baseLen);
base[baseLen] = '\0';
char* lastUnderscore = strrchr(base, '_');
if (!lastUnderscore || lastUnderscore == base) return false;
const char* sizeStr = lastUnderscore + 1;
char* endPtr;
long sizeVal = strtol(sizeStr, &endPtr, 10);
if (endPtr == sizeStr || *endPtr != '\0' || sizeVal < 1 || sizeVal > 255) return false;
size = static_cast<uint8_t>(sizeVal);
// V4 .cpfont files bundle every style (regular/bold/italic/bold-italic) into
// one file, so style is always 0 at the registry level. The per-style
// bitstream is selected later by SdCardFont::getEpdFont(style). The `style`
// field in SdCardFontFileInfo is reserved for future formats that split
// styles across files; scanDirectory() defends against accidental
// (pointSize, style) collisions in that scenario.
style = 0;
return true;
}
void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) {
FsFile dir = Storage.open(dirPath);
if (!dir || !dir.isDirectory()) return;
char nameBuffer[128];
while (true) {
FsFile entry = dir.openNextFile();
if (!entry) break;
if (entry.isDirectory()) {
entry.close();
continue;
}
entry.getName(nameBuffer, sizeof(nameBuffer));
entry.close();
// Skip macOS resource fork files (._*) and other hidden files
if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue;
uint8_t size, style;
if (!parseFilename(nameBuffer, size, style)) continue;
// Reject duplicate (pointSize, style) entries in the same family. With
// v4's bundle-everything design parseFilename always returns style=0, so
// two files at the same size in the same family would silently shadow
// each other in findFile(). Skip the duplicate and warn.
bool duplicate = false;
for (const auto& existing : family.files) {
if (existing.pointSize == size && existing.style == style) {
duplicate = true;
break;
}
}
if (duplicate) {
LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath);
continue;
}
SdCardFontFileInfo info;
info.path = std::string(dirPath) + "/" + nameBuffer;
info.pointSize = size;
info.style = style;
family.files.push_back(std::move(info));
}
}
// Scan a single root (e.g. "/.fonts") and append its families to `out`.
// Skips families whose names already exist in `out` (de-duplicates between
// the hidden and visible roots — first scan wins).
void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector<SdCardFontFamilyInfo>& out) {
FsFile root = Storage.open(rootPath);
if (!root) {
LOG_DBG("SDREG", "Fonts directory not found: %s", rootPath);
return;
}
if (!root.isDirectory()) {
LOG_ERR("SDREG", "Fonts path is not a directory: %s", rootPath);
return;
}
char nameBuffer[128];
while (true) {
FsFile entry = root.openNextFile();
if (!entry) break;
if (entry.isDirectory()) {
entry.getName(nameBuffer, sizeof(nameBuffer));
entry.close();
// Skip hidden/system directories inside the root (macOS ._*, .Trashes, etc.)
if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue;
// De-dup by family name across roots.
bool exists = false;
for (const auto& fam : out) {
if (fam.name == nameBuffer) {
exists = true;
break;
}
}
if (exists) continue;
SdCardFontFamilyInfo family;
family.name = nameBuffer;
std::string subDirPath = std::string(rootPath) + "/" + nameBuffer;
SdCardFontRegistry::scanDirectory(subDirPath.c_str(), family);
if (!family.files.empty()) {
out.push_back(std::move(family));
LOG_DBG("SDREG", "Found family: %s (%d files) in %s", out.back().name.c_str(),
static_cast<int>(out.back().files.size()), rootPath);
}
} else {
entry.close();
}
}
}
bool SdCardFontRegistry::discover() {
families_.clear();
families_.reserve(MAX_SD_FAMILIES);
// Hidden root is scanned first so it wins on name collisions, matching the
// sleep-folder pattern (/.sleep preferred over /sleep).
scanRoot(FONTS_DIR_HIDDEN, families_);
scanRoot(FONTS_DIR_VISIBLE, families_);
// Sort families alphabetically
std::sort(families_.begin(), families_.end(),
[](const SdCardFontFamilyInfo& a, const SdCardFontFamilyInfo& b) { return a.name < b.name; });
// Cap at MAX_SD_FAMILIES
if (static_cast<int>(families_.size()) > MAX_SD_FAMILIES) {
families_.resize(MAX_SD_FAMILIES);
}
LOG_DBG("SDREG", "Discovery complete: %d families", static_cast<int>(families_.size()));
return !families_.empty();
}
const char* SdCardFontRegistry::findFamilyRoot(const char* familyName) {
if (!familyName || !*familyName) return nullptr;
char path[160];
snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_HIDDEN, familyName);
if (Storage.exists(path)) return FONTS_DIR_HIDDEN;
snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_VISIBLE, familyName);
if (Storage.exists(path)) return FONTS_DIR_VISIBLE;
return nullptr;
}
const char* SdCardFontRegistry::defaultWriteRoot() {
// If exactly one of the roots already exists, keep using it. Otherwise
// (neither exists, or both exist) prefer the hidden root for new installs.
bool hiddenExists = Storage.exists(FONTS_DIR_HIDDEN);
bool visibleExists = Storage.exists(FONTS_DIR_VISIBLE);
if (hiddenExists) return FONTS_DIR_HIDDEN;
if (visibleExists) return FONTS_DIR_VISIBLE;
return FONTS_DIR_HIDDEN;
}
const SdCardFontFamilyInfo* SdCardFontRegistry::findFamily(const std::string& name) const {
for (const auto& f : families_) {
if (f.name == name) return &f;
}
return nullptr;
}
int SdCardFontRegistry::getFamilyIndex(const std::string& name) const {
for (int i = 0; i < static_cast<int>(families_.size()); i++) {
if (families_[i].name == name) return i;
}
return -1;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
struct SdCardFontFileInfo {
std::string path; // v4 on-disk naming: "/<root>/<Family>/<Family>_<size>.cpfont"
// where <root> is "/.fonts" (preferred, hidden) or "/fonts" (visible).
// e.g. "/.fonts/NotoSansCJK/NotoSansCJK_14.cpfont"
uint8_t pointSize; // parsed from filename: 14
uint8_t style; // always 0 in v4 (all 4 styles bundled in one file);
// kept for potential future formats
};
struct SdCardFontFamilyInfo {
std::string name; // directory name, e.g. "NotoSansCJK"
std::vector<SdCardFontFileInfo> files;
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
bool hasSize(uint8_t size) const;
std::vector<uint8_t> availableSizes() const;
};
class SdCardFontRegistry {
public:
static constexpr int MAX_SD_FAMILIES = 128;
// Two top-level roots are scanned at discovery time. Hidden is preferred
// when creating new installs; both are read from if present.
static constexpr const char* FONTS_DIR_HIDDEN = "/.fonts";
static constexpr const char* FONTS_DIR_VISIBLE = "/fonts";
// Returns the existing root for `familyName` (the one that contains
// /<root>/<familyName>/), or nullptr if the family is not installed in
// either root. Used by writers to keep re-installs in their existing dir.
static const char* findFamilyRoot(const char* familyName);
// Returns the root path that should be used when creating a brand-new
// family on disk (no prior install): the existing root if exactly one of
// the two roots exists, otherwise the hidden root.
static const char* defaultWriteRoot();
// Scan SD card, populate families_. Returns true if any families found.
bool discover();
const std::vector<SdCardFontFamilyInfo>& getFamilies() const { return families_; }
const SdCardFontFamilyInfo* findFamily(const std::string& name) const;
int getFamilyIndex(const std::string& name) const;
int getFamilyCount() const { return static_cast<int>(families_.size()); }
private:
std::vector<SdCardFontFamilyInfo> families_; // sorted alphabetically
static bool parseFilename(const char* filename, uint8_t& size, uint8_t& style);
static void scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family);
// Scan one root (e.g. "/.fonts"), append families to `out`, dedup by name.
static void scanRoot(const char* rootPath, std::vector<SdCardFontFamilyInfo>& out);
};
+16 -16
View File
@@ -1,21 +1,21 @@
#pragma once
#include <builtinFonts/bookerly_12_bold.h>
#include <builtinFonts/bookerly_12_bolditalic.h>
#include <builtinFonts/bookerly_12_italic.h>
#include <builtinFonts/bookerly_12_regular.h>
#include <builtinFonts/bookerly_14_bold.h>
#include <builtinFonts/bookerly_14_bolditalic.h>
#include <builtinFonts/bookerly_14_italic.h>
#include <builtinFonts/bookerly_14_regular.h>
#include <builtinFonts/bookerly_16_bold.h>
#include <builtinFonts/bookerly_16_bolditalic.h>
#include <builtinFonts/bookerly_16_italic.h>
#include <builtinFonts/bookerly_16_regular.h>
#include <builtinFonts/bookerly_18_bold.h>
#include <builtinFonts/bookerly_18_bolditalic.h>
#include <builtinFonts/bookerly_18_italic.h>
#include <builtinFonts/bookerly_18_regular.h>
#include <builtinFonts/notoserif_12_bold.h>
#include <builtinFonts/notoserif_12_bolditalic.h>
#include <builtinFonts/notoserif_12_italic.h>
#include <builtinFonts/notoserif_12_regular.h>
#include <builtinFonts/notoserif_14_bold.h>
#include <builtinFonts/notoserif_14_bolditalic.h>
#include <builtinFonts/notoserif_14_italic.h>
#include <builtinFonts/notoserif_14_regular.h>
#include <builtinFonts/notoserif_16_bold.h>
#include <builtinFonts/notoserif_16_bolditalic.h>
#include <builtinFonts/notoserif_16_italic.h>
#include <builtinFonts/notoserif_16_regular.h>
#include <builtinFonts/notoserif_18_bold.h>
#include <builtinFonts/notoserif_18_bolditalic.h>
#include <builtinFonts/notoserif_18_italic.h>
#include <builtinFonts/notoserif_18_regular.h>
#include <builtinFonts/notosans_8_regular.h>
#include <builtinFonts/notosans_12_bold.h>
#include <builtinFonts/notosans_12_bolditalic.h>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+308 -308
View File
@@ -3,239 +3,239 @@
* name: notosans_16_regular
* size: 16
* mode: 2-bit compressed: true
* Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit --compress
* Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit --compress --pnum
*/
#pragma once
#include "EpdFontData.h"
static const uint8_t notosans_16_regularBitmaps[35113] = {
0xBD, 0x5A, 0x3D, 0x8C, 0xE4, 0xC8, 0x75, 0x2E, 0x92, 0xBB, 0x4B, 0x40, 0x84, 0xC1, 0x0B, 0xB4,
0xB2, 0xA0, 0x0D, 0x98, 0x5C, 0x24, 0xC1, 0xC7, 0x54, 0xC1, 0x0E, 0x8B, 0x82, 0xA1, 0x7C, 0x83,
0x9D, 0x5B, 0xC0, 0x1B, 0x9C, 0x53, 0x1D, 0x20, 0x4C, 0xA2, 0xC0, 0xC1, 0xCC, 0x90, 0xF6, 0x05,
0x0A, 0x14, 0x68, 0x01, 0xE1, 0x0C, 0x03, 0x97, 0x28, 0x38, 0x19, 0x72, 0x6F, 0xB0, 0x89, 0x03,
0x09, 0xE8, 0x69, 0x12, 0x36, 0xE0, 0xC4, 0xC1, 0xD1, 0x06, 0x04, 0x87, 0xA2, 0xE0, 0xDB, 0x40,
0xE7, 0xE0, 0x08, 0x9B, 0x07, 0xB3, 0x57, 0x45, 0x96, 0xBF, 0xEF, 0x15, 0xD9, 0xDD, 0xB3, 0x3B,
0xFB, 0xAB, 0x85, 0x39, 0xAC, 0xC7, 0x6E, 0x76, 0xFD, 0xBC, 0x7A, 0xFF, 0xEF, 0xD5, 0x68, 0xA5,
0xAF, 0xF8, 0xCB, 0xD4, 0x7B, 0xF3, 0xDF, 0x9F, 0xA9, 0x6B, 0x8A, 0x97, 0x56, 0x55, 0x5E, 0x95,
0x05, 0xBE, 0xAD, 0xA3, 0x66, 0xBE, 0xC3, 0x1A, 0xF7, 0x29, 0xEE, 0x80, 0xF7, 0x77, 0x83, 0x52,
0xA9, 0x7F, 0xF4, 0x5A, 0x74, 0xEE, 0x03, 0xC2, 0x2E, 0x68, 0x94, 0xF2, 0xBA, 0xA0, 0x56, 0xCA,
0xEF, 0x42, 0xC2, 0x36, 0x02, 0x0C, 0x9A, 0xA8, 0x54, 0x5F, 0x7B, 0xF8, 0xE4, 0xD1, 0x17, 0x79,
0x6C, 0x71, 0x95, 0xD7, 0xEF, 0xFF, 0xE6, 0xE4, 0x38, 0x57, 0x51, 0x99, 0x62, 0x50, 0x9C, 0x67,
0x02, 0x8F, 0x00, 0x13, 0x75, 0x1B, 0x30, 0x55, 0xA7, 0x58, 0x1E, 0x1D, 0xA7, 0x19, 0xAA, 0x23,
0xD5, 0xE3, 0xFD, 0x6D, 0xD5, 0x01, 0x9E, 0x7A, 0x84, 0x6B, 0x9F, 0x70, 0xF0, 0xDD, 0xE2, 0xCD,
0xB2, 0xB8, 0xF2, 0xD0, 0xB0, 0x9A, 0xB4, 0x5B, 0x0F, 0x54, 0x68, 0x6D, 0x93, 0x58, 0xDB, 0x15,
0x9F, 0xFC, 0x6D, 0x77, 0x81, 0x77, 0x66, 0x6E, 0x23, 0x5A, 0xF1, 0x93, 0x52, 0xA5, 0xB6, 0x44,
0x9F, 0x41, 0xA9, 0x02, 0x1F, 0x22, 0xDB, 0xA9, 0xE8, 0xC7, 0x03, 0xC6, 0x1A, 0xB4, 0x35, 0x9F,
0x79, 0xF4, 0xD7, 0x66, 0xBA, 0xFF, 0xA8, 0x07, 0x1A, 0x4D, 0x41, 0x4C, 0x6E, 0x95, 0xFB, 0xF9,
0xD9, 0xDC, 0xE5, 0x4F, 0xDC, 0x81, 0x8A, 0x6D, 0x0D, 0xE4, 0x55, 0xFA, 0xE7, 0x2D, 0x30, 0x56,
0x19, 0x10, 0x5D, 0x13, 0xF7, 0x8E, 0xD8, 0x9F, 0x02, 0xCD, 0x4E, 0x1E, 0x24, 0x0C, 0x1E, 0x71,
0x29, 0x8F, 0x44, 0x36, 0xD6, 0x67, 0x37, 0x1E, 0xA8, 0x23, 0xAF, 0x5B, 0x6B, 0xA3, 0xDE, 0xF3,
0xBB, 0x7E, 0x53, 0xA9, 0xE4, 0x93, 0x7F, 0xEF, 0xFA, 0x38, 0x8F, 0xEC, 0x47, 0xFF, 0xD6, 0xC5,
0xA5, 0xF7, 0xEB, 0xF8, 0x9F, 0x3A, 0x12, 0x33, 0xF1, 0x5B, 0x4E, 0x90, 0x05, 0xF2, 0x38, 0x75,
0x8F, 0x41, 0x5E, 0xFA, 0x9D, 0xCF, 0x2E, 0x41, 0xE3, 0x61, 0x80, 0xC2, 0xA7, 0x21, 0xC9, 0x89,
0x56, 0x31, 0x91, 0x9B, 0x2A, 0x19, 0x04, 0x57, 0x2F, 0x5F, 0xB0, 0x4E, 0x2C, 0x3F, 0x56, 0x16,
0x34, 0xF4, 0xA6, 0xFB, 0xC0, 0x31, 0xE8, 0x3D, 0xF4, 0x01, 0x9A, 0x66, 0x0F, 0xF9, 0xC6, 0x1B,
0x02, 0xD2, 0x7F, 0x4C, 0x48, 0x6D, 0x5D, 0x71, 0xDB, 0xC9, 0xC8, 0x19, 0x0A, 0xBE, 0xF6, 0xED,
0xA8, 0x82, 0x36, 0xEA, 0x8B, 0x32, 0x6C, 0x92, 0x3A, 0x69, 0xE3, 0x5A, 0xE7, 0xE1, 0x00, 0x88,
0x59, 0x75, 0xAE, 0x49, 0x5B, 0x59, 0xDE, 0x10, 0x46, 0x46, 0xA5, 0xB5, 0xD2, 0x93, 0x8A, 0xB7,
0xF7, 0x2C, 0x46, 0x58, 0x3B, 0xC4, 0x2D, 0x70, 0x28, 0x03, 0xA3, 0xBC, 0x3B, 0x64, 0x2C, 0xAF,
0xD3, 0xD3, 0xD3, 0xEF, 0x42, 0x24, 0xD7, 0x58, 0xB8, 0x0B, 0x9B, 0xB8, 0x4E, 0xCA, 0x34, 0xA7,
0x94, 0x9E, 0xE3, 0xEF, 0xE2, 0xE0, 0x8F, 0xDF, 0xF9, 0x3E, 0x2D, 0xE3, 0x3A, 0x6E, 0xC2, 0xD6,
0x27, 0xD2, 0x67, 0xEA, 0xDA, 0x1A, 0xEF, 0x92, 0x3C, 0xAE, 0xC3, 0x36, 0xE8, 0x7C, 0x6E, 0xCA,
0xE0, 0x6F, 0x19, 0xB0, 0xFC, 0x5D, 0xA8, 0x91, 0xEF, 0x7D, 0xAE, 0xD1, 0x45, 0x4D, 0x5C, 0x6A,
0x0C, 0x7D, 0xC7, 0x11, 0x28, 0x6C, 0x5D, 0xA3, 0x4C, 0xA1, 0xBD, 0x1B, 0xD4, 0xD7, 0x8B, 0x9F,
0xFD, 0xCB, 0x54, 0x41, 0x0E, 0xBE, 0x55, 0x4D, 0xBF, 0x50, 0x31, 0x36, 0xAE, 0xC1, 0xDC, 0x0B,
0x6C, 0xD2, 0xA4, 0x65, 0xD0, 0xC7, 0x8D, 0xD0, 0xFE, 0x1D, 0x8F, 0x42, 0xAF, 0x5E, 0x02, 0x0A,
0x4C, 0x93, 0x0B, 0xB8, 0x79, 0xEF, 0xAB, 0xE3, 0x97, 0x8D, 0x50, 0xC0, 0x27, 0x26, 0x11, 0x6A,
0x12, 0x21, 0x83, 0x40, 0x9D, 0x12, 0x4D, 0x72, 0x2E, 0xC7, 0x7D, 0x7C, 0x7C, 0x49, 0x59, 0x15,
0x54, 0x58, 0x61, 0x77, 0x8A, 0xB2, 0x47, 0x16, 0x7A, 0xBD, 0x30, 0x53, 0x51, 0x43, 0x23, 0x6C,
0x27, 0xA6, 0x10, 0x95, 0xD4, 0x32, 0x91, 0x0A, 0xEC, 0x59, 0xBA, 0x29, 0xF0, 0xD9, 0x9F, 0xBB,
0x86, 0x2F, 0xE8, 0xEA, 0xAE, 0x14, 0x22, 0xE5, 0x59, 0x88, 0x7B, 0x30, 0x7E, 0xD0, 0xA9, 0xB8,
0xF5, 0x07, 0x76, 0x06, 0x6F, 0x73, 0xE1, 0x88, 0x30, 0xC5, 0x81, 0x94, 0xB8, 0xA4, 0xF9, 0x73,
0x41, 0x46, 0xB0, 0x1F, 0x21, 0x13, 0x40, 0x7A, 0x2F, 0xB0, 0x65, 0xCF, 0xA8, 0x60, 0x7B, 0xAB,
0xE7, 0x42, 0x40, 0x26, 0xA5, 0xB4, 0x8A, 0x88, 0x80, 0x2A, 0x49, 0xA7, 0xAA, 0x2E, 0xB0, 0x5D,
0xFC, 0x9F, 0x9D, 0xFE, 0xA8, 0xFB, 0x13, 0x20, 0xFD, 0x06, 0xB7, 0x5C, 0x54, 0x8E, 0x88, 0x0B,
0xE8, 0xED, 0x49, 0x0F, 0xA1, 0xF1, 0x0D, 0x89, 0x48, 0x82, 0x00, 0x87, 0x4B, 0x80, 0xEF, 0xA8,
0x0D, 0x6E, 0x68, 0x44, 0xD6, 0x40, 0x94, 0x31, 0x03, 0xDF, 0xF3, 0xC7, 0x90, 0xEF, 0x93, 0xE7,
0xBD, 0xFA, 0x62, 0xB5, 0x3A, 0x60, 0xBC, 0x53, 0xC5, 0x6A, 0xA2, 0x16, 0x42, 0x01, 0xF5, 0x63,
0x2C, 0xFE, 0x9E, 0xF2, 0x65, 0xDD, 0xF1, 0x8A, 0x75, 0x09, 0x22, 0x4C, 0xF6, 0x61, 0x85, 0xC9,
0x2C, 0x7B, 0x70, 0x18, 0xEC, 0x91, 0x8C, 0x98, 0xD4, 0xBC, 0xA8, 0xB0, 0xE9, 0x00, 0x14, 0xEA,
0x8E, 0xF2, 0x46, 0xB5, 0xC9, 0xD1, 0xAF, 0xE2, 0x88, 0xC4, 0x4E, 0x58, 0xFB, 0xA6, 0xAC, 0x2F,
0x93, 0x06, 0x04, 0x31, 0x41, 0x4A, 0x70, 0x44, 0x60, 0x0C, 0x0D, 0xB8, 0xA1, 0x20, 0x18, 0x4A,
0x81, 0x21, 0x1B, 0x0D, 0xF9, 0x6F, 0x28, 0x27, 0xC6, 0x19, 0x87, 0xB0, 0x01, 0x00, 0xAB, 0x44,
0xAF, 0x8D, 0xEC, 0xAD, 0x17, 0xF0, 0xED, 0xD5, 0x6A, 0xFA, 0xF9, 0x8C, 0xF8, 0xB3, 0x00, 0xFB,
0x6D, 0x52, 0xA0, 0x92, 0xF2, 0x39, 0xE3, 0xB9, 0xB4, 0x6C, 0x69, 0x1F, 0x3E, 0x50, 0x85, 0x33,
0xFF, 0xCD, 0x35, 0x32, 0x5B, 0x05, 0xC3, 0x8C, 0xEF, 0x53, 0xCD, 0x1F, 0x72, 0x10, 0x79, 0xFB,
0xBD, 0xA2, 0x21, 0x55, 0x53, 0x9A, 0xF2, 0x9B, 0x8B, 0x90, 0x7A, 0xFC, 0x96, 0x10, 0x54, 0x8F,
0xEF, 0x2A, 0x7F, 0xDC, 0xB1, 0x8E, 0x8E, 0x82, 0xB2, 0x0D, 0x76, 0x2B, 0x27, 0xDD, 0x4A, 0xDF,
0x00, 0x5D, 0x74, 0x0A, 0x0F, 0xA0, 0x37, 0xF7, 0xB1, 0xA3, 0x16, 0x34, 0xD5, 0xE5, 0x2C, 0xC2,
0x94, 0xCF, 0xE4, 0x00, 0x40, 0x48, 0x53, 0x58, 0x17, 0xFC, 0x06, 0x29, 0xD8, 0x40, 0x07, 0x42,
0x43, 0x7E, 0x74, 0x4A, 0xFC, 0x89, 0x02, 0xCD, 0x1D, 0xA7, 0xAB, 0x43, 0x76, 0x0B, 0x3F, 0xCE,
0x9F, 0x15, 0x24, 0x2A, 0x1B, 0x84, 0x7D, 0x41, 0x29, 0xDD, 0xF5, 0xDD, 0x31, 0xDF, 0x7F, 0xAA,
0x6F, 0xBD, 0xC8, 0x94, 0xCE, 0x17, 0xB6, 0x8F, 0x3B, 0x02, 0xCB, 0x75, 0x82, 0xCD, 0xF8, 0x64,
0x77, 0xB4, 0xBD, 0x2F, 0x9A, 0x39, 0x72, 0xAB, 0xE7, 0x4E, 0xBB, 0x04, 0x24, 0xA2, 0x62, 0x2D,
0xD0, 0x08, 0x46, 0xCA, 0x95, 0x25, 0xE2, 0x7A, 0x12, 0xB9, 0xAA, 0x55, 0x38, 0x24, 0xD0, 0x85,
0x1A, 0x92, 0x43, 0xF3, 0x4D, 0x96, 0x1C, 0xAA, 0xB1, 0xD3, 0x56, 0xF8, 0x15, 0x95, 0xB4, 0x37,
0x46, 0xFA, 0xD8, 0x9E, 0xC6, 0xA8, 0x9C, 0xD5, 0xD3, 0x61, 0x30, 0xD2, 0x31, 0x60, 0xAA, 0x68,
0x7B, 0xD2, 0x61, 0x2A, 0xEC, 0x01, 0x23, 0x44, 0x54, 0x32, 0xCE, 0xA7, 0x65, 0x3E, 0x07, 0xF8,
0x55, 0xFC, 0x44, 0x9E, 0x96, 0x9E, 0xCD, 0xE3, 0xFE, 0x96, 0x55, 0x81, 0xB5, 0xE0, 0x4B, 0x35,
0xEC, 0x45, 0x39, 0xBB, 0x4C, 0x93, 0xDE, 0x29, 0xC4, 0x35, 0x0F, 0x0A, 0x11, 0x59, 0x51, 0xE2,
0xCF, 0xF1, 0xEA, 0x2E, 0xDA, 0x35, 0x67, 0x0E, 0x73, 0xAD, 0xAE, 0xBA, 0xF6, 0xA6, 0xD2, 0x03,
0x5E, 0x69, 0x93, 0x36, 0x42, 0xD0, 0x67, 0xAE, 0x84, 0x06, 0xB7, 0xA4, 0x00, 0x9C, 0xC1, 0x94,
0x0E, 0x6A, 0xB7, 0x35, 0x15, 0x0A, 0xD9, 0xE9, 0x25, 0x2C, 0x3F, 0xD0, 0x55, 0x4C, 0x87, 0x1F,
0x74, 0x33, 0xFF, 0xAE, 0xD2, 0x41, 0xC4, 0x90, 0xDF, 0x13, 0xD1, 0xD1, 0x8A, 0x38, 0x46, 0x56,
0x2D, 0x1D, 0xC2, 0xFC, 0x00, 0x31, 0x08, 0x8A, 0x03, 0x57, 0x5D, 0x50, 0xAA, 0xD5, 0xD3, 0x5D,
0x7E, 0xB4, 0x9B, 0x27, 0x75, 0x8E, 0xF8, 0xAA, 0x8F, 0xDE, 0x6E, 0xB5, 0x88, 0x46, 0xBB, 0x6A,
0x9C, 0xF3, 0xF6, 0xED, 0x03, 0x2C, 0x39, 0x2C, 0x13, 0xFC, 0xE8, 0xD2, 0x62, 0x37, 0xC0, 0xD1,
0x82, 0x4B, 0xFD, 0xA2, 0x2A, 0xDF, 0x15, 0x81, 0x0B, 0x1B, 0xE7, 0x41, 0x0E, 0x5D, 0x03, 0x18,
0x0A, 0x09, 0x51, 0x61, 0x3F, 0x7F, 0xA5, 0xF9, 0x60, 0x80, 0x36, 0xB7, 0xAF, 0x1F, 0x4C, 0x48,
0xCF, 0x61, 0xE7, 0x36, 0x2D, 0xBF, 0x79, 0x8F, 0x3E, 0x15, 0xD4, 0x44, 0xF6, 0x02, 0xFB, 0xF1,
0x09, 0xD5, 0xB5, 0x45, 0xD4, 0xE0, 0x84, 0x1A, 0xC2, 0x30, 0x88, 0xB6, 0x42, 0xF4, 0xAF, 0x43,
0xFE, 0xC3, 0x9A, 0x91, 0x06, 0xE2, 0xA6, 0x18, 0xEC, 0x1E, 0x12, 0x2C, 0x8F, 0x08, 0x21, 0x05,
0x6F, 0x32, 0x75, 0x9A, 0x81, 0x41, 0x78, 0x1C, 0xC1, 0xD9, 0xC9, 0xA3, 0xA3, 0x97, 0x74, 0x8F,
0x35, 0x5F, 0x9E, 0xA9, 0xE1, 0x08, 0x5D, 0x46, 0xD5, 0x65, 0xEA, 0xC2, 0xB7, 0x41, 0x9B, 0x2A,
0xFD, 0xE5, 0x67, 0x5F, 0xD6, 0x89, 0x8A, 0xA7, 0xC0, 0xD2, 0x8A, 0xDD, 0xC1, 0x1F, 0x37, 0x49,
0x55, 0xEB, 0x1D, 0xD2, 0x93, 0x8B, 0x05, 0xD2, 0xED, 0xF7, 0x1F, 0x95, 0x64, 0xB6, 0xD3, 0xE3,
0xEF, 0xD8, 0x4F, 0x77, 0x32, 0x49, 0x13, 0x41, 0xAE, 0x8A, 0x00, 0x09, 0x9B, 0xBD, 0xFF, 0xA5,
0x8A, 0x06, 0xFF, 0x4D, 0x6D, 0x0D, 0x7E, 0x27, 0x42, 0xDA, 0x88, 0x85, 0xAD, 0x29, 0xBA, 0x49,
0x79, 0x2E, 0x06, 0xC7, 0xD9, 0x3B, 0xAA, 0xFA, 0x85, 0x90, 0xCE, 0x10, 0xC2, 0xCD, 0x41, 0x63,
0xA1, 0x34, 0x30, 0x24, 0xC1, 0x93, 0xD5, 0xC3, 0x9E, 0xC4, 0xF6, 0x86, 0xB9, 0x03, 0x15, 0x7E,
0x23, 0x43, 0xB4, 0x1B, 0x4E, 0x5D, 0xC4, 0x84, 0x6E, 0xF2, 0xCA, 0xFE, 0x9E, 0x9A, 0x8B, 0xF1,
0x00, 0x0C, 0x76, 0x10, 0x91, 0xE2, 0x97, 0x8A, 0x40, 0x5F, 0x06, 0x1B, 0x00, 0x58, 0x52, 0xBB,
0xAA, 0xA4, 0x73, 0x4E, 0xD0, 0xF3, 0x9D, 0x8C, 0xD0, 0x39, 0x8D, 0x3D, 0x67, 0x4D, 0xEA, 0x05,
0xA4, 0x04, 0x9A, 0xEF, 0xAC, 0x9A, 0x56, 0x8F, 0x46, 0x37, 0xC2, 0x8A, 0x27, 0x71, 0xE1, 0x2F,
0x3F, 0xC2, 0x9E, 0x77, 0x9E, 0xFD, 0xE2, 0x51, 0x13, 0x8C, 0xCA, 0x2B, 0xE3, 0x6E, 0x71, 0x8F,
0x7B, 0x23, 0xB5, 0xD9, 0x59, 0xAA, 0xF1, 0xB2, 0x53, 0xD8, 0xBF, 0xDB, 0xEC, 0x1D, 0x99, 0xD8,
0x41, 0x31, 0x75, 0xA4, 0x68, 0x34, 0xC8, 0x3A, 0x1F, 0xDF, 0xAF, 0xE9, 0xC7, 0xA8, 0xF2, 0x14,
0x1A, 0xF8, 0xB1, 0x4B, 0x3B, 0x67, 0x40, 0x07, 0xD1, 0x27, 0xCE, 0xA4, 0x7A, 0x44, 0x1F, 0x16,
0xF4, 0xE2, 0x26, 0x18, 0x09, 0x99, 0xC5, 0xB9, 0x39, 0x30, 0xEE, 0xC0, 0xEE, 0x9D, 0x74, 0x91,
0xCE, 0x32, 0x2C, 0xEE, 0xE6, 0xED, 0xC3, 0x2C, 0x4D, 0xAB, 0x6A, 0x5C, 0x16, 0xDA, 0xD2, 0xDE,
0x59, 0xBA, 0x9C, 0x96, 0x99, 0xC1, 0x15, 0x7E, 0x69, 0xD7, 0xEC, 0x6A, 0x55, 0xA2, 0x4F, 0xCD,
0xF6, 0xA2, 0x7E, 0x6C, 0xD3, 0x6A, 0xF5, 0x60, 0x9E, 0xB3, 0x7D, 0xD5, 0xF9, 0x5F, 0x77, 0x0D,
0x73, 0xC9, 0x51, 0x78, 0x15, 0xCC, 0x40, 0x42, 0xC2, 0xD9, 0x27, 0x0F, 0x4D, 0x00, 0x89, 0x7F,
0x97, 0xA1, 0xED, 0xEC, 0x5E, 0xCA, 0xE7, 0x73, 0x6E, 0xF5, 0x0F, 0x24, 0x8A, 0x15, 0xC0, 0x77,
0x05, 0xBB, 0x68, 0x76, 0xD6, 0xE4, 0x9C, 0x26, 0xEB, 0x35, 0x22, 0x7B, 0xA5, 0xE9, 0x1B, 0xB5,
0x6F, 0x1F, 0xBF, 0x6F, 0xC5, 0x29, 0x52, 0x8F, 0x60, 0x04, 0xAF, 0xDF, 0x61, 0x7C, 0xEA, 0x09,
0x6F, 0x5E, 0x19, 0x60, 0x9F, 0x0F, 0x99, 0x8A, 0x59, 0x07, 0x5E, 0x6B, 0xEC, 0x1E, 0xC0, 0x53,
0x55, 0xB6, 0x0E, 0xB7, 0xEA, 0xCD, 0xA2, 0x46, 0x58, 0x07, 0x0C, 0xAF, 0x28, 0x83, 0x35, 0x75,
0xF3, 0x6D, 0xDD, 0x69, 0x79, 0x5C, 0x29, 0x04, 0x84, 0x55, 0x0F, 0x6B, 0x47, 0xE2, 0x88, 0x6E,
0x17, 0x4E, 0x5B, 0x0D, 0x97, 0x35, 0x0C, 0x13, 0x8C, 0xF3, 0xA3, 0xE0, 0x94, 0x61, 0x5C, 0x60,
0x18, 0x87, 0x1A, 0x52, 0xDB, 0x90, 0xF8, 0x13, 0xB5, 0x42, 0xC2, 0x65, 0xFB, 0x13, 0x68, 0xE0,
0xC4, 0xB0, 0xC9, 0x90, 0x69, 0x86, 0x8C, 0x94, 0xDC, 0x0B, 0xB3, 0xB4, 0x8C, 0xDE, 0x38, 0x9F,
0xCF, 0x99, 0xA1, 0xD5, 0x8B, 0x7E, 0x27, 0xCD, 0xFA, 0x15, 0xC5, 0xE7, 0x75, 0x9A, 0x3D, 0x14,
0xEB, 0x72, 0x4E, 0x1C, 0xE8, 0xE1, 0x5B, 0xB1, 0xAA, 0x1D, 0x3D, 0xE2, 0x44, 0x27, 0x52, 0x19,
0x7A, 0x00, 0x4D, 0x1D, 0xD4, 0xDA, 0xC0, 0x67, 0x1C, 0x69, 0x03, 0x5B, 0x87, 0xA4, 0x18, 0xB1,
0xC0, 0xA0, 0x4D, 0x52, 0x7A, 0xBD, 0x36, 0x71, 0xED, 0x77, 0xDA, 0xC0, 0x2D, 0xB4, 0xDA, 0x20,
0xEF, 0xAC, 0x01, 0x3B, 0x64, 0x9B, 0xC6, 0xEB, 0x11, 0x9E, 0x61, 0x43, 0x30, 0xAF, 0x46, 0xAD,
0x33, 0xC2, 0xF3, 0x33, 0x42, 0x77, 0xC3, 0x17, 0x03, 0xC2, 0x2E, 0xED, 0x20, 0xCC, 0xBD, 0xA6,
0xD8, 0xAE, 0x49, 0xB4, 0x35, 0x71, 0x5A, 0x13, 0x99, 0x35, 0xC3, 0x99, 0x35, 0xED, 0xC2, 0xDA,
0x54, 0x04, 0x20, 0xF8, 0xDA, 0x40, 0x23, 0xD6, 0x24, 0xF6, 0xDA, 0x80, 0x13, 0x6B, 0x03, 0x9E,
0xAC, 0xC9, 0x85, 0x35, 0xF9, 0xB1, 0x26, 0x61, 0xD7, 0x24, 0xEC, 0x9A, 0xB1, 0xF1, 0x9A, 0x84,
0x05, 0x50, 0x23, 0x41, 0x41, 0x90, 0x72, 0x4F, 0x31, 0x41, 0x28, 0x16, 0xC6, 0xEC, 0x1C, 0xED,
0xD6, 0x79, 0xC0, 0x06, 0x76, 0xEB, 0x71, 0x35, 0x20, 0xF2, 0x62, 0xA0, 0x4A, 0xC5, 0x29, 0xC4,
0x7C, 0x32, 0x02, 0xA1, 0x81, 0x2F, 0x24, 0x48, 0xDC, 0x48, 0xE2, 0x31, 0x4A, 0x62, 0xE1, 0xC2,
0x59, 0x73, 0x00, 0xC7, 0x1D, 0x0C, 0x3B, 0xD7, 0xD3, 0x8D, 0x72, 0x33, 0x50, 0x9B, 0xD3, 0x32,
0x69, 0x38, 0x33, 0xAC, 0x29, 0xCC, 0xBD, 0xEF, 0x5C, 0xAE, 0xE4, 0x05, 0x2E, 0x86, 0x52, 0x14,
0x3D, 0xBC, 0xF8, 0x94, 0x86, 0x8E, 0x26, 0x05, 0x33, 0x47, 0x03, 0xB7, 0x63, 0x9C, 0xA5, 0xBC,
0x98, 0x1B, 0x3F, 0x1B, 0xF7, 0x3E, 0xE9, 0xAC, 0x98, 0xAA, 0x09, 0x7C, 0x7E, 0xF0, 0x1A, 0xA6,
0xE7, 0xFF, 0x7D, 0xEF, 0xF5, 0x4B, 0xF7, 0x6E, 0x0F, 0x7C, 0xBA, 0x78, 0x1D, 0xA2, 0x34, 0x97,
0x77, 0x70, 0xAD, 0x72, 0x52, 0x46, 0x48, 0xA3, 0xC4, 0x4B, 0x93, 0x38, 0x8B, 0x7E, 0x4A, 0x31,
0x01, 0xEE, 0x7C, 0x01, 0xF2, 0x55, 0x14, 0x77, 0x50, 0xD3, 0xF1, 0xC9, 0xCE, 0xD7, 0xCA, 0x4F,
0x07, 0x0A, 0x59, 0x2E, 0x6A, 0x1D, 0x76, 0x8B, 0x96, 0xBB, 0xF9, 0x36, 0x7B, 0x77, 0xEC, 0xE2,
0xD3, 0x13, 0xFB, 0x00, 0x3A, 0x63, 0xE2, 0xE9, 0xE1, 0x00, 0x61, 0xFC, 0xBA, 0x98, 0xD9, 0x25,
0x77, 0xCA, 0x5D, 0x2F, 0xDA, 0xEE, 0x50, 0x4C, 0xC0, 0x96, 0x99, 0x0F, 0x3E, 0x54, 0x4C, 0xE7,
0xCD, 0x6C, 0xB8, 0x37, 0x73, 0x06, 0xC2, 0x76, 0xF1, 0x0E, 0xD6, 0x29, 0x3E, 0xFE, 0x80, 0x29,
0x1B, 0xF2, 0xB1, 0x69, 0x76, 0xA6, 0xD8, 0xD6, 0x1E, 0x3C, 0x2F, 0x85, 0x7B, 0xEB, 0xE0, 0x0D,
0x2D, 0xF9, 0x4B, 0x41, 0x40, 0x59, 0x08, 0xBA, 0x82, 0xF2, 0x80, 0x58, 0x22, 0xAD, 0xE3, 0xED,
0xFB, 0x36, 0x0F, 0xC4, 0x65, 0x15, 0x92, 0x3F, 0xDF, 0x75, 0x75, 0x92, 0x73, 0x92, 0x66, 0x43,
0x52, 0x52, 0xDB, 0x3D, 0x58, 0x1A, 0x4C, 0x00, 0xA3, 0x0C, 0xCB, 0xD2, 0x80, 0x33, 0x08, 0xC4,
0x24, 0x0C, 0x85, 0xE9, 0x41, 0x5E, 0x54, 0x7A, 0x66, 0x97, 0xCC, 0xB0, 0xA4, 0x04, 0x4E, 0x8E,
0x73, 0x70, 0x95, 0xFE, 0x0D, 0x04, 0x26, 0xFE, 0xE7, 0x6E, 0x06, 0xE1, 0x7F, 0x30, 0x6C, 0xFE,
0x2F, 0xB1, 0x76, 0x35, 0x0D, 0x5F, 0x3E, 0xC7, 0xC0, 0x64, 0xC5, 0xB9, 0x84, 0xCD, 0x7E, 0x5F,
0x30, 0x5A, 0xC1, 0x4A, 0x4C, 0xAB, 0x5A, 0x3C, 0x11, 0xE3, 0xE9, 0x0E, 0xB1, 0x1F, 0x70, 0x38,
0xEF, 0xB0, 0x2E, 0x3A, 0x5D, 0x30, 0x0A, 0x64, 0x81, 0x85, 0x81, 0x62, 0xD4, 0x78, 0xC3, 0x1A,
0x58, 0xB0, 0xDC, 0x75, 0x86, 0x88, 0x30, 0xE8, 0x02, 0x44, 0xB5, 0x1A, 0x23, 0x61, 0x11, 0xF1,
0xF4, 0xFB, 0xB0, 0x61, 0xA6, 0xE6, 0x0D, 0x18, 0x53, 0x22, 0xEC, 0x34, 0x1C, 0x4B, 0x2A, 0x27,
0x79, 0xC4, 0xE7, 0x45, 0xAA, 0x42, 0x86, 0xA6, 0xE7, 0xDA, 0x05, 0xAA, 0x3A, 0x53, 0xFE, 0xFF,
0xF0, 0x79, 0xE6, 0xC2, 0xD7, 0x04, 0x14, 0xF9, 0x43, 0xE3, 0xAA, 0x02, 0x76, 0x96, 0x2A, 0x86,
0xB8, 0xCC, 0x13, 0xA9, 0x1C, 0x2C, 0x6A, 0x60, 0xFF, 0x9A, 0xFE, 0x3B, 0x9D, 0x69, 0x14, 0x93,
0x3C, 0x49, 0xC9, 0x34, 0x8F, 0xB2, 0x86, 0x55, 0x0B, 0x46, 0xEF, 0xE9, 0x47, 0x24, 0xC5, 0xBF,
0x36, 0x14, 0x4B, 0xD6, 0x83, 0xED, 0x52, 0xAD, 0x90, 0x4F, 0x01, 0x25, 0x34, 0xFE, 0x8C, 0x19,
0x09, 0xFB, 0x69, 0x3A, 0x34, 0x56, 0xE5, 0x30, 0xB2, 0xA0, 0xAB, 0x4D, 0x4B, 0xA4, 0x1A, 0x2C,
0x7C, 0x61, 0x69, 0xCD, 0x90, 0x6C, 0xC3, 0x08, 0xED, 0x82, 0xD9, 0x73, 0x41, 0xBE, 0x02, 0x89,
0xA8, 0x15, 0xEF, 0x09, 0xC3, 0x9C, 0x96, 0x18, 0xA1, 0x99, 0x2C, 0x5F, 0x38, 0xC5, 0x53, 0x1B,
0x06, 0xF2, 0x9A, 0xD8, 0x26, 0x3F, 0xE5, 0x76, 0xBE, 0x2C, 0xE7, 0x94, 0x8D, 0x71, 0x8E, 0xAB,
0x26, 0xBC, 0x8E, 0x90, 0xEE, 0x8B, 0x37, 0x4B, 0x4A, 0xBF, 0xD9, 0x15, 0x51, 0xE6, 0x6C, 0xF4,
0x30, 0x2F, 0xAF, 0xDE, 0xFC, 0x47, 0xA4, 0x7A, 0x25, 0x15, 0xB0, 0x16, 0x50, 0x30, 0x70, 0x52,
0xC5, 0x8A, 0x02, 0xF3, 0x56, 0x6E, 0x37, 0xE1, 0xCD, 0x63, 0xC9, 0xCA, 0xB2, 0xD9, 0x84, 0xB0,
0x14, 0xC2, 0x4C, 0x83, 0x35, 0x66, 0xA9, 0x7C, 0x76, 0x73, 0x92, 0x3D, 0xCC, 0x24, 0x38, 0x9B,
0x3B, 0xA6, 0xCA, 0x15, 0x05, 0xA5, 0xF3, 0x9C, 0x15, 0xB2, 0x33, 0xEB, 0x8C, 0xD2, 0x71, 0x3D,
0x5B, 0x18, 0xC8, 0x22, 0x93, 0xD5, 0xFC, 0xDB, 0x15, 0xCB, 0x25, 0x6F, 0xE5, 0xC6, 0x6C, 0xB8,
0x81, 0xB9, 0xE2, 0x19, 0x83, 0xAB, 0xAE, 0xB9, 0x78, 0x95, 0x8B, 0x66, 0xCC, 0x3E, 0x6F, 0x13,
0xBF, 0x5F, 0xC5, 0xD8, 0x4D, 0x1F, 0xB1, 0x24, 0x1E, 0x40, 0xB5, 0x1A, 0xBF, 0xE3, 0xBE, 0x7A,
0x16, 0xC3, 0xD7, 0xDC, 0xC0, 0x91, 0x2B, 0x69, 0x9C, 0x4A, 0xD5, 0x7E, 0xC9, 0xBD, 0x97, 0xBC,
0xFA, 0x43, 0x70, 0x21, 0xA9, 0xC3, 0x16, 0x1B, 0xFA, 0x2B, 0x57, 0x22, 0xB4, 0x39, 0xC2, 0x94,
0x36, 0x78, 0x7C, 0xAB, 0xF7, 0x5C, 0x31, 0x66, 0xEF, 0xCD, 0x6E, 0x3C, 0x34, 0x3E, 0x8D, 0xF2,
0x93, 0x63, 0x03, 0xD5, 0x60, 0x0C, 0x66, 0xCE, 0xA5, 0xC1, 0xB8, 0xAA, 0x00, 0xEF, 0x52, 0x93,
0xD8, 0xF1, 0x34, 0xB4, 0xED, 0x29, 0x0B, 0x53, 0x37, 0xAF, 0xA8, 0xD1, 0x5D, 0x05, 0xB2, 0x08,
0x02, 0x96, 0x15, 0x2C, 0x47, 0x6D, 0x59, 0x8E, 0x6A, 0x5C, 0x39, 0xAA, 0x90, 0x22, 0x69, 0xBE,
0xAB, 0x49, 0x95, 0xCF, 0x02, 0x57, 0x9E, 0xC9, 0x5D, 0xED, 0xAA, 0xE6, 0xB0, 0x01, 0xF6, 0xFF,
0xA8, 0xC2, 0x54, 0xB7, 0x93, 0xA5, 0x3A, 0x35, 0x97, 0xFF, 0x1F, 0x78, 0xDC, 0xD6, 0xF4, 0x17,
0x0D, 0x7D, 0x2E, 0xE5, 0x2F, 0xCD, 0x2F, 0xD7, 0xE3, 0x2E, 0xB5, 0xDC, 0xF5, 0x61, 0xDF, 0x70,
0xBC, 0xDE, 0x72, 0x2C, 0x92, 0xBE, 0x66, 0x9E, 0xF0, 0xBA, 0x9A, 0xE5, 0xE2, 0x85, 0x20, 0x1D,
0xE3, 0x12, 0xF4, 0xFA, 0xAC, 0x0C, 0xB7, 0xF7, 0x6C, 0xC9, 0xCA, 0x7F, 0x89, 0x39, 0x75, 0x89,
0xC9, 0xD3, 0x2B, 0x36, 0x13, 0x1F, 0x7E, 0x65, 0x17, 0xB8, 0x4B, 0x5D, 0xC2, 0xDC, 0x54, 0x65,
0x34, 0xDC, 0xB0, 0x6E, 0x2A, 0x55, 0x4C, 0xC8, 0xDF, 0xF7, 0xC7, 0x1A, 0x29, 0xF3, 0x52, 0x22,
0x17, 0x6C, 0x6F, 0x0D, 0xCE, 0xB7, 0x62, 0x65, 0xC9, 0x9D, 0x97, 0xB2, 0xF4, 0x6A, 0x55, 0xCD,
0xB5, 0x14, 0xA9, 0xAA, 0xEC, 0x69, 0x9F, 0x2F, 0x8A, 0xC8, 0x5D, 0x62, 0x9B, 0xDF, 0xFB, 0xE1,
0x9C, 0xE0, 0xA4, 0x0C, 0x0A, 0xA5, 0xBA, 0xE4, 0x1D, 0x3B, 0xD7, 0x0A, 0x41, 0x14, 0x4B, 0x66,
0x5C, 0x31, 0x6F, 0x69, 0xB7, 0x2C, 0x2B, 0xAD, 0x46, 0x7D, 0x63, 0x7B, 0xF7, 0xD2, 0xFB, 0xD7,
0x6D, 0xF3, 0x5E, 0xC6, 0xA8, 0xF4, 0x84, 0x5E, 0xE3, 0x7D, 0xA1, 0x97, 0x75, 0xF4, 0x4A, 0x9F,
0xA1, 0x57, 0x7C, 0x19, 0x08, 0xBD, 0x92, 0x1D, 0xBD, 0x8A, 0x1D, 0xBD, 0x7E, 0xBB, 0xA3, 0x57,
0xBC, 0x63, 0xCC, 0x5C, 0x73, 0xBC, 0x4E, 0xDF, 0x14, 0xFF, 0x1E, 0xF2, 0x16, 0xD1, 0xF9, 0x7B,
0x15, 0x08, 0x79, 0x53, 0xBD, 0x40, 0x22, 0xD8, 0x22, 0x5B, 0x42, 0x4E, 0x7B, 0xBD, 0xBD, 0x67,
0x20, 0xA5, 0x23, 0xE4, 0xEE, 0x9C, 0x34, 0x7E, 0xDD, 0xF6, 0x4D, 0xC8, 0x76, 0xE1, 0x6A, 0x7C,
0x62, 0x94, 0xF4, 0x4B, 0xFF, 0xD4, 0x37, 0x25, 0x39, 0x2D, 0xD4, 0x6E, 0xD4, 0x5C, 0x25, 0xD4,
0x7F, 0xC4, 0x9D, 0xD1, 0xA8, 0xFF, 0xBD, 0x51, 0x76, 0x50, 0x55, 0x23, 0x55, 0xAA, 0xEC, 0x85,
0xCD, 0x1F, 0x32, 0xB8, 0xA0, 0x0C, 0xCC, 0xC8, 0x60, 0xD3, 0x33, 0xC4, 0xF5, 0x19, 0x8C, 0x79,
0x06, 0x1E, 0x65, 0x44, 0x0C, 0x22, 0xA2, 0xE1, 0xF7, 0x34, 0x72, 0x31, 0x1D, 0xB0, 0x42, 0x35,
0xA2, 0x71, 0x9B, 0x69, 0x9D, 0x41, 0xC4, 0x32, 0x98, 0xFF, 0x0C, 0x96, 0xE2, 0xA6, 0x7E, 0xDE,
0x35, 0xEF, 0xEA, 0x36, 0xCC, 0x41, 0x3A, 0xA9, 0xA3, 0xC2, 0xFE, 0x0E, 0x0E, 0x01, 0x26, 0xE1,
0x0F, 0x8F, 0x4F, 0x10, 0xD0, 0xF8, 0x88, 0x65, 0x3A, 0x50, 0xDC, 0xC2, 0x52, 0x8B, 0xDB, 0x95,
0x07, 0x02, 0xC8, 0xB7, 0xFB, 0x98, 0x31, 0x28, 0x8F, 0xF6, 0x7C, 0x9E, 0xDE, 0x94, 0xCF, 0xB3,
0x58, 0x43, 0x7F, 0x3C, 0x16, 0x91, 0x82, 0xF1, 0x9E, 0x44, 0xFC, 0x05, 0xB5, 0x2E, 0xA5, 0x58,
0xF3, 0xDC, 0x70, 0x06, 0x0C, 0xDE, 0xA3, 0x1D, 0x90, 0xAF, 0x71, 0x2D, 0x5D, 0xA4, 0xB3, 0x0C,
0x0B, 0xB6, 0x37, 0x46, 0x37, 0xD5, 0xBE, 0xF6, 0x2E, 0xD7, 0x11, 0x10, 0x26, 0xC1, 0xFE, 0x18,
0x0B, 0x5A, 0x1C, 0x58, 0xD0, 0x8C, 0x16, 0x34, 0x13, 0xC5, 0xE7, 0x1A, 0xAF, 0x60, 0xC3, 0x9F,
0xD6, 0xE0, 0xB7, 0x65, 0xF1, 0x44, 0x83, 0x13, 0x6A, 0xF0, 0x4B, 0xCD, 0xED, 0x0C, 0x6E, 0x47,
0x06, 0x99, 0xF4, 0xD1, 0xE6, 0xEF, 0x10, 0x57, 0x62, 0x47, 0x2E, 0x76, 0x7B, 0xE9, 0xED, 0x8E,
0x50, 0x26, 0x15, 0x53, 0xE4, 0x9A, 0x0F, 0xA4, 0x9C, 0x7E, 0xBE, 0xA8, 0x3D, 0xE3, 0x29, 0xA9,
0x22, 0xB7, 0xCB, 0x61, 0x60, 0xE9, 0x2C, 0x27, 0x43, 0x02, 0xD6, 0x51, 0xA3, 0xE6, 0x4C, 0x9D,
0xD4, 0x2C, 0x22, 0xEF, 0x0E, 0x65, 0x3A, 0xDE, 0x9E, 0x14, 0x51, 0x12, 0x6B, 0x0A, 0x6B, 0xFE,
0xF4, 0xAB, 0xBB, 0xAF, 0x52, 0x6E, 0xE1, 0xC9, 0x0D, 0x6C, 0x70, 0x0A, 0x46, 0xB3, 0xE8, 0x99,
0xA1, 0xE9, 0x57, 0x6C, 0x05, 0x5A, 0x05, 0x84, 0xAD, 0xCE, 0x7D, 0x9B, 0xB6, 0xB7, 0x2C, 0x36,
0xA3, 0x7D, 0x3B, 0xA4, 0x34, 0x76, 0x17, 0x73, 0x41, 0x76, 0x3E, 0xD1, 0x58, 0xCE, 0x45, 0x46,
0x77, 0x94, 0xC9, 0x30, 0x9B, 0x31, 0x75, 0xE7, 0x80, 0x37, 0x70, 0x67, 0x86, 0x51, 0xCF, 0x9A,
0x44, 0x3D, 0x97, 0xB3, 0x2B, 0x09, 0x88, 0xCE, 0x96, 0x13, 0xB4, 0x98, 0x76, 0x9B, 0xE5, 0xF0,
0x33, 0x66, 0xF8, 0x61, 0xCB, 0x08, 0x40, 0x84, 0x37, 0x18, 0x45, 0x72, 0x23, 0x4B, 0xD9, 0x2D,
0xE3, 0x47, 0x58, 0x06, 0x31, 0xF9, 0x07, 0x39, 0x4F, 0x91, 0x12, 0xB2, 0x1C, 0x9D, 0x28, 0xF3,
0x61, 0x9B, 0x85, 0xA0, 0x5B, 0xD0, 0x9D, 0x05, 0xED, 0x9A, 0x95, 0x04, 0xA6, 0xB7, 0x7E, 0x3F,
0xF8, 0x1D, 0x66, 0xDE, 0xF6, 0xEA, 0x2B, 0xCC, 0x3D, 0x75, 0xEA, 0x09, 0x28, 0xB9, 0x69, 0x25,
0xB7, 0x2B, 0x1A, 0x39, 0x77, 0x20, 0x57, 0x1B, 0x89, 0xCE, 0x11, 0x90, 0x2F, 0xC7, 0x53, 0x1B,
0x97, 0x53, 0xFA, 0x72, 0x7A, 0x2F, 0x85, 0xA2, 0x82, 0xE8, 0xA7, 0x0C, 0x3E, 0xE3, 0x69, 0x39,
0xFB, 0x93, 0xEC, 0x50, 0xBE, 0xEA, 0x13, 0xF8, 0x81, 0x8D, 0x94, 0x9B, 0x43, 0x12, 0x1E, 0xA1,
0x0E, 0x06, 0x8D, 0x64, 0x6B, 0xA1, 0x44, 0x18, 0xAF, 0xA2, 0x18, 0xD3, 0x13, 0x47, 0xB1, 0xB0,
0x5D, 0x28, 0xE6, 0xCB, 0xFF, 0x3A, 0xEC, 0x29, 0x76, 0x46, 0x5F, 0xA3, 0xE9, 0x66, 0x52, 0x49,
0xC8, 0x37, 0x0B, 0xC5, 0xC2, 0x5D, 0x0C, 0xBC, 0x3F, 0xDF, 0xDA, 0x1F, 0xCF, 0xD1, 0x1D, 0xBF,
0x4F, 0xCC, 0x2A, 0x76, 0xE3, 0xC9, 0x05, 0xBD, 0x76, 0xCD, 0x76, 0xF3, 0xF8, 0xC4, 0x9D, 0x40,
0xA8, 0x8D, 0x8B, 0xA4, 0x39, 0x9E, 0x23, 0x93, 0xF9, 0x24, 0x62, 0x74, 0x6E, 0x94, 0xAF, 0x79,
0xB8, 0x06, 0x35, 0x52, 0xC5, 0xF1, 0x31, 0x8F, 0xE4, 0x9A, 0x4A, 0x0A, 0x64, 0x3F, 0x60, 0x1A,
0x10, 0x6F, 0xF9, 0xCB, 0xF3, 0x34, 0xE0, 0x42, 0x7D, 0x67, 0x90, 0x53, 0xC0, 0x5E, 0x0E, 0x3A,
0xCE, 0x9F, 0xA7, 0x2B, 0xF0, 0xB4, 0x9F, 0xF3, 0x74, 0xC5, 0x93, 0xA3, 0x8D, 0xA3, 0x97, 0x5D,
0xEA, 0x97, 0x10, 0xC8, 0x92, 0xEC, 0xC3, 0xCE, 0x89, 0xE4, 0xB3, 0xB7, 0x54, 0x09, 0x94, 0xFE,
0x9C, 0x05, 0x88, 0x04, 0xBE, 0x28, 0x77, 0x27, 0xAF, 0x4F, 0xDD, 0x18, 0xCB, 0x7C, 0xB4, 0xE3,
0xD1, 0x83, 0x2B, 0x51, 0x84, 0xC8, 0xFB, 0x11, 0x22, 0x7F, 0x1E, 0xE5, 0xE7, 0x27, 0xC8, 0x51,
0xFE, 0x52, 0x55, 0xCC, 0xFB, 0xBD, 0x3B, 0xEA, 0xFF, 0x00, 0xD5, 0x5A, 0x3D, 0x8C, 0xDC, 0xD6,
0xBD, 0x5A, 0x3D, 0x8C, 0xE5, 0x56, 0x15, 0xBE, 0xB6, 0x77, 0xD7, 0x12, 0x16, 0x72, 0x8A, 0x2C,
0x44, 0x6C, 0xE1, 0x26, 0x55, 0x22, 0xE2, 0x36, 0xC5, 0x8E, 0xAF, 0xA3, 0x88, 0x3E, 0xC5, 0x4E,
0x56, 0x62, 0x8A, 0xD0, 0x12, 0x09, 0x4D, 0x93, 0x82, 0x62, 0xE6, 0xD9, 0x90, 0x82, 0x22, 0x05,
0x2B, 0xA1, 0x20, 0xA4, 0x34, 0x14, 0x09, 0x82, 0xB7, 0xC5, 0x36, 0x14, 0x20, 0xBD, 0x79, 0xB6,
0x40, 0xA2, 0xA1, 0x88, 0x41, 0x42, 0x94, 0x38, 0x62, 0xB7, 0x48, 0x28, 0x62, 0x81, 0x23, 0xFC,
0x36, 0xB6, 0x2F, 0xDF, 0x77, 0xAE, 0xFD, 0xDE, 0xCC, 0xEE, 0xEC, 0x6F, 0x56, 0xF8, 0xF9, 0x1E,
0xCF, 0xF3, 0xBB, 0x3F, 0xE7, 0x9E, 0xFF, 0x73, 0xEE, 0x68, 0xA5, 0xCF, 0xF9, 0x24, 0xEA, 0x95,
0xE9, 0xF3, 0x6D, 0x75, 0x41, 0xF1, 0xD2, 0xAA, 0x48, 0x8B, 0x3C, 0xC3, 0xB7, 0x55, 0x50, 0x4D,
0xB7, 0x5F, 0xE2, 0x3E, 0xC2, 0xED, 0xF1, 0x7E, 0xD5, 0xCB, 0x95, 0xFA, 0xAD, 0x53, 0xA3, 0x73,
0xEB, 0x11, 0x36, 0x5E, 0xA5, 0x94, 0xD3, 0x78, 0xA5, 0x52, 0x6E, 0xE3, 0x13, 0xD6, 0x01, 0xA0,
0x57, 0x05, 0xB9, 0xFA, 0xDA, 0xCD, 0xBB, 0xB7, 0x3E, 0x4B, 0x43, 0x83, 0x2B, 0xBF, 0x78, 0xF0,
0xB7, 0xC3, 0xFD, 0x54, 0x05, 0x79, 0x8C, 0x41, 0x61, 0x9A, 0x08, 0xDC, 0x03, 0x8C, 0xD4, 0x55,
0xC0, 0x58, 0x1D, 0x61, 0x79, 0x74, 0x1C, 0x27, 0xA8, 0xF6, 0x54, 0x8B, 0xF7, 0x57, 0x55, 0x03,
0x78, 0xE4, 0x10, 0xAE, 0x5C, 0xC2, 0xCE, 0xB5, 0x8B, 0x57, 0xF3, 0xE2, 0xCA, 0x41, 0xC3, 0x6A,
0xD2, 0xAE, 0xDC, 0x50, 0xBE, 0x31, 0x55, 0x64, 0x4C, 0x93, 0x7D, 0xF0, 0xF3, 0xE6, 0x04, 0xEF,
0xFA, 0xA9, 0x0D, 0x68, 0xD9, 0x7B, 0xB9, 0x8A, 0x4D, 0x8E, 0x3E, 0x9D, 0x52, 0x19, 0xFE, 0x08,
0x4C, 0xA3, 0x82, 0x9F, 0x74, 0x18, 0xDB, 0xA3, 0xAD, 0xF8, 0x4C, 0x83, 0x1F, 0xF5, 0xE3, 0xC1,
0xAD, 0x16, 0x68, 0x54, 0x19, 0x31, 0xB9, 0x92, 0xEF, 0xE6, 0x67, 0xB3, 0x97, 0x3B, 0x72, 0x07,
0x2A, 0x34, 0x25, 0x90, 0x57, 0xF1, 0xEB, 0x35, 0x30, 0x56, 0x09, 0x10, 0x5D, 0x11, 0xF7, 0x86,
0xD8, 0x1F, 0x01, 0xCD, 0x46, 0x1E, 0x24, 0x0C, 0x1E, 0x61, 0x2E, 0x8F, 0x48, 0x36, 0xD6, 0x26,
0x97, 0x6E, 0xA8, 0x3D, 0xA7, 0x59, 0xE9, 0x5E, 0xBD, 0xE2, 0x36, 0xED, 0xBA, 0x50, 0xD1, 0x07,
0x7F, 0x6D, 0xDA, 0x30, 0x0D, 0xCC, 0xBB, 0x7F, 0x69, 0xC2, 0xDC, 0xF9, 0x7D, 0xF8, 0x87, 0x86,
0xC4, 0x8C, 0xDC, 0x9A, 0x13, 0x24, 0x9E, 0x3C, 0x8E, 0xEC, 0xA3, 0x93, 0x97, 0x6E, 0xE3, 0xB2,
0x8B, 0x57, 0x39, 0x18, 0xA0, 0xF0, 0x57, 0x17, 0xA5, 0x44, 0x2B, 0x1B, 0xC9, 0x4D, 0x15, 0x75,
0x82, 0xAB, 0x93, 0xCE, 0x58, 0x47, 0x86, 0x7F, 0x16, 0x06, 0x34, 0x74, 0xC6, 0x03, 0xE0, 0xE8,
0xB5, 0x0E, 0xFA, 0x00, 0xCD, 0x7E, 0x07, 0xF9, 0xC6, 0xE9, 0x3C, 0xD2, 0x7F, 0x88, 0x48, 0x6D,
0x5D, 0x70, 0xDB, 0xD1, 0xC0, 0x19, 0x32, 0xBE, 0x76, 0xCD, 0xA0, 0xBC, 0x3A, 0x68, 0xB3, 0xDC,
0xAF, 0xA2, 0x32, 0xAA, 0xC3, 0x52, 0xA7, 0x7E, 0x07, 0x88, 0x59, 0x75, 0xAA, 0x49, 0x5B, 0x59,
0xBE, 0x27, 0x0C, 0x7A, 0x15, 0x97, 0x4A, 0x8F, 0x2A, 0xDC, 0x5C, 0x37, 0x18, 0x61, 0x4C, 0x17,
0xD6, 0xC0, 0x21, 0xF7, 0x7A, 0xE5, 0xBC, 0x41, 0xC6, 0xF2, 0x3A, 0x3A, 0x3A, 0x7A, 0x15, 0x22,
0xB9, 0xC2, 0xC2, 0x8D, 0x5F, 0x85, 0x65, 0x94, 0xC7, 0x29, 0xA5, 0x74, 0x81, 0xCF, 0xC9, 0xA9,
0x0F, 0xBF, 0xF3, 0x7D, 0x9C, 0x87, 0x65, 0x58, 0xF9, 0xB5, 0x4B, 0xA4, 0x8F, 0xD5, 0x85, 0x15,
0xDE, 0x45, 0x69, 0x58, 0xFA, 0xB5, 0xD7, 0xB8, 0xDC, 0x54, 0x8F, 0xCF, 0x3C, 0x60, 0xFE, 0x9C,
0xA8, 0x81, 0xEF, 0x5D, 0xAE, 0xD1, 0x04, 0x55, 0x98, 0x6B, 0x0C, 0x7D, 0xCE, 0x12, 0xC8, 0xAF,
0x6D, 0xA3, 0x4C, 0xA1, 0xBD, 0xE8, 0x95, 0x17, 0xB3, 0x9F, 0xFD, 0x69, 0x2C, 0x20, 0x07, 0xDF,
0x2A, 0xC6, 0x8F, 0x54, 0x88, 0x8D, 0x6B, 0x30, 0xF7, 0x04, 0x9B, 0xEC, 0xE3, 0xDC, 0x6B, 0xC3,
0x4A, 0x68, 0xFF, 0x9C, 0x43, 0xA1, 0x57, 0x8F, 0x00, 0x19, 0xA6, 0x49, 0x05, 0x5C, 0xBE, 0xFE,
0xC5, 0xFE, 0xA3, 0x46, 0x28, 0xE0, 0x13, 0x92, 0x08, 0x25, 0x89, 0x90, 0x40, 0xA0, 0x8E, 0x88,
0x26, 0x39, 0x97, 0xE2, 0xDE, 0xDF, 0x3F, 0xA3, 0xAC, 0x0A, 0x2A, 0xAC, 0xB0, 0x3B, 0x45, 0xD9,
0x23, 0x0B, 0x9D, 0x56, 0x98, 0xA9, 0xA8, 0xA1, 0x01, 0xB6, 0x13, 0x52, 0x88, 0x72, 0x6A, 0x99,
0x48, 0x05, 0xF6, 0x2C, 0xDD, 0x14, 0xF8, 0xEC, 0x4E, 0x5D, 0xFD, 0x87, 0x74, 0xB5, 0x57, 0x0C,
0x91, 0x52, 0xD0, 0x09, 0xE5, 0x51, 0x70, 0x82, 0xDA, 0xE9, 0xD9, 0xF9, 0x84, 0xBD, 0xC1, 0x8D,
0x54, 0x98, 0xA2, 0x88, 0xAE, 0x05, 0x51, 0xFE, 0x30, 0x30, 0xF5, 0x93, 0x61, 0x32, 0x01, 0xA6,
0x5A, 0x63, 0x52, 0xA0, 0xE5, 0x6D, 0xAE, 0x74, 0x5C, 0x08, 0x08, 0xC5, 0x22, 0xAD, 0x14, 0x11,
0xEC, 0x04, 0x92, 0x58, 0xD4, 0xDE, 0x97, 0x75, 0xF4, 0xCF, 0x5A, 0xBF, 0x5E, 0x7F, 0x1D, 0x6F,
0x9E, 0xE2, 0x16, 0x1A, 0x0E, 0x50, 0x7B, 0x53, 0xEA, 0x3B, 0x87, 0x4D, 0x02, 0xF9, 0xBF, 0x00,
0x76, 0x4E, 0xF4, 0x98, 0x9A, 0x33, 0xD1, 0xC5, 0x6F, 0x2C, 0x3D, 0x34, 0xB0, 0x58, 0xE3, 0x15,
0x5E, 0x07, 0xC4, 0x2A, 0xBF, 0xEF, 0x6B, 0xB1, 0x5C, 0x2E, 0x29, 0x28, 0x6C, 0x56, 0xE3, 0x8A,
0x91, 0xCA, 0x86, 0x4D, 0x60, 0x99, 0x16, 0xFC, 0x71, 0x39, 0xB7, 0x12, 0x0D, 0x3A, 0xD9, 0x02,
0xBE, 0x93, 0xC5, 0x03, 0xAC, 0xF4, 0x76, 0x81, 0xA5, 0xCC, 0xA0, 0xA6, 0xBD, 0xAB, 0x2B, 0x32,
0x62, 0x14, 0x9D, 0x53, 0x13, 0x37, 0x4E, 0x81, 0x0C, 0x74, 0x71, 0x06, 0xB5, 0x4E, 0xD1, 0xAF,
0xE0, 0x88, 0xC8, 0x8C, 0xC0, 0xF3, 0xB2, 0xA8, 0xBA, 0x4C, 0xEA, 0x11, 0x84, 0x04, 0x31, 0xC1,
0x9E, 0xEC, 0xAE, 0xA7, 0x9D, 0x06, 0xF0, 0x2B, 0x80, 0x10, 0x56, 0x0F, 0x9C, 0xEE, 0xC9, 0xE6,
0x9E, 0xE2, 0xD0, 0x5B, 0x1B, 0xE0, 0x57, 0x00, 0xB0, 0x27, 0xA2, 0xBE, 0xBD, 0xC8, 0x6E, 0x2B,
0xE0, 0xA5, 0xE5, 0x72, 0xFC, 0xE5, 0x84, 0xF8, 0xFD, 0x00, 0xFB, 0xAD, 0x62, 0xA0, 0x12, 0xF3,
0x39, 0xE1, 0x39, 0xB7, 0x64, 0x6E, 0x6F, 0xDF, 0x50, 0x99, 0xB5, 0xF2, 0xD5, 0x05, 0x15, 0x51,
0x48, 0x3B, 0x75, 0x1F, 0x07, 0xD8, 0xDC, 0x2E, 0x05, 0x07, 0x36, 0xAF, 0x65, 0x15, 0xB5, 0x26,
0xA6, 0xC5, 0xBE, 0x3C, 0xCB, 0xA2, 0xC3, 0x6F, 0x11, 0x41, 0x71, 0xE7, 0x9A, 0x72, 0x49, 0x34,
0x0A, 0xB1, 0x48, 0xB0, 0xB0, 0x0C, 0xA6, 0x40, 0x59, 0x21, 0x56, 0xFA, 0x12, 0xE8, 0xA2, 0x63,
0x18, 0x7A, 0xBD, 0x3E, 0xC0, 0x8E, 0x6A, 0xD0, 0x54, 0xE7, 0xA7, 0xC4, 0x35, 0x3A, 0x05, 0x20,
0x90, 0x31, 0x8C, 0x08, 0x7E, 0x03, 0x6B, 0xD7, 0xCA, 0x1B, 0xFC, 0x9E, 0xFC, 0x68, 0x94, 0xB8,
0x0D, 0x91, 0x45, 0xE1, 0xF1, 0x0E, 0xCC, 0xFC, 0xD8, 0x32, 0x94, 0x92, 0x33, 0x61, 0x53, 0x6F,
0xB1, 0xC9, 0xE7, 0x6E, 0x8B, 0x59, 0x0C, 0x9C, 0x7B, 0xFA, 0x6E, 0x31, 0x8F, 0xB7, 0x7D, 0xD7,
0x67, 0x64, 0x64, 0xD2, 0x42, 0xA8, 0x84, 0x43, 0x84, 0xFC, 0xCD, 0x75, 0xF0, 0xA8, 0x42, 0x27,
0x41, 0xD7, 0x2A, 0xD1, 0xA4, 0x49, 0x0B, 0x0A, 0x14, 0x78, 0x39, 0x50, 0xAE, 0x0A, 0x78, 0x28,
0x0C, 0x13, 0xE3, 0x01, 0xE5, 0xED, 0x82, 0x0E, 0xC3, 0x9C, 0x91, 0xEB, 0x08, 0x0D, 0x44, 0x1F,
0xC3, 0x2D, 0x80, 0x52, 0xCA, 0x2C, 0x2D, 0x1C, 0x9C, 0x67, 0x84, 0xC4, 0xE6, 0x86, 0xB2, 0x86,
0x5A, 0xAE, 0xC3, 0x81, 0xF6, 0x1F, 0x93, 0x06, 0x9B, 0xC3, 0x46, 0x45, 0x25, 0xF4, 0x00, 0x0A,
0x22, 0xA2, 0x92, 0x90, 0xC5, 0x34, 0xD9, 0xA0, 0xBC, 0x00, 0x7E, 0x15, 0x77, 0x80, 0x49, 0x1D,
0x93, 0x86, 0x5F, 0x1C, 0x18, 0x4E, 0x0A, 0xBE, 0x14, 0xDD, 0x4E, 0x94, 0x93, 0xFB, 0xE9, 0x07,
0xC4, 0x2F, 0x38, 0x50, 0x88, 0x80, 0x0E, 0x38, 0x30, 0xB7, 0xF1, 0xEA, 0x1A, 0xDA, 0x05, 0x6B,
0xF5, 0x52, 0xAD, 0xCE, 0xBB, 0x76, 0x16, 0xD1, 0x01, 0x5E, 0x71, 0x15, 0x57, 0x42, 0xD0, 0xFB,
0xAE, 0x88, 0x76, 0x35, 0xE7, 0xE6, 0x8F, 0x61, 0x31, 0x3B, 0xB5, 0xDD, 0x9A, 0xF2, 0x29, 0x35,
0x19, 0x9D, 0x01, 0xE9, 0x95, 0xD1, 0x23, 0x8C, 0xA7, 0xFF, 0xD0, 0xD5, 0xF4, 0xBB, 0x8A, 0x3B,
0x11, 0x43, 0x7E, 0x8F, 0x44, 0x47, 0x0B, 0xE2, 0x18, 0x88, 0x3C, 0x48, 0x07, 0x3F, 0x3D, 0x85,
0x18, 0x05, 0x45, 0xCF, 0xB6, 0xE1, 0xDE, 0x0B, 0x4A, 0xB5, 0xBC, 0xB7, 0xCB, 0x3B, 0xDB, 0x79,
0x62, 0xEB, 0x6F, 0xCF, 0xFB, 0xD3, 0xD9, 0xAE, 0x16, 0x8C, 0x34, 0x39, 0x95, 0xF5, 0xD1, 0x2E,
0xF9, 0xA5, 0xBB, 0x79, 0x82, 0x77, 0xCE, 0x2C, 0x76, 0xC9, 0xB4, 0xD4, 0x67, 0xA5, 0x3F, 0x2A,
0xF2, 0x17, 0x45, 0xE0, 0xFC, 0xCA, 0x3A, 0x8A, 0xD3, 0x1E, 0x00, 0x0C, 0x85, 0x6D, 0x51, 0x7E,
0x3B, 0x7D, 0xA5, 0xF9, 0x60, 0x1C, 0x36, 0xB5, 0xE7, 0x4F, 0x4D, 0x48, 0x07, 0x61, 0xA6, 0x36,
0xCE, 0xBF, 0x39, 0xB7, 0x3E, 0x14, 0xD4, 0x0C, 0x95, 0xC6, 0x33, 0xEF, 0x1F, 0x52, 0x5D, 0x6B,
0x04, 0x07, 0xD6, 0x96, 0x41, 0x18, 0x3A, 0x91, 0x79, 0x88, 0xFE, 0x45, 0xC8, 0xBF, 0x5F, 0x32,
0xA0, 0x40, 0x78, 0x14, 0x82, 0xDD, 0x5D, 0x84, 0xE5, 0x11, 0x08, 0xC4, 0xE0, 0x4D, 0xA2, 0x8E,
0x12, 0x30, 0x08, 0x8F, 0x3D, 0xF8, 0x34, 0x79, 0x34, 0x74, 0x86, 0xF6, 0xB1, 0xE2, 0xCB, 0x63,
0xD5, 0xED, 0xA1, 0xCB, 0xA0, 0x60, 0xCC, 0x4F, 0x5C, 0xE3, 0xD5, 0xB1, 0xD2, 0x9F, 0x7F, 0xFC,
0x79, 0x19, 0xA9, 0x70, 0xF4, 0x0C, 0x55, 0xE2, 0x0D, 0x7C, 0xB8, 0x49, 0xAA, 0x5A, 0x6B, 0x91,
0x1E, 0xAD, 0xCB, 0x8F, 0x37, 0xDF, 0xB9, 0x95, 0x93, 0xD9, 0x46, 0x88, 0xFA, 0xB2, 0xF9, 0x70,
0x2B, 0x93, 0x34, 0x11, 0xE4, 0xAA, 0x08, 0x90, 0xB0, 0xD9, 0xF9, 0x2F, 0xD5, 0xD9, 0xFB, 0x37,
0xB5, 0xD5, 0xFB, 0x44, 0x84, 0xB4, 0x12, 0x0B, 0x5B, 0x9E, 0x88, 0x86, 0x2F, 0xC4, 0xE0, 0x58,
0x7B, 0x07, 0x33, 0x02, 0x81, 0x26, 0xE9, 0xC4, 0x49, 0x42, 0x61, 0x61, 0xA0, 0xA1, 0x34, 0xD0,
0x5B, 0xEF, 0xEE, 0xF2, 0x66, 0x4B, 0x62, 0x83, 0x04, 0xB6, 0x43, 0x54, 0x52, 0xD5, 0x39, 0x44,
0xDB, 0xE1, 0xE9, 0x42, 0x26, 0xB4, 0x93, 0x17, 0xE6, 0x53, 0x5A, 0x20, 0x8C, 0x07, 0x60, 0x4C,
0x03, 0xBD, 0xC4, 0x2F, 0x05, 0x81, 0x3E, 0x0B, 0xD6, 0x00, 0x30, 0x15, 0x66, 0x59, 0x48, 0xE7,
0x94, 0xA0, 0xE5, 0x3B, 0x19, 0xA1, 0x53, 0x1A, 0x7B, 0xCE, 0x1A, 0x95, 0x33, 0x88, 0x09, 0x34,
0xDF, 0x19, 0x35, 0x2E, 0x6F, 0x0D, 0x76, 0x84, 0x11, 0x4F, 0x62, 0xA3, 0x5C, 0xFE, 0x09, 0x7B,
0xDE, 0x38, 0xE6, 0xB3, 0x5B, 0x95, 0x37, 0x28, 0x27, 0x0F, 0x49, 0x80, 0xB8, 0x54, 0xD6, 0x2F,
0x5A, 0xDF, 0xB4, 0xDE, 0x3A, 0xB7, 0xE1, 0xAC, 0x53, 0xD8, 0xBD, 0x5B, 0xEF, 0x1C, 0x19, 0x87,
0x69, 0x31, 0x8B, 0xA4, 0x68, 0xD0, 0xC9, 0x3A, 0xEF, 0x1F, 0x94, 0x34, 0xA8, 0x54, 0x79, 0x0A,
0x0D, 0xFC, 0xD8, 0x99, 0x9D, 0x33, 0x6E, 0x83, 0xE8, 0x13, 0x67, 0x52, 0x3D, 0xA0, 0x0F, 0xF3,
0x5A, 0x71, 0x13, 0xF4, 0xD8, 0xFD, 0xEC, 0xDC, 0x2C, 0x18, 0xB6, 0x60, 0xFB, 0x4E, 0xBA, 0x48,
0x67, 0x19, 0x16, 0x36, 0xD3, 0xF6, 0x61, 0x96, 0xC6, 0x65, 0x31, 0xCC, 0x0B, 0x6D, 0xC4, 0xC0,
0xD3, 0xE5, 0xD4, 0x4C, 0x00, 0xCE, 0xF1, 0x4B, 0xDB, 0x66, 0x96, 0xCB, 0x1C, 0x7D, 0x4A, 0xB6,
0x87, 0xF5, 0x63, 0x1B, 0x97, 0xCB, 0x1B, 0xD3, 0x9C, 0xF5, 0xE3, 0xCE, 0xFF, 0xA4, 0x6B, 0xCC,
0x6D, 0x72, 0x91, 0x34, 0xDB, 0x11, 0x09, 0x67, 0xEE, 0xDE, 0xEC, 0x3D, 0x48, 0xFC, 0x8B, 0x8C,
0x60, 0x45, 0xDA, 0xAC, 0x70, 0x3F, 0x80, 0x73, 0xCB, 0xDF, 0x90, 0x28, 0x46, 0x00, 0xDF, 0x65,
0xEC, 0xA2, 0xD9, 0x59, 0x93, 0x73, 0x9A, 0xAC, 0xD7, 0x08, 0xE0, 0x95, 0xA6, 0x6F, 0xD4, 0xAE,
0xB9, 0xF3, 0xA6, 0x91, 0x10, 0x81, 0x7A, 0x04, 0x23, 0x78, 0xF1, 0x0D, 0x86, 0xA1, 0x8E, 0xF0,
0xE6, 0xB1, 0x01, 0xF6, 0x79, 0x93, 0x19, 0x97, 0xB1, 0xE0, 0x89, 0xC6, 0xEE, 0x00, 0x72, 0x0A,
0x78, 0x3B, 0x7F, 0x43, 0x97, 0xFA, 0x34, 0x77, 0x4B, 0x67, 0x49, 0x7F, 0x89, 0xB0, 0x3E, 0x7C,
0x76, 0x77, 0x9C, 0xEF, 0x17, 0x0A, 0x01, 0x61, 0xD1, 0xC2, 0xDA, 0x91, 0x38, 0xA2, 0xDB, 0x99,
0xD5, 0xD6, 0x9E, 0xCB, 0x8A, 0x27, 0xEF, 0xAD, 0x1F, 0x05, 0xA7, 0x7A, 0xC6, 0x96, 0x3D, 0xC3,
0x87, 0x9E, 0xD4, 0xEE, 0x49, 0xFC, 0x91, 0x5A, 0xC1, 0xF0, 0x5B, 0x99, 0xF7, 0xA0, 0x81, 0x23,
0xC3, 0xA6, 0x9E, 0x4C, 0xEB, 0xC9, 0x48, 0x49, 0xB1, 0x30, 0x0B, 0x03, 0x68, 0x9F, 0xF3, 0xB9,
0x9C, 0x19, 0x5A, 0x3D, 0xEB, 0x77, 0x54, 0xAD, 0x1E, 0x53, 0x7C, 0x9E, 0xA4, 0x99, 0xD3, 0x62,
0x9D, 0xD3, 0x23, 0x31, 0xD8, 0xF0, 0x24, 0x80, 0xF5, 0x25, 0x6C, 0x82, 0x03, 0xA7, 0x13, 0x29,
0x7A, 0x7A, 0x00, 0x4D, 0x1D, 0xD4, 0xBA, 0x87, 0xCF, 0xD8, 0xD3, 0x3D, 0x6C, 0x1D, 0x72, 0x5F,
0xC4, 0x02, 0x9D, 0xEE, 0xA3, 0xDC, 0x69, 0x75, 0x1F, 0x96, 0x6E, 0xA3, 0x7B, 0xB8, 0x85, 0x5A,
0xF7, 0x48, 0x2F, 0x4B, 0xC0, 0x06, 0x49, 0x65, 0xEF, 0xB4, 0x08, 0xCF, 0xB0, 0x21, 0x98, 0xD7,
0x5E, 0xAD, 0x12, 0xC2, 0xC5, 0x31, 0xA1, 0xBD, 0xE1, 0x8B, 0x01, 0x61, 0x97, 0xB6, 0x10, 0xE6,
0x5E, 0x53, 0x6C, 0x57, 0x24, 0xDA, 0x8A, 0x38, 0xAD, 0x88, 0xCC, 0x0A, 0xC6, 0x4D, 0xAD, 0x68,
0x17, 0x56, 0x7D, 0x41, 0x00, 0x82, 0xAF, 0x7A, 0x68, 0xC4, 0x8A, 0xC4, 0x5E, 0xF5, 0xE0, 0xC4,
0xAA, 0x07, 0x4F, 0x56, 0xE4, 0xC2, 0x8A, 0xFC, 0x58, 0x91, 0xB0, 0x2B, 0x12, 0x76, 0xC5, 0xD8,
0x78, 0x45, 0xC2, 0x02, 0xA8, 0x81, 0x20, 0x23, 0x88, 0xB9, 0xA7, 0x90, 0xC0, 0x17, 0x0B, 0xD3,
0x6F, 0x1D, 0xED, 0xC6, 0x7A, 0xC0, 0x0A, 0x76, 0xEB, 0x4E, 0xD1, 0x29, 0xBF, 0x63, 0xA0, 0x4A,
0xC5, 0xC9, 0xC4, 0x7C, 0x32, 0x02, 0xA1, 0x81, 0x27, 0x73, 0xC3, 0x6A, 0x2D, 0xF1, 0x22, 0xF5,
0xCD, 0x6F, 0x6C, 0x38, 0xDB, 0x9F, 0x82, 0xC3, 0x16, 0xFA, 0x8D, 0xED, 0x69, 0x47, 0xD9, 0x19,
0xA8, 0xCD, 0x71, 0x1E, 0x55, 0x9C, 0x19, 0xD6, 0x14, 0xE6, 0xDE, 0xB5, 0x2E, 0x57, 0xF2, 0x02,
0x1B, 0x43, 0x29, 0x8A, 0x1E, 0x5E, 0x7C, 0x48, 0x43, 0x47, 0x93, 0x82, 0x99, 0x83, 0x8E, 0xDB,
0xE9, 0xAD, 0xA5, 0x3C, 0x99, 0x1A, 0xFF, 0xEE, 0xED, 0xFB, 0xA8, 0x31, 0x62, 0xAA, 0x46, 0xF0,
0xF9, 0xC6, 0x13, 0x98, 0x9E, 0xFF, 0xFB, 0xDE, 0xCB, 0x47, 0xEE, 0xDD, 0x9C, 0xF2, 0xE9, 0xE2,
0x75, 0x88, 0xD2, 0x54, 0xC5, 0xC1, 0xB5, 0x4C, 0x49, 0x19, 0x21, 0x8D, 0x12, 0x2F, 0x4D, 0xE2,
0xCC, 0xFA, 0x29, 0x35, 0x03, 0xB8, 0xF3, 0x19, 0xC8, 0x57, 0x51, 0xDC, 0x4E, 0x8D, 0xFB, 0x87,
0x5B, 0x5F, 0x2B, 0x3F, 0x9D, 0x52, 0xC8, 0x7C, 0x56, 0x6B, 0xBF, 0x99, 0xB5, 0xDC, 0xCE, 0xB7,
0xDE, 0xB9, 0x63, 0x1B, 0x9F, 0x1E, 0x9A, 0x1B, 0xD0, 0x99, 0x3E, 0x1C, 0x6F, 0x76, 0x10, 0xC6,
0xE7, 0xC5, 0xCC, 0xCE, 0xB9, 0x53, 0x6A, 0x7B, 0xD1, 0x76, 0xFB, 0x62, 0x02, 0x36, 0xCC, 0x7C,
0x98, 0x73, 0x32, 0x6B, 0xEF, 0x27, 0xC3, 0xBD, 0x9E, 0x52, 0x0B, 0xB6, 0x93, 0xE7, 0xB0, 0x4E,
0xF6, 0xFE, 0x5B, 0x4C, 0xD9, 0x90, 0x8F, 0x8D, 0x93, 0x33, 0xC5, 0xB6, 0x76, 0xE0, 0x41, 0x29,
0xDC, 0x33, 0x07, 0x4F, 0x69, 0xC9, 0x1F, 0x09, 0x3C, 0xCA, 0x82, 0xD7, 0x64, 0x94, 0x07, 0xC4,
0x12, 0x71, 0x19, 0x6E, 0xDE, 0x34, 0xA9, 0x27, 0x2E, 0x2B, 0x93, 0xFC, 0xF9, 0x9A, 0x2D, 0x87,
0x2C, 0x48, 0x9A, 0x35, 0x49, 0x49, 0x6D, 0x77, 0x60, 0x69, 0x30, 0x01, 0x8C, 0x32, 0x2C, 0x4B,
0x05, 0xCE, 0x20, 0x10, 0x93, 0x30, 0x14, 0xA6, 0xA7, 0x85, 0x5D, 0x76, 0xFA, 0x6D, 0x32, 0xC3,
0xCA, 0x11, 0x38, 0x39, 0x4C, 0xC1, 0x55, 0xFC, 0x63, 0x08, 0x4C, 0xF8, 0xC7, 0x66, 0x02, 0xFE,
0xDF, 0x19, 0x36, 0xFF, 0x4B, 0xAC, 0x5D, 0x49, 0xC3, 0x97, 0x4E, 0x31, 0x30, 0x59, 0xB1, 0x90,
0xB0, 0xD9, 0x6D, 0x33, 0x46, 0x2B, 0x58, 0x09, 0xB3, 0x21, 0xED, 0xF4, 0x1A, 0x96, 0x3B, 0x90,
0xB9, 0xD5, 0xC0, 0x61, 0xD1, 0x60, 0x5D, 0xD6, 0x4F, 0x18, 0x05, 0xE2, 0xD9, 0x33, 0x50, 0x0C,
0x2A, 0xA7, 0x5B, 0x01, 0x0B, 0x56, 0xB5, 0x8E, 0x11, 0x11, 0x7A, 0x8D, 0x87, 0xA8, 0x56, 0x63,
0x24, 0x2C, 0x22, 0x9E, 0x6E, 0xEB, 0x57, 0x31, 0xA2, 0x43, 0xA7, 0xC3, 0x98, 0x1C, 0x61, 0x67,
0xCF, 0xB1, 0xA4, 0x72, 0x94, 0x06, 0x7C, 0x9E, 0xC4, 0xCA, 0x67, 0x68, 0xBA, 0xD0, 0x36, 0x50,
0xD5, 0x89, 0x72, 0xFF, 0xC3, 0xE7, 0xB1, 0x0D, 0x5F, 0x23, 0x50, 0xE4, 0xCB, 0xCA, 0x56, 0x05,
0xCC, 0x24, 0x55, 0x0C, 0x71, 0x99, 0x27, 0x52, 0x39, 0x58, 0xF1, 0xD0, 0xAC, 0xC9, 0xC0, 0x7F,
0xC7, 0x13, 0x8D, 0x42, 0x92, 0x27, 0xCA, 0x99, 0xE6, 0x51, 0xD6, 0xB0, 0x6A, 0xC6, 0xE8, 0x3D,
0x7E, 0x97, 0xA4, 0xF8, 0x73, 0x45, 0xB1, 0x64, 0xD9, 0xD7, 0xCC, 0xD5, 0x0A, 0xF9, 0xCB, 0xA3,
0x84, 0x86, 0x1F, 0x33, 0x23, 0x61, 0x3F, 0x4D, 0x87, 0xC6, 0xE2, 0x1B, 0x46, 0x66, 0x74, 0xB5,
0x71, 0x8E, 0x54, 0x83, 0xF5, 0x2D, 0x2C, 0xAD, 0x19, 0x92, 0xAD, 0x19, 0xA1, 0x9D, 0xB0, 0xF6,
0x92, 0x91, 0xAF, 0x40, 0x22, 0xA8, 0xC5, 0x7B, 0xC2, 0x30, 0xC7, 0x39, 0x46, 0x68, 0x16, 0x5C,
0x4E, 0xAC, 0xE2, 0xA9, 0x35, 0x03, 0x79, 0x4D, 0x6C, 0xA3, 0x9F, 0x72, 0x3B, 0x9F, 0xE7, 0x53,
0xCA, 0xC6, 0x38, 0x67, 0x4A, 0x97, 0x9F, 0x00, 0xEC, 0x8A, 0x73, 0xD9, 0x94, 0x0A, 0xAC, 0xB7,
0x45, 0x94, 0x29, 0x1B, 0x9D, 0x03, 0x27, 0x29, 0x9F, 0x3E, 0xFD, 0x8F, 0x48, 0xF5, 0x72, 0x2A,
0x60, 0x29, 0x20, 0x63, 0xE0, 0xA4, 0xB2, 0x25, 0x05, 0xE6, 0x99, 0xDC, 0x76, 0xC2, 0xCB, 0xFB,
0x92, 0x95, 0x25, 0x93, 0x09, 0x61, 0x29, 0x84, 0x99, 0x06, 0x4B, 0xC9, 0x52, 0xE0, 0x6C, 0xA6,
0x24, 0xBB, 0x9B, 0x48, 0x70, 0x3C, 0x75, 0x8C, 0xA7, 0x4A, 0x85, 0x74, 0x9E, 0xB2, 0x42, 0x76,
0x66, 0x39, 0x51, 0x3A, 0xAE, 0x26, 0x0B, 0x03, 0x59, 0x64, 0xB2, 0x9A, 0xBE, 0x54, 0xB0, 0x5C,
0xF2, 0x4C, 0x6E, 0xCC, 0x86, 0x1B, 0x98, 0x2B, 0x1E, 0x25, 0x88, 0x20, 0x2A, 0x1B, 0xAF, 0x72,
0xD1, 0x84, 0xD9, 0xE7, 0x55, 0xE2, 0xF7, 0xBB, 0x10, 0xBB, 0x69, 0x03, 0x56, 0xBE, 0x3D, 0xA8,
0x56, 0xE5, 0x36, 0xDC, 0x57, 0xCB, 0x9A, 0xF7, 0x8A, 0x1B, 0xD8, 0xE3, 0xB6, 0xE5, 0x34, 0x21,
0x4C, 0xB7, 0xB9, 0xF7, 0x9C, 0x57, 0xBF, 0x0D, 0x2E, 0x44, 0xA5, 0x5F, 0x63, 0x43, 0x3F, 0x54,
0x36, 0x41, 0x4D, 0x11, 0xA6, 0xD4, 0xDE, 0x9D, 0x2B, 0xAD, 0x23, 0x05, 0xBD, 0x53, 0xDE, 0xEC,
0xD2, 0xCD, 0xDE, 0xA5, 0x51, 0xBE, 0xBB, 0xDF, 0x43, 0x35, 0x18, 0x83, 0xF5, 0x0B, 0x69, 0x30,
0xAE, 0xCA, 0xC3, 0xBB, 0xB8, 0x8F, 0xCC, 0x70, 0xE4, 0x9B, 0xFA, 0x88, 0x85, 0xA9, 0xCB, 0xE7,
0xD4, 0xE8, 0xCE, 0x03, 0x49, 0x00, 0x01, 0x4B, 0x32, 0x96, 0xA3, 0x36, 0x2C, 0x47, 0x55, 0xB6,
0x1C, 0x95, 0xF1, 0x57, 0xFD, 0xD0, 0xEA, 0xA9, 0xFC, 0x80, 0x2E, 0x52, 0xB7, 0x29, 0x39, 0xAC,
0x83, 0xFD, 0xDF, 0x2B, 0x30, 0xD5, 0xD5, 0x68, 0xAE, 0x4E, 0x4D, 0x55, 0xFE, 0x1B, 0x0E, 0xB7,
0x35, 0x7E, 0xB7, 0xA2, 0xCF, 0xA5, 0xFC, 0xC5, 0xE9, 0xD9, 0x7A, 0xDC, 0x99, 0x96, 0xDA, 0x3E,
0xEC, 0xEB, 0x0F, 0x17, 0x6B, 0x8E, 0x45, 0xD2, 0x57, 0x4D, 0x13, 0x5E, 0x54, 0x73, 0x05, 0xEB,
0x61, 0x20, 0x1E, 0xC2, 0x1C, 0xF4, 0xFA, 0x38, 0xF7, 0x37, 0xD7, 0x4D, 0xCE, 0x02, 0x7F, 0x8E,
0x39, 0x75, 0x8E, 0xC9, 0xE3, 0x73, 0x36, 0x13, 0x9E, 0xFE, 0xCA, 0x2E, 0xAC, 0x3A, 0xE5, 0x30,
0x37, 0x45, 0x1E, 0x74, 0x97, 0x8C, 0x9D, 0x4A, 0x65, 0x23, 0xF2, 0xF7, 0xDD, 0xE9, 0x45, 0xCC,
0xBC, 0x94, 0xC8, 0x49, 0x8D, 0x58, 0x7C, 0x2B, 0x56, 0x5E, 0x6F, 0xAB, 0xCF, 0x00, 0xCB, 0x65,
0x31, 0xD5, 0x52, 0xA4, 0xAA, 0xB2, 0xA3, 0x7D, 0x3A, 0x2B, 0x22, 0x77, 0x89, 0x6D, 0xBE, 0xF6,
0x83, 0x29, 0xC1, 0x89, 0x19, 0x14, 0x4A, 0x75, 0xC9, 0xD9, 0xB7, 0xAE, 0x15, 0x82, 0x28, 0x96,
0x6C, 0x2A, 0x05, 0xCF, 0xED, 0x8A, 0x61, 0xA5, 0xB5, 0x57, 0xDF, 0xD8, 0x5C, 0x3B, 0xF3, 0xFE,
0x49, 0xDB, 0xB4, 0x97, 0x21, 0xC8, 0x1D, 0xA1, 0xD7, 0x70, 0x20, 0xF4, 0x32, 0x96, 0x5E, 0xF1,
0x7D, 0xF4, 0x0A, 0xCF, 0x02, 0xA1, 0x57, 0xB4, 0xA5, 0x57, 0xB6, 0xA5, 0xD7, 0x3F, 0xB6, 0xF4,
0x0A, 0xB7, 0x8C, 0x89, 0x25, 0xD7, 0x26, 0x0B, 0x8D, 0x0A, 0x3F, 0x85, 0xBC, 0x05, 0x74, 0xFE,
0x4E, 0x01, 0x42, 0x5E, 0x56, 0x0F, 0x91, 0x08, 0xB6, 0xC0, 0xE4, 0x90, 0xD3, 0x56, 0x6F, 0xAE,
0xF7, 0x90, 0xD2, 0x01, 0x72, 0xB7, 0x20, 0x8D, 0x9F, 0xB4, 0xBD, 0x00, 0xD9, 0xCE, 0x6C, 0x8D,
0x4F, 0x8C, 0x92, 0x7E, 0xE4, 0x47, 0xBD, 0x20, 0xC9, 0x69, 0xA6, 0xB6, 0xA3, 0xA6, 0x2A, 0xA1,
0xFE, 0x0A, 0x77, 0x42, 0xA3, 0xFE, 0xAB, 0x5E, 0x99, 0x4E, 0x15, 0x95, 0x54, 0xA9, 0x92, 0x87,
0x36, 0xB7, 0x4B, 0xE0, 0x82, 0x12, 0x30, 0x23, 0x81, 0x4D, 0x4F, 0x10, 0xD7, 0x27, 0x30, 0xE6,
0x09, 0x78, 0x94, 0x10, 0x31, 0x88, 0x88, 0x86, 0xDF, 0xD3, 0xC8, 0xC5, 0xB4, 0xC7, 0x0A, 0xD5,
0x80, 0xC6, 0x6D, 0xC6, 0x65, 0x02, 0x11, 0xE3, 0xC1, 0x42, 0x02, 0x4B, 0x71, 0x59, 0x3F, 0xE8,
0x9A, 0x76, 0x75, 0x15, 0xE6, 0x20, 0x1E, 0xD5, 0x5E, 0x66, 0x3E, 0xE1, 0x99, 0xC4, 0xE6, 0xE0,
0xCB, 0x3B, 0x87, 0x08, 0x68, 0x5C, 0xC4, 0x32, 0x0D, 0x28, 0x6E, 0x60, 0xA9, 0xC5, 0xED, 0xCA,
0x03, 0x01, 0xE4, 0xB3, 0x7D, 0x4C, 0x18, 0xE4, 0x7B, 0x3B, 0x3E, 0x8F, 0x4F, 0xCB, 0xE7, 0xB9,
0x3E, 0x9D, 0x53, 0xAF, 0x5A, 0xE5, 0x0D, 0xD7, 0x25, 0xE2, 0xCF, 0xA8, 0x75, 0x31, 0xC5, 0x9A,
0xC7, 0x83, 0x13, 0x60, 0xF0, 0x1E, 0x6C, 0x81, 0x7C, 0x0D, 0x4B, 0xE9, 0x22, 0x9D, 0x65, 0x98,
0xB7, 0xB9, 0x34, 0xD8, 0xA9, 0x76, 0xB5, 0x77, 0xB9, 0xF6, 0x80, 0x30, 0x09, 0xF6, 0x55, 0x2C,
0x68, 0x76, 0xCA, 0x82, 0x26, 0xB4, 0xA0, 0x89, 0x28, 0x3E, 0xD7, 0x78, 0x0C, 0x1B, 0x7E, 0xAF,
0x06, 0x3F, 0x2B, 0x8B, 0x27, 0x1A, 0x1C, 0x51, 0x83, 0x1F, 0x69, 0x6E, 0x27, 0x70, 0x35, 0xE8,
0x91, 0x49, 0xEF, 0xAD, 0x7F, 0x81, 0xB8, 0x12, 0x3B, 0xB2, 0xB1, 0xDB, 0x23, 0x6F, 0x7B, 0x84,
0x32, 0xAA, 0x90, 0x22, 0x57, 0xBD, 0x25, 0xE5, 0xF4, 0xC5, 0xAC, 0xF6, 0x8C, 0xA7, 0xA4, 0x8A,
0x5C, 0xDB, 0x33, 0x3F, 0x87, 0xC4, 0x8F, 0xA6, 0x90, 0x80, 0x75, 0xD4, 0xA0, 0x3A, 0x56, 0x87,
0x25, 0x8B, 0xC8, 0xDB, 0x43, 0x99, 0x86, 0xB7, 0x23, 0x45, 0x94, 0xC8, 0xF4, 0x99, 0xE9, 0xBF,
0xF9, 0xC5, 0xB5, 0xC7, 0x29, 0xB7, 0xF0, 0xE4, 0x06, 0x36, 0x38, 0x06, 0xA3, 0x59, 0xF4, 0x4C,
0xD0, 0xF4, 0x63, 0xB6, 0x0C, 0xAD, 0x00, 0xC2, 0x46, 0xA7, 0xAE, 0x89, 0xEB, 0x2B, 0x06, 0x9B,
0xD1, 0xAE, 0xE9, 0x62, 0x1A, 0xBB, 0x93, 0xA9, 0x20, 0x6B, 0x19, 0x4E, 0x2B, 0xB8, 0x20, 0xBD,
0x06, 0x86, 0x36, 0xB0, 0x81, 0x08, 0xB3, 0x19, 0x53, 0x37, 0x16, 0x38, 0x1D, 0x77, 0xD6, 0x33,
0xEA, 0x59, 0x91, 0xA8, 0x0B, 0x39, 0xBB, 0x92, 0x80, 0xE8, 0x78, 0x3E, 0x41, 0x0B, 0x69, 0xB7,
0x59, 0x0E, 0x3F, 0x66, 0x86, 0xEF, 0xD7, 0x8C, 0x00, 0x44, 0x78, 0xBD, 0x41, 0x24, 0x37, 0x30,
0x94, 0xDD, 0x3C, 0xBC, 0x85, 0x65, 0x10, 0x93, 0xBF, 0x95, 0xF2, 0x14, 0x29, 0x22, 0xCB, 0xD1,
0x89, 0x32, 0xEF, 0xD7, 0x89, 0x0F, 0xBA, 0x79, 0xCD, 0xB1, 0x57, 0xAF, 0x58, 0x49, 0x60, 0x7A,
0xEB, 0xB6, 0x9D, 0xDB, 0x60, 0xE6, 0x4D, 0xAB, 0xBE, 0xC0, 0xDC, 0x63, 0xA3, 0xEE, 0x82, 0x92,
0xEB, 0x5A, 0x72, 0xBB, 0xAC, 0x92, 0x73, 0x07, 0x72, 0xB5, 0x92, 0xE8, 0x1C, 0x01, 0xF9, 0x7C,
0x3C, 0xB5, 0xB6, 0x39, 0xA5, 0x2B, 0x87, 0xF4, 0x52, 0x28, 0xCA, 0x88, 0x7E, 0xCC, 0xE0, 0x33,
0x1C, 0xE7, 0xB3, 0x3F, 0xC9, 0x0E, 0xE5, 0xAB, 0x3E, 0xE4, 0x11, 0xA6, 0x94, 0x9B, 0x7D, 0x12,
0x1E, 0xA1, 0x0E, 0x06, 0x0D, 0x64, 0x6B, 0xA6, 0x44, 0x18, 0xCF, 0xA3, 0x98, 0x1C, 0xEF, 0x0A,
0xC5, 0x10, 0x32, 0x4D, 0x14, 0x73, 0xE5, 0x5F, 0x1A, 0x76, 0x14, 0x3B, 0xA6, 0xAF, 0xD1, 0x74,
0x33, 0xB1, 0x24, 0xE4, 0xEB, 0x99, 0x62, 0xFE, 0x36, 0x06, 0x96, 0xF3, 0x2D, 0xBF, 0x3E, 0x73,
0xC8, 0x45, 0x77, 0xFC, 0x26, 0x31, 0x2B, 0xD8, 0x8D, 0x27, 0x17, 0xF4, 0xDA, 0x25, 0xDB, 0xE5,
0xFD, 0xC3, 0xDD, 0x99, 0xAB, 0x63, 0xFF, 0x2D, 0x41, 0x46, 0x46, 0xD3, 0x49, 0xC4, 0x60, 0xDD,
0x28, 0x5F, 0x43, 0x7B, 0xE4, 0xBC, 0x2C, 0xDB, 0xDF, 0x4F, 0x59, 0x56, 0x2D, 0xA4, 0x40, 0xF6,
0x7D, 0xA6, 0x01, 0xE1, 0xC6, 0x9E, 0x70, 0x9D, 0x7F, 0x9F, 0xA8, 0x97, 0x3B, 0x39, 0x05, 0x6C,
0xE5, 0xA0, 0x63, 0xF1, 0x20, 0x5D, 0x81, 0xA7, 0xBD, 0xCD, 0xD3, 0x15, 0x47, 0x8E, 0x36, 0xF6,
0x1E, 0x75, 0xA9, 0x5F, 0x43, 0x20, 0x73, 0xB2, 0x0F, 0x3B, 0x27, 0x92, 0xF7, 0xDF, 0x52, 0x25,
0x50, 0xFA, 0x36, 0x0B, 0x10, 0x11, 0x7C, 0x51, 0x6A, 0x4F, 0x5E, 0xEF, 0xB9, 0x31, 0x96, 0xF9,
0x68, 0xC3, 0xA3, 0x07, 0x5B, 0xA2, 0xF0, 0x91, 0xF7, 0x23, 0x44, 0xBE, 0x1D, 0xA4, 0x8B, 0x43,
0xE4, 0x28, 0xDF, 0xE3, 0x31, 0xB6, 0x1C, 0xCB, 0xFD, 0x0F, 0xD5, 0x5A, 0x3D, 0x8C, 0xDC, 0xD6,
0x11, 0x1E, 0x92, 0xD2, 0xD1, 0x10, 0x81, 0x50, 0x85, 0x64, 0x04, 0x16, 0x2C, 0xBA, 0x48, 0x11,
0x0B, 0xB6, 0xD8, 0xBA, 0xB8, 0x5B, 0xD2, 0x48, 0x91, 0x22, 0x85, 0x55, 0xE8, 0x2C, 0x20, 0x2E,
0x54, 0xC7, 0x41, 0x70, 0x29, 0x5C, 0xA8, 0x58, 0x2D, 0xE9, 0x2A, 0x65, 0xAE, 0x71, 0x95, 0x46,
@@ -2225,85 +2225,85 @@ static const EpdGlyph notosans_16_regularGlyphs[] = {
{ 9, 4, 172, 1, 11, 9, 898 }, // -
{ 5, 6, 143, 2, 5, 8, 907 }, // .
{ 13, 24, 198, 0, 24, 78, 915 }, // /
{ 17, 26, 305, 1, 25, 111, 993 }, // 0
{ 10, 24, 305, 2, 24, 60, 1104 }, // 1
{ 17, 25, 305, 1, 25, 107, 1164 }, // 2
{ 17, 26, 305, 1, 25, 111, 1271 }, // 3
{ 19, 24, 305, 0, 24, 114, 1382 }, // 4
{ 16, 25, 305, 2, 24, 100, 1496 }, // 5
{ 17, 26, 305, 1, 25, 111, 1596 }, // 6
{ 17, 24, 305, 1, 24, 102, 1707 }, // 7
{ 17, 26, 305, 1, 25, 111, 1809 }, // 8
{ 17, 26, 305, 1, 25, 111, 1920 }, // 9
{ 5, 20, 143, 2, 19, 25, 2031 }, // :
{ 6, 24, 143, 1, 19, 36, 2056 }, // ;
{ 17, 18, 305, 1, 21, 77, 2092 }, // <
{ 17, 10, 305, 1, 17, 43, 2169 }, // =
{ 17, 18, 305, 1, 21, 77, 2212 }, // >
{ 14, 26, 231, 0, 25, 91, 2289 }, // ?
{ 28, 27, 479, 1, 24, 189, 2380 }, // @
{ 22, 24, 341, 0, 24, 132, 2569 }, // A
{ 17, 24, 347, 3, 24, 102, 2701 }, // B
{ 19, 26, 337, 2, 25, 124, 2803 }, // C
{ 20, 24, 389, 3, 24, 120, 2927 }, // D
{ 14, 24, 296, 3, 24, 84, 3047 }, // E
{ 14, 24, 277, 3, 24, 84, 3131 }, // F
{ 20, 26, 388, 2, 25, 130, 3215 }, // G
{ 19, 24, 395, 3, 24, 114, 3345 }, // H
{ 9, 24, 181, 1, 24, 54, 3459 }, // I
{ 10, 31, 146, -3, 24, 78, 3513 }, // J
{ 18, 24, 330, 3, 24, 108, 3591 }, // K
{ 14, 24, 279, 3, 24, 84, 3699 }, // L
{ 24, 24, 484, 3, 24, 144, 3783 }, // M
{ 20, 24, 405, 3, 24, 120, 3927 }, // N
{ 22, 26, 416, 2, 25, 143, 4047 }, // O
{ 16, 24, 323, 3, 24, 96, 4190 }, // P
{ 22, 31, 416, 2, 25, 171, 4286 }, // Q
{ 18, 24, 332, 3, 24, 108, 4457 }, // R
{ 16, 26, 293, 1, 25, 104, 4565 }, // S
{ 19, 24, 296, 0, 24, 114, 4669 }, // T
{ 19, 25, 390, 3, 24, 119, 4783 }, // U
{ 20, 24, 320, 0, 24, 120, 4902 }, // V
{ 31, 24, 496, 0, 24, 186, 5022 }, // W
{ 20, 24, 312, 0, 24, 120, 5208 }, // X
{ 19, 24, 302, 0, 24, 114, 5328 }, // Y
{ 17, 24, 305, 1, 24, 102, 5442 }, // Z
{ 9, 30, 175, 2, 24, 68, 5544 }, // [
{ 13, 24, 198, 0, 24, 78, 5612 }, // <backslash>
{ 9, 30, 175, 0, 24, 68, 5690 }, // ]
{ 17, 16, 305, 1, 24, 68, 5758 }, // ^
{ 16, 3, 237, -1, -3, 12, 5826 }, // _
{ 8, 6, 150, 1, 26, 12, 5838 }, // `
{ 15, 20, 299, 1, 19, 75, 5850 }, // a
{ 17, 27, 328, 2, 26, 115, 5925 }, // b
{ 14, 20, 256, 1, 19, 70, 6040 }, // c
{ 17, 27, 328, 1, 26, 115, 6110 }, // d
{ 17, 20, 301, 1, 19, 85, 6225 }, // e
{ 13, 26, 183, 0, 26, 85, 6310 }, // f
{ 17, 27, 328, 1, 19, 115, 6395 }, // g
{ 16, 26, 330, 2, 26, 104, 6510 }, // h
{ 5, 25, 138, 2, 25, 32, 6614 }, // i
{ 9, 33, 138, -2, 25, 75, 6646 }, // j
{ 16, 26, 285, 2, 26, 104, 6721 }, // k
{ 4, 26, 138, 2, 26, 26, 6825 }, // l
{ 27, 19, 499, 2, 19, 129, 6851 }, // m
{ 16, 19, 330, 2, 19, 76, 6980 }, // n
{ 18, 20, 323, 1, 19, 90, 7056 }, // o
{ 17, 27, 328, 2, 19, 115, 7146 }, // p
{ 17, 27, 328, 1, 19, 115, 7261 }, // q
{ 12, 19, 220, 2, 19, 57, 7376 }, // r
{ 14, 20, 255, 1, 19, 70, 7433 }, // s
{ 12, 23, 193, 0, 22, 69, 7503 }, // t
{ 16, 19, 330, 2, 18, 76, 7572 }, // u
{ 17, 18, 271, 0, 18, 77, 7648 }, // v
{ 26, 18, 419, 0, 18, 117, 7725 }, // w
{ 18, 18, 282, 0, 18, 81, 7842 }, // x
{ 17, 26, 272, 0, 18, 111, 7923 }, // y
{ 14, 18, 251, 1, 18, 63, 8034 }, // z
{ 12, 30, 203, 0, 24, 90, 8097 }, // {
{ 4, 35, 294, 7, 26, 35, 8187 }, // |
{ 11, 30, 203, 1, 24, 83, 8222 }, // }
{ 17, 5, 305, 1, 14, 22, 8305 }, // ~
{ 17, 26, 311, 1, 25, 111, 993 }, // 0
{ 10, 24, 235, 0, 24, 60, 1104 }, // 1
{ 16, 25, 296, 1, 25, 100, 1164 }, // 2
{ 17, 26, 305, 1, 25, 111, 1264 }, // 3
{ 19, 24, 305, 0, 24, 114, 1375 }, // 4
{ 16, 25, 305, 2, 24, 100, 1489 }, // 5
{ 17, 26, 305, 1, 25, 111, 1589 }, // 6
{ 17, 24, 272, 0, 24, 102, 1700 }, // 7
{ 17, 26, 314, 1, 25, 111, 1802 }, // 8
{ 17, 26, 305, 1, 25, 111, 1913 }, // 9
{ 5, 20, 143, 2, 19, 25, 2024 }, // :
{ 6, 24, 143, 1, 19, 36, 2049 }, // ;
{ 17, 18, 305, 1, 21, 77, 2085 }, // <
{ 17, 10, 305, 1, 17, 43, 2162 }, // =
{ 17, 18, 305, 1, 21, 77, 2205 }, // >
{ 14, 26, 231, 0, 25, 91, 2282 }, // ?
{ 28, 27, 479, 1, 24, 189, 2373 }, // @
{ 22, 24, 341, 0, 24, 132, 2562 }, // A
{ 17, 24, 347, 3, 24, 102, 2694 }, // B
{ 19, 26, 337, 2, 25, 124, 2796 }, // C
{ 20, 24, 389, 3, 24, 120, 2920 }, // D
{ 14, 24, 296, 3, 24, 84, 3040 }, // E
{ 14, 24, 277, 3, 24, 84, 3124 }, // F
{ 20, 26, 388, 2, 25, 130, 3208 }, // G
{ 19, 24, 395, 3, 24, 114, 3338 }, // H
{ 9, 24, 181, 1, 24, 54, 3452 }, // I
{ 10, 31, 146, -3, 24, 78, 3506 }, // J
{ 18, 24, 330, 3, 24, 108, 3584 }, // K
{ 14, 24, 279, 3, 24, 84, 3692 }, // L
{ 24, 24, 484, 3, 24, 144, 3776 }, // M
{ 20, 24, 405, 3, 24, 120, 3920 }, // N
{ 22, 26, 416, 2, 25, 143, 4040 }, // O
{ 16, 24, 323, 3, 24, 96, 4183 }, // P
{ 22, 31, 416, 2, 25, 171, 4279 }, // Q
{ 18, 24, 332, 3, 24, 108, 4450 }, // R
{ 16, 26, 293, 1, 25, 104, 4558 }, // S
{ 19, 24, 296, 0, 24, 114, 4662 }, // T
{ 19, 25, 390, 3, 24, 119, 4776 }, // U
{ 20, 24, 320, 0, 24, 120, 4895 }, // V
{ 31, 24, 496, 0, 24, 186, 5015 }, // W
{ 20, 24, 312, 0, 24, 120, 5201 }, // X
{ 19, 24, 302, 0, 24, 114, 5321 }, // Y
{ 17, 24, 305, 1, 24, 102, 5435 }, // Z
{ 9, 30, 175, 2, 24, 68, 5537 }, // [
{ 13, 24, 198, 0, 24, 78, 5605 }, // <backslash>
{ 9, 30, 175, 0, 24, 68, 5683 }, // ]
{ 17, 16, 305, 1, 24, 68, 5751 }, // ^
{ 16, 3, 237, -1, -3, 12, 5819 }, // _
{ 8, 6, 150, 1, 26, 12, 5831 }, // `
{ 15, 20, 299, 1, 19, 75, 5843 }, // a
{ 17, 27, 328, 2, 26, 115, 5918 }, // b
{ 14, 20, 256, 1, 19, 70, 6033 }, // c
{ 17, 27, 328, 1, 26, 115, 6103 }, // d
{ 17, 20, 301, 1, 19, 85, 6218 }, // e
{ 13, 26, 183, 0, 26, 85, 6303 }, // f
{ 17, 27, 328, 1, 19, 115, 6388 }, // g
{ 16, 26, 330, 2, 26, 104, 6503 }, // h
{ 5, 25, 138, 2, 25, 32, 6607 }, // i
{ 9, 33, 138, -2, 25, 75, 6639 }, // j
{ 16, 26, 285, 2, 26, 104, 6714 }, // k
{ 4, 26, 138, 2, 26, 26, 6818 }, // l
{ 27, 19, 499, 2, 19, 129, 6844 }, // m
{ 16, 19, 330, 2, 19, 76, 6973 }, // n
{ 18, 20, 323, 1, 19, 90, 7049 }, // o
{ 17, 27, 328, 2, 19, 115, 7139 }, // p
{ 17, 27, 328, 1, 19, 115, 7254 }, // q
{ 12, 19, 220, 2, 19, 57, 7369 }, // r
{ 14, 20, 255, 1, 19, 70, 7426 }, // s
{ 12, 23, 193, 0, 22, 69, 7496 }, // t
{ 16, 19, 330, 2, 18, 76, 7565 }, // u
{ 17, 18, 271, 0, 18, 77, 7641 }, // v
{ 26, 18, 419, 0, 18, 117, 7718 }, // w
{ 18, 18, 282, 0, 18, 81, 7835 }, // x
{ 17, 26, 272, 0, 18, 111, 7916 }, // y
{ 14, 18, 251, 1, 18, 63, 8027 }, // z
{ 12, 30, 203, 0, 24, 90, 8090 }, // {
{ 4, 35, 294, 7, 26, 35, 8180 }, // |
{ 11, 30, 203, 1, 24, 83, 8215 }, // }
{ 17, 5, 305, 1, 14, 22, 8298 }, // ~
{ 0, 0, 139, 0, 0, 0, 0 }, // U+00A0
{ 5, 26, 143, 2, 19, 33, 0 }, // U+00A1
{ 14, 26, 305, 3, 25, 91, 33 }, // U+00A2
@@ -3303,7 +3303,7 @@ static const EpdUnicodeInterval notosans_16_regularIntervals[] = {
};
static const EpdFontGroup notosans_16_regularGroups[] = {
{ 0, 3626, 9206, 97, 0 },
{ 0, 3626, 9181, 97, 0 },
{ 3626, 3331, 11053, 96, 97 },
{ 6957, 4095, 16723, 128, 193 },
{ 11052, 4104, 15196, 96, 321 },
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
# Ignore all font directories except those committed to the repo.
# Fonts like NotoSansCJK are downloaded on demand by build-sd-fonts.py.
*
!.gitignore
!NotoSerif/
!NotoSerif/**
!NotoSans/
!NotoSans/**
!OpenDyslexic/
!OpenDyslexic/**
!Ubuntu/
!Ubuntu/**
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+20 -20
View File
@@ -8,39 +8,39 @@ echo "// The contents of this file are generated by ./lib/EpdFont/scripts/build-
echo "#pragma once"
echo ""
echo "#define BOOKERLY_12_FONT_ID ($(
echo "#define NOTOSERIF_12_FONT_ID ($(
ruby -rdigest -e 'puts [
"./bookerly_12_regular.h",
"./bookerly_12_bold.h",
"./bookerly_12_bolditalic.h",
"./bookerly_12_italic.h",
"./notoserif_12_regular.h",
"./notoserif_12_bold.h",
"./notoserif_12_bolditalic.h",
"./notoserif_12_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define BOOKERLY_14_FONT_ID ($(
echo "#define NOTOSERIF_14_FONT_ID ($(
ruby -rdigest -e 'puts [
"./bookerly_14_regular.h",
"./bookerly_14_bold.h",
"./bookerly_14_bolditalic.h",
"./bookerly_14_italic.h",
"./notoserif_14_regular.h",
"./notoserif_14_bold.h",
"./notoserif_14_bolditalic.h",
"./notoserif_14_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define BOOKERLY_16_FONT_ID ($(
echo "#define NOTOSERIF_16_FONT_ID ($(
ruby -rdigest -e 'puts [
"./bookerly_16_regular.h",
"./bookerly_16_bold.h",
"./bookerly_16_bolditalic.h",
"./bookerly_16_italic.h",
"./notoserif_16_regular.h",
"./notoserif_16_bold.h",
"./notoserif_16_bolditalic.h",
"./notoserif_16_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define BOOKERLY_18_FONT_ID ($(
echo "#define NOTOSERIF_18_FONT_ID ($(
ruby -rdigest -e 'puts [
"./bookerly_18_regular.h",
"./bookerly_18_bold.h",
"./bookerly_18_bolditalic.h",
"./bookerly_18_italic.h",
"./notoserif_18_regular.h",
"./notoserif_18_bold.h",
"./notoserif_18_bolditalic.h",
"./notoserif_18_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Build SD card fonts from a declarative YAML config.
Reads sd-fonts.yaml, downloads any missing source fonts, runs
fontconvert_sdcard.py in parallel for each family, and optionally
generates the fonts.json manifest.
Usage:
# Generate fonts (output in ./output/)
python3 build-sd-fonts.py
# Generate fonts + manifest
python3 build-sd-fonts.py --manifest --base-url "http://localhost:8000/"
# Custom config / output paths
python3 build-sd-fonts.py --config my-fonts.yaml --output-dir dist/
# 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
import os
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
import yaml
SCRIPT_DIR = Path(__file__).parent
FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py"
EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont
DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml"
DEFAULT_OUTPUT = SCRIPT_DIR / "output"
DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts"
INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
def download_font(url: str, dest: Path) -> Path:
"""Download a font file if not already cached. Returns the local path."""
if dest.exists():
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
print(f" Downloading {dest.name}...")
try:
urllib.request.urlretrieve(url, dest)
except Exception as e:
dest.unlink(missing_ok=True)
raise RuntimeError(f"Failed to download {url}: {e}") from e
size_kb = dest.stat().st_size / 1024
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
return dest
def extract_static_instance(source_path: Path, axes: dict, family_name: str, style_name: str) -> Path:
"""Use fonttools instancer to pin variable font axes, producing a static TTF.
Caches the result in INSTANCE_DIR/<family>/<style>_<axes>_<mtime>.ttf.
Returns the path to the static font file.
"""
from fontTools.varLib.instancer import instantiateVariableFont
from fontTools.ttLib import TTFont
mtime = int(source_path.stat().st_mtime)
axis_key = "_".join(f"{k}{v}" for k, v in sorted(axes.items()))
cache_name = f"{style_name}_{axis_key}_{mtime}.ttf"
cached = INSTANCE_DIR / family_name / cache_name
if cached.exists():
return cached
# Clean old cached instances for this style
cached.parent.mkdir(parents=True, exist_ok=True)
for old in cached.parent.glob(f"{style_name}_*.ttf"):
old.unlink()
print(f" Extracting static instance: {family_name}/{style_name} ({axis_key})")
# Atomic write: save to a temp file first, then rename. A crash or save()
# exception would otherwise leave a corrupt `cached` file that future runs
# would happily reuse via the `cached.exists()` check above.
tmp_fd, tmp_name = tempfile.mkstemp(suffix=".ttf", dir=cached.parent)
os.close(tmp_fd)
tmp_path = Path(tmp_name)
# Keep separate handles for the source variable font and the static
# instance: instantiateVariableFont with default inplace=False returns a
# *new* TTFont, so rebinding `font` would otherwise strand the source's
# file handle open until GC runs.
#
# updateFontNames=True — rewrite the name table so the saved font
# reports its weight/style accurately rather
# than retaining the variable-font names.
# optimize=False — skip the gvar interpolation optimisation;
# fully pinning every axis drops gvar anyway,
# so the work would be wasted.
source_font = TTFont(str(source_path))
try:
font = instantiateVariableFont(source_font, axes, updateFontNames=True, optimize=False)
try:
font.save(str(tmp_path))
finally:
font.close()
except Exception:
tmp_path.unlink(missing_ok=True)
raise
finally:
source_font.close()
tmp_path.replace(cached)
return cached
def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Path:
"""Resolve a style spec (path or url) to a local font file path.
If 'variable' key is present, extracts a static instance via fonttools
instancer after resolving the source file.
"""
if "path" in style_spec:
resolved = EPDFONTS_DIR / style_spec["path"]
if not resolved.exists():
raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found")
elif "url" in style_spec:
url = style_spec["url"]
# Derive a stable filename from the URL
filename = url.rsplit("/", 1)[-1]
dest = DOWNLOAD_DIR / family_name / filename
resolved = download_font(url, dest)
else:
raise ValueError(f"{family_name}/{style_name}: must have 'path' or 'url'")
# If variable font axes are specified, extract a static instance
if "variable" in style_spec:
resolved = extract_static_instance(
resolved, style_spec["variable"], family_name, style_name
)
return resolved
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
output_dir.mkdir(parents=True, exist_ok=True)
styles = family.get("styles", {})
intervals = family["intervals"]
sizes = ",".join(str(s) for s in family["sizes"])
# Resolve all font file paths (downloads as needed)
try:
resolved_styles = {}
for style_name, style_spec in styles.items():
resolved_styles[style_name] = resolve_font_path(style_spec, name, style_name)
except (FileNotFoundError, RuntimeError) as e:
return name, False, str(e)
# Build the fontconvert_sdcard.py command
cmd = [sys.executable, str(FONTCONVERT)]
multi_style = len(resolved_styles) > 1 or "regular" not in resolved_styles
has_any_multi = any(k in resolved_styles for k in ("regular", "bold", "italic", "bolditalic"))
if has_any_multi and len(resolved_styles) > 1:
# Multi-style mode
for style_name, font_path in resolved_styles.items():
cmd.extend([f"--{style_name}", str(font_path)])
else:
# Single-style mode
style_name = next(iter(resolved_styles))
font_path = resolved_styles[style_name]
cmd.append(str(font_path))
cmd.extend(["--style", style_name])
cmd.extend(["--intervals", intervals])
cmd.extend(["--sizes", sizes])
cmd.extend(["--name", name])
cmd.extend(["--output-dir", str(output_dir) + "/"])
if family.get("force_autohint", False):
cmd.append("--force-autohint")
# Run fontconvert_sdcard.py
start = time.monotonic()
try:
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)
def generate_manifest(
config_path: Path, output_base: Path, base_url: str, manifest_path: Path
):
"""Generate fonts.json manifest from config + built output.
Uses the standalone generate-font-manifest.py as a subprocess so
descriptions come from the YAML config via --descriptions-from.
"""
manifest_script = SCRIPT_DIR.parent.parent.parent / "scripts" / "generate-font-manifest.py"
if not base_url.endswith("/"):
base_url += "/"
cmd = [
sys.executable, str(manifest_script),
"--input", str(output_base),
"--base-url", base_url,
"--output", str(manifest_path),
]
if config_path.exists():
cmd.extend(["--descriptions-from", str(config_path)])
manifest_path.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR: Manifest generation failed:\n{result.stderr}", file=sys.stderr)
return
print(result.stdout, end="")
print(f"Manifest written: {manifest_path}")
def main():
parser = argparse.ArgumentParser(description="Build SD card fonts from YAML config")
parser.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Path to font families YAML config"
)
parser.add_argument(
"--output-dir", default=str(DEFAULT_OUTPUT), help="Output directory for .cpfont files"
)
parser.add_argument("--only", help="Comma-separated family names to build (default: all)")
parser.add_argument("--manifest", action="store_true", help="Also generate fonts.json manifest")
parser.add_argument("--base-url", default="", help="Base URL for manifest (required with --manifest)")
parser.add_argument(
"--manifest-output", default=None, help="Manifest output path (default: <output-dir>/fonts.json)"
)
parser.add_argument(
"--jobs", "-j", type=int, default=None,
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:
parser.error("--base-url is required when using --manifest")
# Load config
config_path = Path(args.config)
if not config_path.exists():
print(f"ERROR: Config not found: {config_path}", file=sys.stderr)
sys.exit(1)
with open(config_path) as f:
config = yaml.safe_load(f)
families = config.get("families", [])
if not families:
print("ERROR: No families defined in config", file=sys.stderr)
sys.exit(1)
# Filter if --only specified
if args.only:
only_names = set(args.only.split(","))
families = [f for f in families if f["name"] in only_names]
missing = only_names - {f["name"] for f in families}
if missing:
print(f"WARNING: families not found in config: {', '.join(missing)}", file=sys.stderr)
if not families:
print("ERROR: no matching families after --only filter", file=sys.stderr)
sys.exit(1)
output_base = Path(args.output_dir)
if args.clean and output_base.exists():
print(f"Cleaning {output_base}...")
shutil.rmtree(output_base)
output_base.mkdir(parents=True, exist_ok=True)
# Download phase (sequential — avoids hammering servers)
print(f"\n=== Resolving {len(families)} font families ===\n")
for family in families:
for style_name, style_spec in family.get("styles", {}).items():
if "url" in style_spec:
try:
resolve_font_path(style_spec, family["name"], style_name)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# Build phase (parallel)
max_workers = args.jobs or len(families)
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, verbose, timeout): family["name"]
for family in families
}
for future in as_completed(futures):
name, success, message = future.result()
if success:
# Count output files
family_dir = output_base / name
count = len(list(family_dir.glob("*.cpfont")))
size = sum(f.stat().st_size for f in family_dir.glob("*.cpfont"))
print(f" OK: {name} ({count} files, {size / 1024 / 1024:.1f} MB)")
else:
print(f" FAILED: {name}: {message}", file=sys.stderr)
failed.append(name)
# Summary
print("\n=== Summary ===\n")
total_files = len(list(output_base.rglob("*.cpfont")))
total_size = sum(f.stat().st_size for f in output_base.rglob("*.cpfont"))
print(f"Total: {total_files} .cpfont files ({total_size / 1024 / 1024:.1f} MB)")
if failed:
print(f"\nFailed families: {', '.join(failed)}", file=sys.stderr)
# Manifest
if args.manifest:
manifest_path = Path(args.manifest_output) if args.manifest_output else output_base / "fonts.json"
generate_manifest(config_path, output_base, args.base_url, manifest_path)
if failed:
sys.exit(1)
if __name__ == "__main__":
main()
+6 -6
View File
@@ -5,16 +5,16 @@ set -e
cd "$(dirname "$0")"
READER_FONT_STYLES=("Regular" "Italic" "Bold" "BoldItalic")
BOOKERLY_FONT_SIZES=(12 14 16 18)
NOTOSERIF_FONT_SIZES=(12 14 16 18)
NOTOSANS_FONT_SIZES=(12 14 16 18)
OPENDYSLEXIC_FONT_SIZES=(8 10 12 14)
for size in ${BOOKERLY_FONT_SIZES[@]}; do
for size in ${NOTOSERIF_FONT_SIZES[@]}; do
for style in ${READER_FONT_STYLES[@]}; do
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
font_name="notoserif_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/NotoSerif/NotoSerif-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress --pnum > $output_path
echo "Generated $output_path"
done
done
@@ -24,7 +24,7 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress --pnum > $output_path
echo "Generated $output_path"
done
done
+15
View File
@@ -0,0 +1,15 @@
# Canonical version constants for the .cpfont binary format and font manifest.
#
# These are the single source of truth for the build tooling. The CI workflow
# (release-fonts.yml) and both Python scripts (fontconvert_sdcard.py,
# generate-font-manifest.py) read from here.
#
# The firmware C++ headers (SdCardFont.h, FontDownloadActivity.h) carry their
# own copies — those must be bumped manually when the firmware is updated to
# support a new version.
# .cpfont binary format version. Bump when the on-disk struct layout changes.
CPFONT_VERSION = 4
# JSON manifest schema version. Bump when the manifest shape changes.
FONTS_MANIFEST_VERSION = 1
+113 -9
View File
@@ -1,12 +1,16 @@
#!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
# header. Wrapped in a hasattr guard so it's a no-op on older Pythons.
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
# Originally from https://github.com/vroland/epdiy
@@ -18,8 +22,12 @@ parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
parser.add_argument("--compress", dest="compress", action="store_true", help="Compress glyph bitmaps using DEFLATE with group-based compression.")
parser.add_argument("--force-autohint", dest="force_autohint", action="store_true", help="Force FreeType auto-hinter instead of native font hinting. Improves stem width consistency for fonts with weak or no native TrueType hints.")
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]
@@ -168,11 +176,68 @@ def chunks(l, n):
for i in range(0, len(l), n):
yield l[i:i + n]
def extract_pnum_subs(font_path):
"""Extract pnum (proportional figures) GSUB substitutions.
Parses the font's GSUB table for the 'pnum' feature, which replaces
tabular-width figure glyphs with proportional-width alternates.
Returns {original_glyph_name: substitute_glyph_name} or empty dict.
"""
font = TTFont(font_path)
subs = {}
if 'GSUB' not in font:
font.close()
return subs
gsub = font['GSUB'].table
pnum_indices = set()
if gsub.FeatureList:
for fr in gsub.FeatureList.FeatureRecord:
if fr.FeatureTag == 'pnum':
pnum_indices.update(fr.Feature.LookupListIndex)
for li in pnum_indices:
lookup = gsub.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
if hasattr(actual, 'mapping'):
subs.update(actual.mapping)
font.close()
return subs
# Build proportional numeral glyph overrides when --pnum is active.
# Maps (face_index, codepoint) -> freetype glyph index for the proportional alternate.
pnum_glyph_overrides = {}
pnum_kern_subs = {} # face_index -> {original_glyph_name: substitute_glyph_name}
if args.pnum:
for face_idx, font_path in enumerate(args.fontstack):
subs = extract_pnum_subs(font_path)
if not subs:
continue
pnum_kern_subs[face_idx] = subs
tt_font = TTFont(font_path)
cmap = tt_font.getBestCmap() or {}
glyph_order = tt_font.getGlyphOrder()
name_to_glyph_idx = {name: idx for idx, name in enumerate(glyph_order)}
count = 0
for cp, glyph_name in cmap.items():
if glyph_name in subs:
sub_name = subs[glyph_name]
sub_idx = name_to_glyph_idx.get(sub_name, 0)
if sub_idx > 0:
pnum_glyph_overrides[(face_idx, cp)] = sub_idx
count += 1
tt_font.close()
if count > 0:
print(f"pnum: {count} glyph substitutions from {font_path}", file=sys.stderr)
def load_glyph(code_point):
face_index = 0
while face_index < len(font_stack):
face = font_stack[face_index]
glyph_index = face.get_char_index(code_point)
glyph_index = pnum_glyph_overrides.get((face_index, code_point))
if glyph_index is None:
glyph_index = face.get_char_index(code_point)
if glyph_index > 0:
face.load_glyph(glyph_index, load_flags)
return face
@@ -386,23 +451,30 @@ def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern):
key = (left_glyph, right_glyph)
raw_kern[key] = raw_kern.get(key, 0) + xa
def extract_kerning_fonttools(font_path, codepoints, ppem):
def extract_kerning_fonttools(font_path, codepoints, ppem, pnum_subs=None):
"""Extract kerning pairs from a font file using fonttools.
Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
codepoints. Values are scaled from font design units to integer
pixels at ppem.
When pnum_subs is provided, substitute glyph names are also included
in the lookup so kern pairs referencing proportional alternates are found.
"""
font = TTFont(font_path)
units_per_em = font['head'].unitsPerEm
cmap = font.getBestCmap() or {}
# Build glyph_name -> codepoint map (only for requested codepoints)
# Build glyph_name -> codepoint map (only for requested codepoints).
# When pnum is active, include both the original and substitute glyph
# names so kern pairs referencing either are captured.
glyph_to_cp = {}
for cp in codepoints:
gname = cmap.get(cp)
if gname:
glyph_to_cp[gname] = cp
if pnum_subs and gname in pnum_subs:
glyph_to_cp[pnum_subs[gname]] = cp
# Collect raw kerning values in font design units
raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units
@@ -454,7 +526,8 @@ ppem = size * 150.0 / 72.0
kern_map = {} # (leftCp, rightCp) -> adjust
for face_idx, cps in face_idx_cps.items():
font_path = args.fontstack[face_idx]
kern_map.update(extract_kerning_fonttools(font_path, cps, ppem))
subs = pnum_kern_subs.get(face_idx) if args.pnum else None
kern_map.update(extract_kerning_fonttools(font_path, cps, ppem, pnum_subs=subs))
print(f"kerning: {len(kern_map)} pairs extracted", file=sys.stderr)
@@ -564,7 +637,7 @@ def extract_ligatures_fonttools(font_path, codepoints):
# Find lookup indices for ligature features.
# Currently extracts 'liga' (standard) and 'rlig' (required) only.
# To also extract discretionary or historical ligatures, add:
# 'dlig' - Discretionary Ligatures (e.g., ft, st in Bookerly)
# 'dlig' - Discretionary Ligatures (e.g., ft, st in Noto)
# 'hlig' - Historical Ligatures (e.g., long-s+t in OpenDyslexic)
# These are off by default in standard text renderers.
LIGATURE_FEATURES = ('liga', 'rlig')
@@ -726,6 +799,15 @@ if compress:
# are grouped together for efficient LRU caching on the embedded target.
# Since glyphs are in codepoint order, glyphs in the same Unicode block
# are contiguous in the array and form natural groups.
#
# On top of script boundaries, a hard size cap (GROUP_MAX_UNCOMPRESSED_BYTES)
# is applied: if adding the next glyph would push the uncompressed group
# size over the cap, the group is closed and a new one started with the
# same script ID. This bounds the embedded decompressor's transient
# malloc regardless of font density (CJK, Vietnamese, user-supplied
# fonts with large Unicode blocks). Without it, a single dense script
# group can balloon past what fits in a transient page-decompress
# allocation on the device.
SCRIPT_GROUP_RANGES = [
(0x0000, 0x007F), # ASCII
(0x0080, 0x00FF), # Latin-1 Supplement
@@ -743,6 +825,11 @@ if compress:
(0xFFFD, 0xFFFD), # Replacement Character
]
# 64 KB cap: large enough to hold any single built-in script group with
# headroom, small enough to be a comfortable transient malloc on the
# ESP32-C3.
GROUP_MAX_UNCOMPRESSED_BYTES = 65536
def get_script_group(code_point):
for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES):
if start <= code_point <= end:
@@ -753,17 +840,34 @@ if compress:
current_group_id = None
group_start = 0
group_count = 0
group_uncompressed = 0
for i, (props, packed) in enumerate(all_glyphs):
for i, (props, _) in enumerate(all_glyphs):
sg = get_script_group(props.code_point)
if sg != current_group_id:
# Use the byte-aligned size (4-pixel-aligned row stride) rather than
# the packed length, since the decompressor consumes byte-aligned
# buffers. Empty glyphs contribute zero.
glyph_aligned_size = (((props.width + 3) // 4) * props.height
if props.width > 0 and props.height > 0 else 0)
if glyph_aligned_size > GROUP_MAX_UNCOMPRESSED_BYTES:
raise ValueError(
f"Glyph {i} (code point U+{props.code_point:04X}) byte-aligned size "
f"{glyph_aligned_size} exceeds GROUP_MAX_UNCOMPRESSED_BYTES="
f"{GROUP_MAX_UNCOMPRESSED_BYTES}. Consider: (1) increasing GROUP_MAX_UNCOMPRESSED_BYTES, "
f"(2) reducing font size, or (3) excluding this codepoint."
)
size_overflow = group_uncompressed + glyph_aligned_size > GROUP_MAX_UNCOMPRESSED_BYTES
if sg != current_group_id or size_overflow:
if group_count > 0:
groups.append((group_start, group_count))
current_group_id = sg
group_start = i
group_count = 1
group_uncompressed = glyph_aligned_size
else:
group_count += 1
group_uncompressed += glyph_aligned_size
if group_count > 0:
groups.append((group_start, group_count))
+985
View File
@@ -0,0 +1,985 @@
#!/usr/bin/env python3
"""Generate .cpfont binary files for SD card font loading.
Outputs binary .cpfont files containing glyph metadata and uncompressed
2-bit bitmaps, matching the EpdFontData/EpdGlyph/EpdUnicodeInterval struct
layout on the ESP32-C3 (little-endian, RISC-V).
Usage:
# Single file with specific presets
python fontconvert_sdcard.py \\
--intervals latin-ext,greek,cyrillic \\
--size 14 --style regular \\
NotoSans-Regular.ttf \\
-o NotoSansExt_14.cpfont
# All 4 sizes at once
python fontconvert_sdcard.py \\
--intervals cjk \\
--sizes 12,14,16,18 --style regular \\
NotoSansCJKsc-Regular.otf \\
--output-dir NotoSansCJK/
"""
from __future__ import annotations
import struct
import sys
import os
import re
import math
import argparse
from collections import namedtuple
from cpfont_version import CPFONT_VERSION
# --- Unicode interval presets ---
INTERVAL_PRESETS = {
"ascii": [(0x0020, 0x007E)],
"latin1": [(0x0080, 0x00FF)],
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
(0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)],
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
"georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)],
"armenian": [(0x0530, 0x058F)],
"ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)],
"vietnamese": [(0x01A0, 0x01B0), (0x1EA0, 0x1EF9)],
"punctuation": [(0x2000, 0x206F)],
"cjk": [(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
(0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)],
"hangul": [(0xAC00, 0xD7AF), (0x1100, 0x11FF), (0x3130, 0x318F)],
"cherokee": [(0x13A0, 0x13FF), (0xAB70, 0xABBF)],
"tifinagh": [(0x2D30, 0x2D7F)],
# Symbol blocks commonly seen in scifi/popsci/literary fiction.
"symbols": [(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF)],
# Composite preset for English-language literary fiction including scifi/popsci.
# Greek for physics terms, math operators, miscellaneous symbols (♪♫♬), dingbats.
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF),
(0xFB00, 0xFB06)],
# Matches the built-in font intervals from fontconvert.py exactly
"builtin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F),
(0x01A0, 0x01A1), (0x01AF, 0x01B0), (0x01C4, 0x021F),
(0x0300, 0x036F), (0x0400, 0x04FF),
(0x1EA0, 0x1EF9), (0x2000, 0x206F), (0x20A0, 0x20CF),
(0x2070, 0x209F), (0x2190, 0x21FF), (0x2200, 0x22FF),
(0xFB00, 0xFB06)],
}
# Regex for parsing unnamed hex range intervals: (0xSTART-0xEND)
_HEX_RANGE_PATTERN = re.compile(r'^\(0x([0-9a-fA-F]+)-0x([0-9a-fA-F]+)\)$')
def parse_hex_range(s: str) -> tuple[int, int] | None:
match = _HEX_RANGE_PATTERN.fullmatch(s)
if not match:
return None
start_hex, end_hex = match.groups()
start, end = int(start_hex, 16), int(end_hex, 16)
# Validating Unicode range bounds.
if start > end or end > 0x10FFFF:
return None
return start, end
def resolve_intervals(preset_str):
"""Resolve comma-separated preset names into a merged, sorted, deduplicated interval list."""
all_intervals = []
for name in preset_str.split(","):
name = name.strip().lower()
unnamed_interval = parse_hex_range(name)
if name not in INTERVAL_PRESETS and unnamed_interval is None:
print(f"Error: unknown interval preset '{name}'", file=sys.stderr)
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
print("You can also specify unnamed hex ranges like (0x2100-0x214F)", file=sys.stderr)
sys.exit(1)
if unnamed_interval is not None:
all_intervals.append(unnamed_interval)
else:
all_intervals.extend(INTERVAL_PRESETS[name])
# Always add replacement character
all_intervals.append((0xFFFD, 0xFFFD))
# Sort and merge overlapping/adjacent intervals
all_intervals.sort()
merged = []
for start, end in all_intervals:
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
GlyphProps = namedtuple("GlyphProps", [
"width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"
])
# Intermediate data from rasterizing one font style
StyleRasterData = namedtuple("StyleRasterData", [
"style_id", # 0=regular, 1=bold, 2=italic, 3=bolditalic
"intervals", # validated intervals [(start, end), ...]
"all_glyphs", # [(GlyphProps, packed_bytes), ...]
"total_bitmap_size", # int
"advanceY", "ascender", "descender",
"kern_left_classes", "kern_right_classes", "kern_matrix",
"kern_left_class_count", "kern_right_class_count",
"ligature_pairs",
])
def norm_floor(val):
return int(math.floor(val / (1 << 6)))
def norm_ceil(val):
return int(math.ceil(val / (1 << 6)))
# Fixed-point (fp4) output conventions (must match EpdFontData.h / fp4 namespace):
#
# advanceX 12.4 unsigned fixed-point (uint16_t).
# 12 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Encoded from FreeType's 16.16 linearHoriAdvance.
#
# kernMatrix 4.4 signed fixed-point (int8_t).
# 4 integer bits, 4 fractional bits = 1/16-pixel resolution.
# Range: -8.0 to +7.9375 pixels.
# Encoded from font design-unit kerning values.
#
# Both share 4 fractional bits so the renderer can add them directly into a
# single int32_t accumulator and defer rounding until pixel placement.
def fp4_from_ft16_16(val):
"""Convert FreeType 16.16 fixed-point to 12.4 fixed-point with rounding."""
return (val + (1 << 11)) >> 12
def fp4_from_design_units(du, scale):
"""Convert a font design-unit value to 4.4 fixed-point, clamped to int8_t.
Multiplies by scale (ppem / units_per_em) and shifts into 4 fractional
bits. The result is rounded to nearest and clamped to [-128, 127].
"""
raw = round(du * scale * 16)
return max(-128, min(127, raw))
# Standard Unicode ligature codepoints for known input sequences.
# Used as a fallback when the GSUB substitute glyph has no cmap entry.
STANDARD_LIGATURE_MAP = {
(0x66, 0x66): 0xFB00, # ff
(0x66, 0x69): 0xFB01, # fi
(0x66, 0x6C): 0xFB02, # fl
(0x66, 0x66, 0x69): 0xFB03, # ffi
(0x66, 0x66, 0x6C): 0xFB04, # ffl
(0x17F, 0x74): 0xFB05, # long-s + t
(0x73, 0x74): 0xFB06, # st
}
def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern):
"""Extract kerning from a PairPos subtable (Format 1 or 2)."""
if subtable.Format == 1:
# Individual pairs
for i, coverage_glyph in enumerate(subtable.Coverage.glyphs):
if coverage_glyph not in glyph_to_cp:
continue
pair_set = subtable.PairSet[i]
for pvr in pair_set.PairValueRecord:
if pvr.SecondGlyph not in glyph_to_cp:
continue
xa = 0
if hasattr(pvr, 'Value1') and pvr.Value1:
xa = getattr(pvr.Value1, 'XAdvance', 0) or 0
if xa != 0:
key = (coverage_glyph, pvr.SecondGlyph)
raw_kern[key] = raw_kern.get(key, 0) + xa
elif subtable.Format == 2:
# Class-based pairs — iterate by class, not by glyph, to avoid
# O(glyphs²) explosion for CJK fonts with many requested glyphs.
class_def1 = subtable.ClassDef1.classDefs if subtable.ClassDef1 else {}
class_def2 = subtable.ClassDef2.classDefs if subtable.ClassDef2 else {}
coverage_set = set(subtable.Coverage.glyphs)
# Build reverse mappings: class_id -> list of glyph names
left_by_class = {} # only glyphs in coverage AND glyph_to_cp
for glyph in glyph_to_cp:
if glyph not in coverage_set:
continue
c1 = class_def1.get(glyph, 0)
left_by_class.setdefault(c1, []).append(glyph)
right_by_class = {} # all glyphs in glyph_to_cp
for glyph in glyph_to_cp:
c2 = class_def2.get(glyph, 0)
right_by_class.setdefault(c2, []).append(glyph)
# Iterate class pairs (typically << glyph pairs)
for c1, class1_rec in enumerate(subtable.Class1Record):
if c1 not in left_by_class:
continue
for c2, c2_rec in enumerate(class1_rec.Class2Record):
xa = 0
if hasattr(c2_rec, 'Value1') and c2_rec.Value1:
xa = getattr(c2_rec.Value1, 'XAdvance', 0) or 0
if xa == 0:
continue
if c2 not in right_by_class:
continue
for lg in left_by_class[c1]:
for rg in right_by_class[c2]:
key = (lg, rg)
raw_kern[key] = raw_kern.get(key, 0) + xa
def extract_kerning_fonttools(font_path, codepoints, ppem):
"""Extract kerning pairs from a font file using fonttools.
Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
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 {}
# Build glyph_name -> [codepoints] map (preserves aliases where multiple
# codepoints share a glyph, e.g. space/nbsp)
glyph_to_cps = {}
for cp in codepoints:
gname = cmap.get(cp)
if gname:
glyph_to_cps.setdefault(gname, []).append(cp)
# Flat dict for membership checks and subtable extraction (uses keys only)
glyph_to_cp = glyph_to_cps
# Collect raw kerning values in font design units
raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units
# 1. Legacy kern table
if 'kern' in font:
for subtable in font['kern'].kernTables:
if hasattr(subtable, 'kernTable'):
for (lg, rg), val in subtable.kernTable.items():
if lg in glyph_to_cp and rg in glyph_to_cp:
raw_kern[(lg, rg)] = raw_kern.get((lg, rg), 0) + val
# 2. GPOS 'kern' feature
if 'GPOS' in font:
gpos = font['GPOS'].table
kern_lookup_indices = set()
if gpos.FeatureList:
for fr in gpos.FeatureList.FeatureRecord:
if fr.FeatureTag == 'kern':
kern_lookup_indices.update(fr.Feature.LookupListIndex)
for li in kern_lookup_indices:
lookup = gpos.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
# Unwrap Extension (lookup type 9) wrappers. After unwrapping,
# `lookup.LookupType` is still 9, so we must look at the
# *effective* type carried on the extension subtable to know
# whether `actual` is a PairPos table.
if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
effective_type = getattr(st, 'ExtensionLookupType', lookup.LookupType)
if hasattr(actual, 'Format'):
# _extract_pairpos_subtable assumes a Type-2 (PairPos)
# subtable. Other lookup types reachable through the kern
# feature (cursive attachment, mark-to-mark, contextual,
# etc.) have a different shape and crash inside the
# extractor. Skip them with a debug note rather than
# aborting the whole build. Modern fonts often ship kern
# via Extension-wrapped PairPos, so checking the effective
# type instead of the outer type is what makes those
# lookups actually reach the extractor.
if effective_type == 2:
_extract_pairpos_subtable(actual, glyph_to_cp, raw_kern)
else:
print(f" Debug: skipping unsupported GPOS kern lookupType="
f"{effective_type} (outer={lookup.LookupType}, Format={actual.Format})",
file=sys.stderr)
font.close()
# Scale design-unit kerning values to 4.4 fixed-point pixels.
# Expand glyph aliases: if multiple codepoints share a glyph, emit kern
# pairs for all codepoint combinations.
scale = ppem / units_per_em
result = {} # (leftCp, rightCp) -> 4.4 fixed-point adjust
for (lg, rg), du in raw_kern.items():
adjust = fp4_from_design_units(du, scale)
if adjust != 0:
for lcp in glyph_to_cps[lg]:
for rcp in glyph_to_cps[rg]:
result[(lcp, rcp)] = adjust
return result
def derive_kern_classes(kern_map):
"""Derive class-based kerning from a pair map.
Returns (kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count) where:
- kern_left_classes: sorted list of (codepoint, classId) tuples
- kern_right_classes: sorted list of (codepoint, classId) tuples
- kern_matrix: flat list of int8 values (left_class_count * right_class_count)
- kern_left_class_count: number of distinct left classes
- kern_right_class_count: number of distinct right classes
"""
if not kern_map:
return [], [], [], 0, 0
all_left_cps = {lcp for lcp, _ in kern_map}
all_right_cps = {rcp for _, rcp in kern_map}
sorted_right_cps = sorted(all_right_cps)
sorted_left_cps = sorted(all_left_cps)
# Group left codepoints by identical adjustment row
left_profile_to_class = {}
left_class_map = {}
left_class_id = 1
for lcp in sorted(all_left_cps):
row = tuple(kern_map.get((lcp, rcp), 0) for rcp in sorted_right_cps)
if row not in left_profile_to_class:
left_profile_to_class[row] = left_class_id
left_class_id += 1
left_class_map[lcp] = left_profile_to_class[row]
# Group right codepoints by identical adjustment column
right_profile_to_class = {}
right_class_map = {}
right_class_id = 1
for rcp in sorted(all_right_cps):
col = tuple(kern_map.get((lcp, rcp), 0) for lcp in sorted_left_cps)
if col not in right_profile_to_class:
right_profile_to_class[col] = right_class_id
right_class_id += 1
right_class_map[rcp] = right_profile_to_class[col]
kern_left_class_count = left_class_id - 1
kern_right_class_count = right_class_id - 1
if kern_left_class_count > 255 or kern_right_class_count > 255:
print(f"WARNING: kerning class count exceeds uint8_t range "
f"(left={kern_left_class_count}, right={kern_right_class_count}), "
f"dropping kerning for this style",
file=sys.stderr)
return ([], [], [], 0, 0)
# Build the class x class matrix
kern_matrix = [0] * (kern_left_class_count * kern_right_class_count)
for (lcp, rcp), adjust in kern_map.items():
lc = left_class_map[lcp] - 1
rc = right_class_map[rcp] - 1
kern_matrix[lc * kern_right_class_count + rc] = adjust
# Build sorted class entry lists
kern_left_classes = sorted(left_class_map.items())
kern_right_classes = sorted(right_class_map.items())
return (kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count)
def extract_ligatures_fonttools(font_path, codepoints):
"""Extract ligature substitution pairs from a font file using fonttools.
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 {}
# Build glyph_name -> codepoint and codepoint -> glyph_name maps
glyph_to_cp = {}
cp_to_glyph = {}
for cp, gname in cmap.items():
glyph_to_cp[gname] = cp
cp_to_glyph[cp] = gname
# Collect raw ligature rules: (sequence_of_codepoints) -> ligature_codepoint
raw_ligatures = {} # tuple of codepoints -> ligature codepoint
if 'GSUB' in font:
gsub = font['GSUB'].table
LIGATURE_FEATURES = ('liga', 'rlig')
liga_lookup_indices = set()
if gsub.FeatureList:
for fr in gsub.FeatureList.FeatureRecord:
if fr.FeatureTag in LIGATURE_FEATURES:
liga_lookup_indices.update(fr.Feature.LookupListIndex)
for li in liga_lookup_indices:
lookup = gsub.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
# Unwrap Extension (lookup type 7) wrappers
if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
# LigatureSubst is lookup type 4
if not hasattr(actual, 'ligatures'):
continue
for first_glyph, ligature_list in actual.ligatures.items():
if first_glyph not in glyph_to_cp:
continue
first_cp = glyph_to_cp[first_glyph]
for lig in ligature_list:
component_cps = []
valid = True
for comp_glyph in lig.Component:
if comp_glyph not in glyph_to_cp:
valid = False
break
component_cps.append(glyph_to_cp[comp_glyph])
if not valid:
continue
seq = tuple([first_cp] + component_cps)
if lig.LigGlyph in glyph_to_cp:
lig_cp = glyph_to_cp[lig.LigGlyph]
elif seq in STANDARD_LIGATURE_MAP:
lig_cp = STANDARD_LIGATURE_MAP[seq]
else:
seq_str = ', '.join(f'U+{cp:04X}' for cp in seq)
print(f"ligatures: WARNING: dropping ligature ({seq_str}) -> "
f"glyph '{lig.LigGlyph}': output glyph has no cmap entry "
f"and input sequence is not in STANDARD_LIGATURE_MAP",
file=sys.stderr)
continue
raw_ligatures[seq] = lig_cp
font.close()
# Filter: only keep ligatures where all input and output codepoints are
# in our generated glyph set, and all codepoints fit in 16 bits.
#
# The on-disk format packs each component as a uint16 (the 3+ chained
# path packs `intermediate_cp << 16 | last_cp`, where `intermediate_cp`
# is the lig_cp of the prefix). Dropping any seq with an SMP cp here —
# plus any lig_cp > 0xFFFF — means every cp that reaches `packed = … <<
# 16 | …` below is already 16-bit safe, including the chained path
# (intermediate_cp = filtered[prefix] is filtered too).
codepoints_set = set(codepoints)
filtered = {}
for seq, lig_cp in raw_ligatures.items():
if lig_cp not in codepoints_set or lig_cp > 0xFFFF:
continue
if any(cp > 0xFFFF for cp in seq):
continue
if all(cp in codepoints_set for cp in seq):
filtered[seq] = lig_cp
# Decompose into chained pairs
pairs = []
# First pass: collect all 2-codepoint ligatures
two_char = {seq: lig_cp for seq, lig_cp in filtered.items() if len(seq) == 2}
for seq, lig_cp in two_char.items():
packed = (seq[0] << 16) | seq[1]
pairs.append((packed, lig_cp))
# Second pass: decompose 3+ codepoint ligatures into chained pairs
for seq, lig_cp in filtered.items():
if len(seq) < 3:
continue
prefix = seq[:-1]
last_cp = seq[-1]
if prefix in filtered:
intermediate_cp = filtered[prefix]
packed = (intermediate_cp << 16) | last_cp
pairs.append((packed, lig_cp))
else:
print(f"ligatures: skipping {len(seq)}-char ligature "
f"({', '.join(f'U+{cp:04X}' for cp in seq)}) -> U+{lig_cp:04X}: "
f"no intermediate ligature for prefix", file=sys.stderr)
# Sort by packed pair key — on-device lookup uses binary search
pairs.sort(key=lambda p: p[0])
return pairs
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))
face = freetype.Face(fontfile)
# Set font size at 150 DPI (matching fontconvert.py) BEFORE any glyph load.
# load_glyph() with FT_LOAD_RENDER renders at the active size, so calling
# it before set_char_size() would waste work at the default size and risk
# Invalid_Size_Handle on some fonts.
face.set_char_size(size << 6, size << 6, 150, 150)
load_flags = freetype.FT_LOAD_RENDER
if force_autohint:
load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT
def load_glyph(code_point):
glyph_index = face.get_char_index(code_point)
if glyph_index > 0:
face.load_glyph(glyph_index, load_flags)
return face
return None
# 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):
if face.get_char_index(code_point) == 0:
if start < code_point:
validated_intervals.append((start, code_point - 1))
start = code_point + 1
if start <= i_end:
validated_intervals.append((start, i_end))
intervals = validated_intervals
total_glyphs = sum(end - start + 1 for start, end in intervals)
print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr)
# Rasterize all glyphs
total_bitmap_size = 0
all_glyphs = []
for i_start, i_end in intervals:
for code_point in range(i_start, i_end + 1):
f = load_glyph(code_point)
if f is None:
glyph = GlyphProps(0, 0, 0, 0, 0, 0, total_bitmap_size, code_point)
all_glyphs.append((glyph, b''))
continue
bitmap = f.glyph.bitmap
# Build 4-bit greyscale bitmap (same logic as fontconvert.py).
#
# FreeType returns the buffer with bitmap.pitch as the row stride
# in bytes, which can be negative when the bitmap is stored
# bottom-up. Iterating bitmap.buffer linearly assumes
# 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 = buf[row_offset + x]
if x % 2 == 0:
px = (v >> 4)
else:
px = px | (v & 0xF0)
pixels4g.append(px)
px = 0
if bitmap.width % 2 > 0:
pixels4g.append(px)
px = 0
# Downsample to 2-bit bitmap
pixels2b = []
px = 0
pitch = (bitmap.width // 2) + (bitmap.width % 2)
for y in range(bitmap.rows):
for x in range(bitmap.width):
px = px << 2
bm = pixels4g[y * pitch + (x // 2)]
bm = (bm >> ((x % 2) * 4)) & 0xF
if bm >= 12:
px += 3
elif bm >= 8:
px += 2
elif bm >= 4:
px += 1
if (y * bitmap.width + x) % 4 == 3:
pixels2b.append(px)
px = 0
if (bitmap.width * bitmap.rows) % 4 != 0:
# Outer parens are for clarity: in Python `*` binds tighter
# than `<<`, so the original `px << (4 - … % 4) * 2` already
# evaluates as `px << ((4 - … % 4) * 2)`. Match the explicit
# bracketing here so the shift width is obvious at a glance,
# mirroring the inner-loop style in fontconvert.py.
px = px << ((4 - (bitmap.width * bitmap.rows) % 4) * 2)
pixels2b.append(px)
packed = bytes(pixels2b)
glyph = GlyphProps(
width=bitmap.width,
height=bitmap.rows,
advance_x=fp4_from_ft16_16(f.glyph.linearHoriAdvance),
left=f.glyph.bitmap_left,
top=f.glyph.bitmap_top,
data_length=len(packed),
data_offset=total_bitmap_size,
code_point=code_point,
)
total_bitmap_size += len(packed)
all_glyphs.append((glyph, packed))
# Get font metrics from pipe character (same heuristic as fontconvert.py)
load_glyph(ord('|'))
advanceY = norm_ceil(face.size.height)
ascender = norm_ceil(face.size.ascender)
descender = norm_floor(face.size.descender)
print(f" [{style_label}] Metrics: advanceY={advanceY}, ascender={ascender}, descender={descender}", file=sys.stderr)
print(f" [{style_label}] Bitmap: {total_bitmap_size} bytes ({total_bitmap_size / 1024:.1f} KB)", file=sys.stderr)
# --- Extract kerning and ligatures ---
ppem = size * 150.0 / 72.0
all_cps = set(g.code_point for g, _ in all_glyphs)
kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem)
# SMP codepoints (> U+FFFF) cannot be stored in the uint16 kern codepoint
# field; drop them before class derivation to avoid a downstream
# struct.error when packing the binary kern tables.
kern_map = {(lcp, rcp): v for (lcp, rcp), v in kern_map.items() if lcp <= 0xFFFF and rcp <= 0xFFFF}
print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr)
(kern_left_classes, kern_right_classes, kern_matrix,
kern_left_class_count, kern_right_class_count) = derive_kern_classes(kern_map)
if kern_map:
matrix_size = kern_left_class_count * kern_right_class_count
entries_size = (len(kern_left_classes) + len(kern_right_classes)) * 3
print(f" [{style_label}] Kerning classes: {kern_left_class_count} left, {kern_right_class_count} right, "
f"{matrix_size + entries_size} bytes", file=sys.stderr)
# SMP codepoints in ligature inputs / outputs are filtered inside
# extract_ligatures_fonttools (see the codepoints_set filter), so every
# entry returned here is already 16-bit safe.
ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps)
if len(ligature_pairs) > 255:
print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating",
file=sys.stderr)
ligature_pairs = ligature_pairs[:255]
print(f" [{style_label}] Ligatures: {len(ligature_pairs)} pairs", file=sys.stderr)
return StyleRasterData(
style_id=style_id,
intervals=intervals,
all_glyphs=all_glyphs,
total_bitmap_size=total_bitmap_size,
advanceY=advanceY,
ascender=ascender,
descender=descender,
kern_left_classes=kern_left_classes,
kern_right_classes=kern_right_classes,
kern_matrix=kern_matrix,
kern_left_class_count=kern_left_class_count,
kern_right_class_count=kern_right_class_count,
ligature_pairs=ligature_pairs,
)
# --- Binary packing helpers ---
# EpdGlyph struct: 16 bytes, little-endian
GLYPH_STRUCT_FORMAT = "<BBHhhH2xI"
assert struct.calcsize(GLYPH_STRUCT_FORMAT) == 16
def pack_style_sections(sd):
"""Pack one StyleRasterData into binary section bytearrays.
Returns (intervals_data, glyphs_data, kern_left, kern_right, kern_matrix, ligatures, bitmaps)."""
intervals_data = bytearray()
offset = 0
for i_start, i_end in sd.intervals:
intervals_data += struct.pack("<III", i_start, i_end, offset)
offset += i_end - i_start + 1
glyphs_data = bytearray()
for glyph, packed in sd.all_glyphs:
glyphs_data += struct.pack(GLYPH_STRUCT_FORMAT,
glyph.width, glyph.height, glyph.advance_x,
glyph.left, glyph.top,
glyph.data_length, glyph.data_offset)
kern_left_data = bytearray()
for cp, cls in sd.kern_left_classes:
kern_left_data += struct.pack("<HB", cp, cls)
kern_right_data = bytearray()
for cp, cls in sd.kern_right_classes:
kern_right_data += struct.pack("<HB", cp, cls)
kern_matrix_data = bytearray()
if sd.kern_matrix:
kern_matrix_data = bytearray(struct.pack(f"<{len(sd.kern_matrix)}b", *sd.kern_matrix))
ligature_data = bytearray()
for packed_pair, lig_cp in sd.ligature_pairs:
ligature_data += struct.pack("<II", packed_pair, lig_cp)
bitmap_data = bytearray()
for glyph, packed in sd.all_glyphs:
bitmap_data += packed
assert len(bitmap_data) == sd.total_bitmap_size
return (intervals_data, glyphs_data, kern_left_data, kern_right_data,
kern_matrix_data, ligature_data, bitmap_data)
def style_sections_total_size(sections):
"""Total byte size of all sections returned by pack_style_sections()."""
return sum(len(s) for s in sections)
# --- File writers ---
def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
force_autohint=False):
"""Generate a multi-style v4 .cpfont file.
style_fonts: dict of {style_id: fontfile_path} e.g. {0: "Regular.ttf", 2: "Italic.ttf"}
"""
MAGIC = b"CPFONT\x00\x00"
HEADER_SIZE = 32
STYLE_TOC_ENTRY_SIZE = 32
flags = 1 # always 2-bit greyscale
style_count = len(style_fonts)
# Rasterize each style
raster_data = {} # style_id -> StyleRasterData
for style_id in sorted(style_fonts.keys()):
fontfile = style_fonts[style_id]
print(f" Rasterizing style {style_id}...", file=sys.stderr)
raster_data[style_id] = rasterize_font_style(
fontfile, size, intervals, style_id=style_id,
force_autohint=force_autohint)
# Pack binary sections for each style
packed_sections = {} # style_id -> tuple of section bytearrays
for style_id, sd in raster_data.items():
packed_sections[style_id] = pack_style_sections(sd)
# Calculate data offsets (after header + TOC)
data_start = HEADER_SIZE + style_count * STYLE_TOC_ENTRY_SIZE
current_offset = data_start
style_offsets = {} # style_id -> absolute file offset
for style_id in sorted(packed_sections.keys()):
style_offsets[style_id] = current_offset
current_offset += style_sections_total_size(packed_sections[style_id])
# Build global header
# V4 header: magic(8) + version(2) + flags(2) + styleCount(1) + reserved(19) = 32
header = struct.pack("<8sHHB19s", MAGIC, CPFONT_VERSION, flags, style_count, bytes(19))
assert len(header) == HEADER_SIZE
# Build style TOC entries
# Each entry: styleId(1) + pad(3) + intervalCount(4) + glyphCount(4) +
# advanceY(1) + ascender(2) + descender(2) + kernL(2) + kernR(2) +
# kernLCls(1) + kernRCls(1) + ligCount(1) + dataOffset(4) + reserved(4) = 32
STYLE_TOC_FORMAT = "<B3xIIBhhHHBBBI4x"
assert struct.calcsize(STYLE_TOC_FORMAT) == STYLE_TOC_ENTRY_SIZE
toc_data = bytearray()
for style_id in sorted(raster_data.keys()):
sd = raster_data[style_id]
if sd.advanceY > 255:
print(f"ERROR: advanceY ({sd.advanceY}) exceeds uint8 range for "
f"style {style_id} size {size}. This likely means the font "
f"size is too large for this format.",
file=sys.stderr)
sys.exit(1)
toc_data += struct.pack(STYLE_TOC_FORMAT,
style_id,
len(sd.intervals), len(sd.all_glyphs),
sd.advanceY, sd.ascender, sd.descender,
len(sd.kern_left_classes), len(sd.kern_right_classes),
sd.kern_left_class_count, sd.kern_right_class_count,
len(sd.ligature_pairs),
style_offsets[style_id])
# Write output
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
total_file_size = 0
with open(output_path, "wb") as f:
f.write(header)
f.write(toc_data)
for style_id in sorted(packed_sections.keys()):
for section in packed_sections[style_id]:
f.write(section)
total_file_size = f.tell()
# Print summary
print(f" Output: {output_path} (v4, {style_count} styles)", file=sys.stderr)
print(f" Header+TOC: {HEADER_SIZE + len(toc_data)} bytes", file=sys.stderr)
for style_id in sorted(raster_data.keys()):
sd = raster_data[style_id]
secs = packed_sections[style_id]
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
sname = style_names.get(style_id, str(style_id))
ssize = style_sections_total_size(secs)
print(f" {sname}: {len(sd.all_glyphs)} glyphs, {len(sd.intervals)} intervals, "
f"{ssize} bytes", file=sys.stderr)
print(f" Total: {total_file_size} bytes ({total_file_size / 1024 / 1024:.2f} MB)", file=sys.stderr)
return total_file_size
def main():
parser = argparse.ArgumentParser(
description="Generate .cpfont files for SD card font loading.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"Available interval presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}"
)
# Font file (positional, optional for multi-style mode)
parser.add_argument("fontfile", nargs="?", default=None,
help="Path to the font file (single-style mode).")
parser.add_argument("--intervals", dest="intervals",
help="Comma-separated interval presets (e.g., 'latin-ext,greek,cyrillic').")
parser.add_argument("--size", type=int, dest="size",
help="Single font size to generate.")
parser.add_argument("--sizes", dest="sizes",
help="Comma-separated sizes (e.g., '12,14,16,18').")
parser.add_argument("--style", dest="style", default="regular",
choices=["regular", "bold", "italic", "bolditalic"],
help="Font style for single-style mode (default: regular).")
parser.add_argument("--name", dest="name",
help="Font family name for output filenames (default: derived from font filename).")
parser.add_argument("--force-autohint", dest="force_autohint", action="store_true",
help="Force FreeType auto-hinter instead of native font hinting.")
parser.add_argument("-o", "--output", dest="output",
help="Output file path (for single-size mode).")
parser.add_argument("--output-dir", dest="output_dir",
help="Output directory for multi-size mode.")
parser.add_argument("--list-presets", action="store_true",
help="List available interval presets and exit.")
# Multi-style mode: per-style font file arguments (generates v4 .cpfont)
parser.add_argument("--regular", dest="font_regular",
help="Font file for regular style (enables multi-style v4 mode).")
parser.add_argument("--bold", dest="font_bold",
help="Font file for bold style.")
parser.add_argument("--italic", dest="font_italic",
help="Font file for italic style.")
parser.add_argument("--bolditalic", dest="font_bolditalic",
help="Font file for bold-italic style.")
args = parser.parse_args()
if args.list_presets:
print("Available interval presets:")
for name, ranges in sorted(INTERVAL_PRESETS.items()):
total = sum(e - s + 1 for s, e in ranges)
print(f" {name:15s} {len(ranges)} range(s), ~{total} codepoints")
sys.exit(0)
# Detect multi-style mode
style_fonts = {}
if args.font_regular:
style_fonts[0] = args.font_regular
if args.font_bold:
style_fonts[1] = args.font_bold
if args.font_italic:
style_fonts[2] = args.font_italic
if args.font_bolditalic:
style_fonts[3] = args.font_bolditalic
is_multistyle = len(style_fonts) > 0
fontfile = args.fontfile
# Require --intervals
if not args.intervals:
print("Error: --intervals is required (e.g., --intervals latin-ext,greek,cyrillic)", file=sys.stderr)
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
sys.exit(1)
intervals = resolve_intervals(args.intervals)
# Determine sizes
if args.sizes:
sizes = [int(s.strip()) for s in args.sizes.split(",")]
elif args.size:
sizes = [args.size]
else:
print("Error: --size or --sizes is required", file=sys.stderr)
sys.exit(1)
# Validate early: single-style mode requires a font file
if not is_multistyle and not fontfile:
print("Error: fontfile is required in single-style mode", file=sys.stderr)
sys.exit(1)
# Determine font name
if args.name:
font_name = args.name
elif is_multistyle:
# Derive from the regular font file
ref_file = style_fonts[min(style_fonts.keys())]
base = os.path.splitext(os.path.basename(ref_file))[0]
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
"-regular", "-bold", "-italic", "-bolditalic"]:
if base.endswith(suffix):
base = base[:-len(suffix)]
break
font_name = base
else:
base = os.path.splitext(os.path.basename(fontfile))[0]
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
"-regular", "-bold", "-italic", "-bolditalic"]:
if base.endswith(suffix):
base = base[:-len(suffix)]
break
font_name = base
if not is_multistyle:
# Single font file provided: wrap as a single-style v4 font
style_map = {"regular": 0, "bold": 1, "italic": 2, "bolditalic": 3}
style_fonts[style_map[args.style]] = fontfile
# Always generate v4 format
if args.output and len(sizes) != 1:
print("Error: --output can only be used with a single size", file=sys.stderr)
sys.exit(1)
output_dir = args.output_dir if args.output_dir else f"{font_name}/"
total_size = 0
for sz in sizes:
if args.output and len(sizes) == 1:
output_path = args.output
else:
filename = f"{font_name}_{sz}.cpfont"
output_path = os.path.join(output_dir, filename)
print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr)
total_size += generate_cpfont_multistyle(
style_fonts, sz, intervals, output_path,
force_autohint=args.force_autohint)
print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -1 +1,3 @@
freetype-py==2.5.1
fonttools>=4.62.1
freetype-py>=2.5.1
pyyaml>=6.0.3
+211
View File
@@ -0,0 +1,211 @@
# SD Card Font Families for CrossPoint Reader
#
# This file is the single source of truth for which fonts are generated,
# how they're sourced, and how they're described in the download manifest.
#
# Adding a new font family = adding a block here. No code changes needed.
#
# Fields:
# name: Output family name (used in filenames and on-device UI)
# description: Human-readable description (shown in download UI and manifest)
# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py
# sizes: Point sizes to generate
# force_autohint: (optional) Force FreeType auto-hinter instead of native hinting
# styles: Map of style name -> font source
# path: relative to lib/EpdFont (for committed fonts)
# url: download URL (for fonts not in the repo)
#
# Variable fonts:
# Some fonts (Bitter, Inter, Alegreya) are distributed as variable fonts.
# freetype-py can't set variable font axis values, so the build script
# uses fonttools.instancer to extract static instances automatically.
#
# To use a variable font, add a 'variable' key to the style spec with
# axis values to pin:
#
# styles:
# regular: {url: "https://...Font[wght].ttf", variable: {wght: 400}}
# bold: {url: "https://...Font[wght].ttf", variable: {wght: 700}}
#
# Extracted static fonts are cached in instanced_fonts/ (gitignored).
# Requires fonttools: pip install -r requirements.txt
families:
# ── Serif ──────────────────────────────────────────────────────────────
- name: Literata
description: "Screen-optimized serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"}
- name: SourceSerif4
description: "Adobe transitional serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"}
- name: NotoSerifExtended
description: "Serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"}
- name: Merriweather
description: "Warm serif for long-form reading (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"}
- name: Lora
description: "Calligraphic serif for literary reading (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"}
- name: GentiumBookPlus
description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"}
- name: IBMPlexSerif
description: "Professional serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"}
- name: Bitter
description: "Slab serif designed for e-ink (Latin, Cyrillic)"
intervals: latin-ext,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 500}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 500}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Sans-serif ─────────────────────────────────────────────────────────
- name: NotoSansExtended
description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)"
intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic
sizes: [12, 14, 16, 18]
styles:
regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"}
bold: {path: "builtinFonts/source/NotoSans/NotoSans-Bold.ttf"}
italic: {path: "builtinFonts/source/NotoSans/NotoSans-Italic.ttf"}
bolditalic: {path: "builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf"}
- name: Inter
description: "Modern sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
- name: SourceSans3
description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"}
- name: IBMPlexSans
description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"}
- name: Alegreya
description: "Calligraphic serif/display (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
# ── Monospace ──────────────────────────────────────────────────────────
- name: IBMPlexMono
description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)"
intervals: latin-ext,greek,cyrillic
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"}
- name: SourceCodePro
description: "Adobe monospace with excellent hinting (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"}
# ── Accessibility ──────────────────────────────────────────────────────
- name: AtkinsonHyperlegibleNext
description: "Accessibility font for low vision (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"}
- name: LexicaUltralegible
description: "Accessibility font for low vision / dyslexia (Latin)"
intervals: latin-ext
sizes: [12, 14, 16, 18]
styles:
regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"}
bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"}
italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"}
bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"}
@@ -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):
+15 -9
View File
@@ -155,6 +155,7 @@ bool Epub::parseTocNcxFile() const {
return false;
}
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
// Explicitly close() file before reopening for reading
tempNcxFile.close();
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
@@ -165,14 +166,12 @@ bool Epub::parseTocNcxFile() const {
if (!ncxParser.setup()) {
LOG_ERR("EBP", "Could not setup toc ncx parser");
tempNcxFile.close();
return false;
}
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) {
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
tempNcxFile.close();
return false;
}
@@ -184,12 +183,12 @@ bool Epub::parseTocNcxFile() const {
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc ncx data");
free(ncxBuffer);
tempNcxFile.close();
return false;
}
}
free(ncxBuffer);
// Explicitly close() file before calling Storage.remove()
tempNcxFile.close();
Storage.remove(tmpNcxPath.c_str());
@@ -212,6 +211,7 @@ bool Epub::parseTocNavFile() const {
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
// Explicitly close() file before reopening for reading
tempNavFile.close();
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
@@ -241,12 +241,12 @@ bool Epub::parseTocNavFile() const {
if (processedSize != readSize) {
LOG_ERR("EBP", "Could not process all toc nav data");
free(navBuffer);
tempNavFile.close();
return false;
}
}
free(navBuffer);
// Explicitly close() file before calling Storage.remove()
tempNavFile.close();
Storage.remove(tmpNavPath.c_str());
@@ -304,10 +304,12 @@ void Epub::parseCssFiles() const {
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
// Explicitly close() file before calling Storage.remove()
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
// Explicitly close() file before reopening for reading
tempCssFile.close();
// Parse the CSS file
@@ -317,6 +319,7 @@ void Epub::parseCssFiles() const {
continue;
}
cssParser->loadFromStream(tempCssFile);
// Explicitly close() file before calling Storage.remove()
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
}
@@ -547,6 +550,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
@@ -555,10 +559,10 @@ bool Epub::generateCoverBmp(bool cropped) const {
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
// Explicitly close() files before calling Storage.remove()
coverJpg.close();
coverBmp.close();
Storage.remove(coverJpgTempPath.c_str());
@@ -580,6 +584,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
@@ -588,10 +593,10 @@ bool Epub::generateCoverBmp(bool cropped) const {
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverPng.close();
return false;
}
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
// Explicitly close() files before calling Storage.remove()
coverPng.close();
coverBmp.close();
Storage.remove(coverPngTempPath.c_str());
@@ -634,6 +639,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
// Explicitly close() file before reopening for reading
coverJpg.close();
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
@@ -642,7 +648,6 @@ bool Epub::generateThumbBmp(int height) const {
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
@@ -651,6 +656,7 @@ bool Epub::generateThumbBmp(int height) const {
int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
// Explicitly close() files before calling Storage.remove()
coverJpg.close();
thumbBmp.close();
Storage.remove(coverJpgTempPath.c_str());
@@ -670,6 +676,7 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
// Explicitly close() file before reopening for reading
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
@@ -678,13 +685,13 @@ bool Epub::generateThumbBmp(int height) const {
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverPng.close();
return false;
}
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success =
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
// Explicitly close() files before calling Storage.remove()
coverPng.close();
thumbBmp.close();
Storage.remove(coverPngTempPath.c_str());
@@ -702,7 +709,6 @@ bool Epub::generateThumbBmp(int height) const {
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close();
return false;
}
+16 -8
View File
@@ -4,7 +4,7 @@
#include <Serialization.h>
#include <ZipFile.h>
#include <vector>
#include <deque>
#include "FsHelpers.h"
@@ -33,6 +33,7 @@ bool BookMetadataCache::beginContentOpfPass() {
}
bool BookMetadataCache::endContentOpfPass() {
// Explicit close() required: member variable persists beyond function scope
spineFile.close();
return true;
}
@@ -44,13 +45,14 @@ bool BookMetadataCache::beginTocPass() {
return false;
}
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
// Explicit close() required: member variable persists beyond function scope
spineFile.close();
return false;
}
if (spineCount >= LARGE_SPINE_THRESHOLD) {
spineHrefIndex.clear();
spineHrefIndex.reserve(spineCount);
spineHrefIndex.resize(spineCount);
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto entry = readSpineEntry(spineFile);
@@ -58,7 +60,7 @@ bool BookMetadataCache::beginTocPass() {
idx.hrefHash = fnvHash64(entry.href);
idx.hrefLen = static_cast<uint16_t>(entry.href.size());
idx.spineIndex = static_cast<int16_t>(i);
spineHrefIndex.push_back(idx);
spineHrefIndex[i] = idx;
}
std::sort(spineHrefIndex.begin(), spineHrefIndex.end(),
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
@@ -75,6 +77,7 @@ bool BookMetadataCache::beginTocPass() {
}
bool BookMetadataCache::endTocPass() {
// Explicit close() required: member variables persist beyond function scope
tocFile.close();
spineFile.close();
@@ -103,11 +106,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
// Explicit close() required: member variable persists beyond function scope
bookFile.close();
return false;
}
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
return false;
@@ -153,7 +158,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
// Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m))
std::vector<int16_t> spineToTocIndex(spineCount, -1);
std::deque<int16_t> spineToTocIndex(spineCount, -1);
tocFile.seek(0);
for (int j = 0; j < tocCount; j++) {
auto tocEntry = readTocEntry(tocFile);
@@ -168,6 +173,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Pre-open zip file to speed up size calculations
if (!zip.open()) {
LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
tocFile.close();
@@ -181,14 +187,14 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion.
// See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134
std::vector<uint32_t> spineSizes;
std::deque<uint32_t> spineSizes;
bool useBatchSizes = false;
if (spineCount >= LARGE_SPINE_THRESHOLD) {
LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
std::vector<ZipFile::SizeTarget> targets;
targets.reserve(spineCount);
std::deque<ZipFile::SizeTarget> targets;
targets.resize(spineCount);
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
@@ -199,7 +205,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
t.hash = ZipFile::fnvHash64(path.c_str(), path.size());
t.len = static_cast<uint16_t>(path.size());
t.index = static_cast<uint16_t>(i);
targets.push_back(t);
targets[i] = t;
}
std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) {
@@ -265,6 +271,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
writeTocEntry(bookFile, tocEntry);
}
// Explicit close() required: member variables persist beyond function scope
bookFile.close();
spineFile.close();
tocFile.close();
@@ -373,6 +380,7 @@ bool BookMetadataCache::load() {
serialization::readPod(bookFile, version);
if (version != BOOK_CACHE_VERSION) {
LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
// Explicit close() required: member variable persists beyond function scope
bookFile.close();
return false;
}
+5 -5
View File
@@ -3,8 +3,8 @@
#include <HalStorage.h>
#include <algorithm>
#include <deque>
#include <string>
#include <vector>
class BookMetadataCache {
public:
@@ -18,11 +18,11 @@ class BookMetadataCache {
struct SpineEntry {
std::string href;
size_t cumulativeSize;
uint32_t cumulativeSize;
int16_t tocIndex;
SpineEntry() : cumulativeSize(0), tocIndex(-1) {}
SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex)
SpineEntry(std::string href, const uint32_t cumulativeSize, const int16_t tocIndex)
: href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {}
};
@@ -44,7 +44,7 @@ class BookMetadataCache {
private:
std::string cachePath;
size_t lutOffset;
uint32_t lutOffset;
uint16_t spineCount;
uint16_t tocCount;
bool loaded;
@@ -61,7 +61,7 @@ class BookMetadataCache {
uint16_t hrefLen; // length for collision reduction
int16_t spineIndex;
};
std::vector<SpineHrefIndexEntry> spineHrefIndex;
std::deque<SpineHrefIndexEntry> spineHrefIndex;
bool useSpineHrefIndex = false;
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;

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