Compare commits

...
Author SHA1 Message Date
Dexif ebe3123997 fix: add missing keyboard metrics to Lyra3CoversTheme (#1101)
Compile Release / build-release (push) Canceled after 0s
Fix for keyboard. Issue #1100
2026-02-23 18:00:17 +11:00
Dave Allie fa4c8a4e33 chore: Bump version to 1.1.1 2026-02-23 18:00:17 +11:00
jpirnay c3093e3f71 fix: Fix hyphenation and rendering of decomposed characters (#1037)
Compile Release / build-release (push) Canceled after 0s
## Summary

* This PR fixes decomposed diacritic handling end-to-end:
- Hyphenation: normalize common Latin base+combining sequences to
precomposed codepoints before Liang pattern matching, so decomposed
words hyphenate correctly
- Rendering: correct combining-mark placement logic so non-spacing marks
are attached to the preceding base glyph in normal and rotated text
rendering paths, with corresponding text-bounds consistency updates.
- Hyphenation around non breaking space variants have been fixed (and
extended)
- Hyphenation of terms that already included of hyphens were fixed to
include Liang pattern application (eg "US-Satellitensystem" was
*exclusively* broken at the existing hyphen)

## Additional Context

* Before
<img width="800" height="480" alt="2"
src="https://github.com/user-attachments/assets/b9c515c4-ab75-45cc-8b52-f4d86bce519d"
/>


* After
<img width="480" height="800" alt="fix1"
src="https://github.com/user-attachments/assets/4999f6a8-f51c-4c0a-b144-f153f77ddb57"
/>
<img width="800" height="480" alt="fix2"
src="https://github.com/user-attachments/assets/7355126b-80c7-441f-b390-4e0897ee3fb6"
/>

* Note 1: the hyphenation fix is not a 100% bullet proof implementation.
It adds composition of *common* base+combining sequences (e.g. O +
U+0308 -> Ö) during codepoint collection. A complete solution would
require implementing proper Unicode normalization (at least NFC,
possibly NFKC in specific cases) before hyphenation and rendering,
instead of hand-mapping a few combining marks. That was beyond the scope
of this fix.

* Note 2: the render fix should be universal and not limited to the
constraints outlined above: it properly x-centers the compund glyph over
the previous one, and it uses at least 1pt of visual distance in y.

Before:
<img width="478" height="167" alt="Image"
src="https://github.com/user-attachments/assets/f8db60d5-35b1-4477-96d0-5003b4e4a2a1"
/>

After: 
<img width="479" height="180" alt="Image"
src="https://github.com/user-attachments/assets/1b48ef97-3a77-475a-8522-23f4aca8e904"
/>

* This should resolve the issues described in #998 
---

### 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-02-22 13:11:37 +11:00
DestinySpeaker 498e087a68 fix: Fixed book title in home screen (#1013)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
* The goal is to fix the title of books in the Home Screen. 

Before

![IMG_8867](https://github.com/user-attachments/assets/6cc9ca22-b95b-4863-872d-ef427c42f833)

After:

![IMG_8868](https://github.com/user-attachments/assets/585031b1-2348-444c-8f32-073fed3b6582)

* **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, Cursor
2026-02-22 13:00:08 +11:00
pablohc e44c004be6 fix: improve Spanish translations (#1054)
## Summary

* **What is the goal of this PR?**
* improve Spanish translations

* **What changes are included?**

- Fix typos and accents (Librería, conexión, etc.)
- Translate untranslated strings (BOOTING, SLEEPING, etc.)
- Improve consistency and conciseness
- Fix question mark placement (¿...?)
- Standardize terminology (Punto de Acceso, Suspensión, etc.)

---

### 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-02-22 13:00:01 +11:00
Luke Stein 45d19f6e1b fix: Shorten "Forget Wifi" button labels to fit on button (#1045) 2026-02-22 12:59:49 +11:00
Dave Allie 8f33bee6ef fix: Destroy CSS Cache file when invalid (#1018)
## Summary

* Destroy CSS Cache file when invalid

## Additional Context

* Fixes issue where it would attempt to rebuild every book open

---

### 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-02-20 17:05:08 +11:00
pablohc bc1ba7277f fix: continue reading card classic theme (#990)
## Summary

* **What is the goal of this PR?** 
* **What changes are included?**

- Adapt card width to cover image aspect ratio in Classic theme
- Increase homeTopPadding from 20px to 40px to avoid overlap with
battery icon
- Card width now calculated from BMP dimensions instead of fixed 240px
- Maximum card width limited to 90% of screen width
- Falls back to original behavior (half screen width) when no cover
available

## Additional Context

* Solve conflicts in PR #683 

Before:
<img width="1052" height="1014" alt="image"
src="https://github.com/user-attachments/assets/6c857913-d697-4e9e-9695-443c0a4c0804"
/>

PR:

![Screenshot_2026-02-19-14-22-36-68_99c04817c0de5652397fc8b56c3b3817](https://github.com/user-attachments/assets/81505728-d42e-41bd-bd77-44848e05b1eb)


---

### 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-02-20 16:39:30 +11:00
Dave Allie 57267f5372 fix: Strip unused CSS rules (#1014)
## Summary

* In a sample book I loaded, it had 900+ CSS rules, and took up 180kB of
memory loading the cache in
* Looking at the rules, a lot of them were completely useless as we only
ever apply look for 3 kinds of CSS rules:
    * `tag`
    * `tag.class1`
    * `.class1`
* Stripping out CSS rules with descendant, nested, attribute matching,
sibling matching, pseudo element selection (as we never actually read
these from the cache) reduced the rule count down to 200

## Additional Context

* I've left in `.class1.class2` rules for now, even though we
technically can never match on them as they're likely to be addressed
soonest out of the all the CSS expansion
* Because we don't ever delete the CSS cache, users will need to delete
the book cache through the menu in order to get this new logic
* A new PR should be done up to address this - tracked here
https://github.com/crosspoint-reader/crosspoint-reader/issues/1015

---

### 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-02-20 16:39:25 +11:00
DestinySpeaker 7c4f69680c fix: Fixed Image Sizing When No Width is Set (#1002)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
When no width is set for an image, the image currently automatically
sets to the width of the page. However, with this fix, the parser will
use the height and aspect ratio of the image to properly set a height
for it. See below example:


Before:

![IMG_8862](https://github.com/user-attachments/assets/64b3b92f-1165-45ca-8bdb-8e69613d9725)

After:

![IMG_8863](https://github.com/user-attachments/assets/5cb99b12-d150-4b37-ae4c-c8a20eb9f3a0)


* **What changes are included?✱
Changes to the CSS parser

## 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, Cursor
2026-02-20 16:39:22 +11:00
martin brook 2cc497cdca fix: use double FAST_REFRESH to prevent washout on large grey images (#957)
## Summary

Fixes https://github.com/crosspoint-reader/crosspoint-reader/issues/1011

Use double FAST_REFRESH for image pages to prevent grayscale washout,
HALF_REFRESH sets e-ink particles too firmly for the grayscale LUT to
adjust, causing washed-out images (especially large, light-gray ones).
Replace HALF_REFRESH with @pablohc's double FAST_REFRESH technique:
blank only the image bounding box area, then re-render with images. This
clears ghosting while keeping particles loosely set for grayscale.

## 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? _**<  PARTIALLY  >**_
2026-02-20 16:39:17 +11:00
Lev Roland-Kalb 8db3542e90 fix: re-implementing Cover Outlines for the new Lyra Themes (#1017)
## Summary

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

Improve legibility of Cover Icons on the home page and elsewhere. Fixes
#898
Re implements the changes made in #907 that were overwritten by the new
lyra themes

* **What changes are included?**
Cover outline is now shown even when cover is found to prevent issues
with low contrast covers blending into the background. Photo is attached
below:

<img width="1137" height="758" alt="Untitled (4)"
src="https://github.com/user-attachments/assets/21ae6c94-4b43-4a0c-bec7-a6e4c642ffad"
/>



## Additional Context

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

Re implements the changes made in #907 that were overwritten by the new
lyra themes

---

### 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-02-20 16:39:14 +11:00
jpirnay 87d9d1dc2a perf: Improve font drawing performance (#978)
## Summary

* ``renderChar`` checked ``is2Bit`` on every pixel inside the inner
loop, even though the value is constant for the lifetime of a single
glyph
* Moved the branch above both loops so each path (2-bit antialiased /
1-bit monochrome) runs without a per-pixel conditional
* Eliminates redundant work in the two inner loops that render font
glyphs to the frame buffer, targeting ``renderChar`` and
``drawTextRotated90CW`` in ``GfxRenderer.cpp``

## Additional Context
* Measured on device using a dedicated framebuffer benchmark (no display
refresh). 100 repetitions of "The quick brown fox jumps".

| Test            | Before          | After           | Change  |
|-----------------|-----------------|-----------------|---------|
| drawText UI12   | 1,337 µs/call | 1,024 µs/call | −23%|
| drawText Bookerly14 | 2.174 µs / call | 1,847 µs/call | −15% |

---

### 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**_ Claude did
the analysis and wrote the benchmarks
2026-02-20 16:39:10 +11:00
Uri Tauber 588984ec30 fix: Fix dangling pointer (#1010)
## Summary

* **What is the goal of this PR?** Small fix for bug I found.

## Additional Context


1. `RecentBooksActivity::loop()` calls
`onSelectBook(recentBooks[selectorIndex].path)` - passing a
**reference** to the path
  2. `onSelectBook` is `onGoToReader` which first calls `exitActivity()`
3. `exitActivity()` triggers `RecentBooksActivity::onExit()` which call
`recentBooks.clear()`
4. The string reference `initialEpubPath` is now a **dangling
reference** - the underlying string has been destroyed
5. When the reference is then used in `new ReaderActivity(...)`, it
reads garbage memory
6. The same issue occurs in `HomeActivity` at line 200 with the same
pattern

The fix is to make a copy of the string in `onGoToReader` before calling
`exitActivity()`, so the path data persists even after the activity
clears its data structures.

---

### AI Usage

Did you use AI tools to help write this code? _**< YES >**_ Claude found
the bug, after I shared with it a serial log.
2026-02-20 16:39:05 +11:00
Arthur Tazhitdinov b8e743ef80 fix: Increase PNGdec buffer size to support wide images (#995)
## Summary

* Increased `PNG_MAX_BUFFERED_PIXELS` from 6402 to 16416 in
`platformio.ini` to support up to 2048px wide RGBA images
* adds a check to abort decoding and log an error if the required PNG
scanline buffer exceeds the configured `PNG_MAX_BUFFERED_PIXELS`,
preventing possible buffer overruns.
* fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/993

---

### 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-02-20 03:51:57 +11:00
Uri Tauber 3e2c518b8e fix: Crash (Load access fault) when indexing chapters containing characters unsupported by bold/italic font variants (#997)
## Summary

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

I flashed the last revision before commit f1740dbe, and chapter indexing
worked without any crashes.
After applying f1740dbe, the same chapter consistently triggered a
device reboot during indexing.

The affected chapter contains inline equation images surrounded by
styled (bold/italic) text that includes special math/symbol characters.

## Additional Context

Prior to f1740dbe, both `getTextAdvanceX()` and `getSpaceWidth()` always
measured text using `EpdFontFamily::REGULAR`, regardless of the actual
style.

Commit f1740dbe improved correctness by passing the active style so
spacing is calculated using the actual bold/italic font variant.

However, bold and italic variants have narrower Unicode coverage than
the regular font. When a character exists in the regular font but not in
the selected styled variant, `pdFont::getGlyph()` returns `nullptr`.

The updated measurement functions did not check for this and immediately
dereferenced the pointer:

`width += font.getGlyph(cp, style)->advanceX;   // nullptr->advanceX`


Because `advanceX` is located at byte offset 2 within `EpdGlyph`,
dereferencing a null pointer caused the CPU to attempt a load from
address `0x00000002`, resulting in a RISC-V:
Load access fault
MCAUSE = 5
MTVAL = 2


## Fix

Added null-safety checks to both `getTextAdvanceX()` and
`getSpaceWidth()`, following the same pattern used in the rendering
path:

If the glyph is missing in the selected style → fall back to the
replacement glyph.

If the replacement glyph is also unavailable → treat the character as
zero-width.

This preserves the improved style-correct spacing while preventing
crashes.
No behavioral changes occur for characters that are supported by the
selected font variant.

---

### AI Usage

Did you use AI tools to help write this code? _**< YES >**_
I encounter this bug while testing 1.1.0 RC. 
I pasted the serial log to Claude, which identify the bug and fixed it.
I can confirm now the chapter in question is indexed and loaded
correctly.
2026-02-20 03:45:26 +11:00
Dave Allie 402e887f73 chore: Bump version to 1.1.0 2026-02-20 00:23:14 +11:00
Maik Allgöwer 103fac2ee1 feat: Basic table support (#980)
I've been reading "Children of Time" over the last days and that book,
annyoingly, has some tabular content.
This content is relevant for the story so I needed some really basic way
to at least be able to read those tables.


This commit simply renders the contents of table cells as separate
paragraphs with a small header describing its position in the table. For
me, it's better than nothing.

## Summary

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

Implements really basic table support

* **What changes are included?**

  * Minimal changes to ChapterHtmlSlimParser
  * A demo book in test/epubs

## Additional Context

Here's some screenshots of the demo-book I provide with this PR.


![PXL_20260218_211446510](https://github.com/user-attachments/assets/49ef81b8-2fa0-4f0d-bb6f-4ef885be6772)


![PXL_20260218_211456379](https://github.com/user-attachments/assets/e7c82b35-b4a9-4a7d-9ec5-2b4bc2ff3514)

---

### 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**_ 
_Little bit of guidance on what to touch, parts of the impl, rest
manually._
2026-02-20 00:13:23 +11:00
Sam Lord 6527f43cb1 feat: Scale cover images up if they're smaller than the device resolution (#964)
## Summary

**What is the goal of this PR?**
* Implement feature request
[#954](https://github.com/crosspoint-reader/crosspoint-reader/issues/954)
* Ensure cover images are scaled up to match the dimensions of the
screen, as well as scaled down

**What changes are included?**
* Naïve implementation for scaling up the source image

## Additional Context

If you find the extra comments to be excessive I can pare them back. 

Edit: Fixed title

---

### 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-02-20 00:00:51 +11:00
ariel-lindemann eb241ab3fc feat: increase keyboard font size for classic theme (#897)
## Summary

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

Adresses Feature Request #896 

* **What changes are included?**

Changed key dimensions, initial positions and margins.

## Additional Context

The keyboard now looks like this:

![image](https://github.com/user-attachments/assets/e2b8f3fe-e54a-4a44-9a29-2ef9f2c8dffb)

---

### 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-02-19 23:51:33 +11:00
Xuan-Son Nguyen 840e8c38f1 feat: add script for listing objects in flash (#880)
## Summary

Adding a simple script to list objects and its size. I know `pio`
already had the "analyze" function but it never works in my case.

Ref discussion:
https://github.com/crosspoint-reader/crosspoint-reader/discussions/862

To use it:

```sh
scripts/script_profile_mem.sh
```

Example:

```
============================================
Top 10 largest symbols in section: .dram0.bss
Total section size: 85976 bytes (83.96 KB)
============================================
    0000bb98 (  46.90 KB)  display
    00000ed8 (   3.71 KB)  g_cnxMgr
    00000ad8 (   2.71 KB)  ftm_initiator
    00000830 (   2.05 KB)  xIsrStack
    000005b4 (   1.43 KB)  packet.8427
    000004e0 (   1.22 KB)  _ZN12_GLOBAL__N_17ctype_wE
    000004a0 (   1.16 KB)  dns_table
    0000049c (   1.15 KB)  s_wifi_nvs
    00000464 (   1.10 KB)  s_coredump_stack
    0000034c (   0.82 KB)  gWpaSm

============================================
Top 10 largest symbols in section: .dram0.data
Total section size: 13037 bytes (12.73 KB)
============================================
    000003c0 (   0.94 KB)  TxRxCxt
    00000328 (   0.79 KB)  phy_param
    000001d0 (   0.45 KB)  g_wifi_osi_funcs
    0000011b (   0.28 KB)  _ZN18CrossPointSettings8instanceE
    000000e6 (   0.22 KB)  country_info_24ghz
    000000dc (   0.21 KB)  g_eb_list_desc
    000000c0 (   0.19 KB)  s_fd_table
    000000b8 (   0.18 KB)  g_timer_info
    000000a8 (   0.16 KB)  rc11NSchedTbl
    000000a0 (   0.16 KB)  g_rmt_objects

============================================
Top 200 largest symbols in section: .flash.rodata
Total section size: 4564375 bytes (4457.40 KB)
============================================
    000325b7 ( 201.43 KB)  _ZL12de_trie_data
    0001cbd8 ( 114.96 KB)  _ZL29notosans_18_bolditalicBitmaps
    0001ca38 ( 114.55 KB)  _ZL29bookerly_18_bolditalicBitmaps
    0001bd3f ( 111.31 KB)  _ZL23bookerly_18_boldBitmaps
    0001af54 ( 107.83 KB)  _ZL23notosans_18_boldBitmaps
    0001abcc ( 106.95 KB)  _ZL25bookerly_18_italicBitmaps
    0001a341 ( 104.81 KB)  _ZL25notosans_18_italicBitmaps
    0001a0a5 ( 104.16 KB)  _ZL26bookerly_18_regularBitmaps
    0001890c (  98.26 KB)  _ZL26notosans_18_regularBitmaps
    000188cc (  98.20 KB)  _ZL33opendyslexic_14_bolditalicBitmaps
    000170ca (  92.20 KB)  _ZL29notosans_16_bolditalicBitmaps
    00015e7f (  87.62 KB)  _ZL29bookerly_16_bolditalicBitmaps
    00015acb (  86.70 KB)  _ZL23notosans_16_boldBitmaps
    00015140 (  84.31 KB)  _ZL23bookerly_16_boldBitmaps
    00014f0c (  83.76 KB)  _ZL25notosans_16_italicBitmaps
    00014d54 (  83.33 KB)  _ZL29opendyslexic_14_italicBitmaps
    00014766 (  81.85 KB)  _ZL27opendyslexic_14_boldBitmaps
    0001467f (  81.62 KB)  _ZL25bookerly_16_italicBitmaps
    00013c46 (  79.07 KB)  _ZL26bookerly_16_regularBitmaps
    00013934 (  78.30 KB)  _ZL26notosans_16_regularBitmaps
    00012572 (  73.36 KB)  _ZL33opendyslexic_12_bolditalicBitmaps
    00011cee (  71.23 KB)  _ZL29notosans_14_bolditalicBitmaps
    00011547 (  69.32 KB)  _ZL30opendyslexic_14_regularBitmaps
    0001153c (  69.31 KB)  _ZL29bookerly_14_bolditalicBitmaps
    00010c2e (  67.04 KB)  _ZL23notosans_14_boldBitmaps
    0001096e (  66.36 KB)  _ZL23bookerly_14_boldBitmaps
    000103d2 (  64.96 KB)  _ZL25notosans_14_italicBitmaps
    000100d5 (  64.21 KB)  _ZL25bookerly_14_italicBitmaps
    0000f83e (  62.06 KB)  _ZL26bookerly_14_regularBitmaps
    0000f7fc (  62.00 KB)  _ZL29opendyslexic_12_italicBitmaps
    0000f42b (  61.04 KB)  _ZL26notosans_14_regularBitmaps
    0000f229 (  60.54 KB)  _ZL27opendyslexic_12_boldBitmaps
    0000d301 (  52.75 KB)  _ZL29notosans_12_bolditalicBitmaps
    0000d22f (  52.55 KB)  _ZL30opendyslexic_12_regularBitmaps
    0000cff4 (  51.99 KB)  _ZL29bookerly_12_bolditalicBitmaps
    0000cd12 (  51.27 KB)  _ZL33opendyslexic_10_bolditalicBitmaps
    0000cad2 (  50.71 KB)  _ZL23bookerly_12_boldBitmaps
    0000c60a (  49.51 KB)  _ZL23notosans_12_boldBitmaps
    0000c147 (  48.32 KB)  _ZL25bookerly_12_italicBitmaps
    0000c120 (  48.28 KB)  _ZL25notosans_12_italicBitmaps
    0000ba7e (  46.62 KB)  _ZL26bookerly_12_regularBitmaps
    0000b454 (  45.08 KB)  _ZL26notosans_12_regularBitmaps
    0000b1e0 (  44.47 KB)  _ZL29opendyslexic_10_italicBitmaps
    0000a939 (  42.31 KB)  _ZL27opendyslexic_10_boldBitmaps
    0000a0dd (  40.22 KB)  _ZL13FilesPageHtml
    0000949d (  37.15 KB)  _ZL30opendyslexic_10_regularBitmaps
    00008415 (  33.02 KB)  _ZL32opendyslexic_8_bolditalicBitmaps
    00008240 (  32.56 KB)  _ZL15ru_ru_trie_data
    00007587 (  29.38 KB)  _ZL28opendyslexic_8_italicBitmaps
    00006f4d (  27.83 KB)  _ZL26opendyslexic_8_boldBitmaps
    00006943 (  26.32 KB)  _ZL15en_us_trie_data
    00006196 (  24.40 KB)  _ZL29opendyslexic_8_regularBitmaps
    00004990 (  18.39 KB)  _ZL21ubuntu_12_boldBitmaps
    000042ce (  16.70 KB)  _ZL24ubuntu_12_regularBitmaps
    000036d0 (  13.70 KB)  _ZL25notosans_18_regularGlyphs
    000036d0 (  13.70 KB)  _ZL25notosans_16_regularGlyphs
    000036d0 (  13.70 KB)  _ZL25notosans_14_regularGlyphs
    000036d0 (  13.70 KB)  _ZL25notosans_12_regularGlyphs
    000036d0 (  13.70 KB)  _ZL24notosans_8_regularGlyphs
    000036d0 (  13.70 KB)  _ZL22notosans_18_boldGlyphs
    000036d0 (  13.70 KB)  _ZL22notosans_16_boldGlyphs
    000036d0 (  13.70 KB)  _ZL22notosans_14_boldGlyphs
    000036d0 (  13.70 KB)  _ZL22notosans_12_boldGlyphs
    000036c0 (  13.69 KB)  _ZL28notosans_18_bolditalicGlyphs
    000036c0 (  13.69 KB)  _ZL28notosans_16_bolditalicGlyphs
    000036c0 (  13.69 KB)  _ZL28notosans_14_bolditalicGlyphs
    000036c0 (  13.69 KB)  _ZL28notosans_12_bolditalicGlyphs
    000036c0 (  13.69 KB)  _ZL24notosans_18_italicGlyphs
    000036c0 (  13.69 KB)  _ZL24notosans_16_italicGlyphs
    000036c0 (  13.69 KB)  _ZL24notosans_14_italicGlyphs
    000036c0 (  13.69 KB)  _ZL24notosans_12_italicGlyphs
    00003627 (  13.54 KB)  _ZL21ubuntu_10_boldBitmaps
    00003551 (  13.33 KB)  _ZL12es_trie_data
    000030c4 (  12.19 KB)  _ZL24ubuntu_10_regularBitmaps
    00002eb0 (  11.67 KB)  _ZL28bookerly_18_bolditalicGlyphs
    00002eb0 (  11.67 KB)  _ZL28bookerly_16_bolditalicGlyphs
    00002eb0 (  11.67 KB)  _ZL28bookerly_14_bolditalicGlyphs
    00002eb0 (  11.67 KB)  _ZL28bookerly_12_bolditalicGlyphs
    00002eb0 (  11.67 KB)  _ZL25bookerly_18_regularGlyphs
    00002eb0 (  11.67 KB)  _ZL25bookerly_16_regularGlyphs
    00002eb0 (  11.67 KB)  _ZL25bookerly_14_regularGlyphs
    00002eb0 (  11.67 KB)  _ZL25bookerly_12_regularGlyphs
    00002eb0 (  11.67 KB)  _ZL24bookerly_18_italicGlyphs
    00002eb0 (  11.67 KB)  _ZL24bookerly_16_italicGlyphs
    00002eb0 (  11.67 KB)  _ZL24bookerly_14_italicGlyphs
    00002eb0 (  11.67 KB)  _ZL24bookerly_12_italicGlyphs
    00002eb0 (  11.67 KB)  _ZL22bookerly_18_boldGlyphs
    00002eb0 (  11.67 KB)  _ZL22bookerly_16_boldGlyphs
    00002eb0 (  11.67 KB)  _ZL22bookerly_14_boldGlyphs
    00002eb0 (  11.67 KB)  _ZL22bookerly_12_boldGlyphs
    00002d50 (  11.33 KB)  _ZL32opendyslexic_14_bolditalicGlyphs
    00002d50 (  11.33 KB)  _ZL32opendyslexic_12_bolditalicGlyphs
    00002d50 (  11.33 KB)  _ZL32opendyslexic_10_bolditalicGlyphs
    00002d50 (  11.33 KB)  _ZL31opendyslexic_8_bolditalicGlyphs
    00002d50 (  11.33 KB)  _ZL29opendyslexic_14_regularGlyphs
    00002d50 (  11.33 KB)  _ZL29opendyslexic_12_regularGlyphs
    00002d50 (  11.33 KB)  _ZL29opendyslexic_10_regularGlyphs
    00002d50 (  11.33 KB)  _ZL28opendyslexic_8_regularGlyphs
    00002d50 (  11.33 KB)  _ZL28opendyslexic_14_italicGlyphs
    00002d50 (  11.33 KB)  _ZL28opendyslexic_12_italicGlyphs
    00002d50 (  11.33 KB)  _ZL28opendyslexic_10_italicGlyphs
    00002d50 (  11.33 KB)  _ZL27opendyslexic_8_italicGlyphs
    00002d50 (  11.33 KB)  _ZL26opendyslexic_14_boldGlyphs
    00002d50 (  11.33 KB)  _ZL26opendyslexic_12_boldGlyphs
    00002d50 (  11.33 KB)  _ZL26opendyslexic_10_boldGlyphs
    00002d50 (  11.33 KB)  _ZL25opendyslexic_8_boldGlyphs
    00002bca (  10.95 KB)  _ZL25notosans_8_regularBitmaps
    0000294c (  10.32 KB)  _ZL16SettingsPageHtml
    000024f0 (   9.23 KB)  _ZL23ubuntu_12_regularGlyphs
    000024f0 (   9.23 KB)  _ZL23ubuntu_10_regularGlyphs
    000024f0 (   9.23 KB)  _ZL20ubuntu_12_boldGlyphs
    000024f0 (   9.23 KB)  _ZL20ubuntu_10_boldGlyphs
    00001b4c (   6.82 KB)  _ZL12fr_trie_data
    000012e8 (   4.73 KB)  ciphersuite_definitions
    00000c8d (   3.14 KB)  _ZL12HomePageHtml
    00000708 (   1.76 KB)  _ZL7Logo120
    00000708 (   1.76 KB)  _ZL7Logo120
    00000688 (   1.63 KB)  esp_err_msg_table
    00000613 (   1.52 KB)  _ZL12it_trie_data
    00000500 (   1.25 KB)  namingBitmap
    00000450 (   1.08 KB)  _ZN4mime9mimeTableE
    00000404 (   1.00 KB)  _ZNSt8__detail12__prime_listE
    00000340 (   0.81 KB)  ciphersuite_preference
    00000300 (   0.75 KB)  _ZL31bookerly_18_bolditalicIntervals
    00000300 (   0.75 KB)  _ZL31bookerly_16_bolditalicIntervals
    00000300 (   0.75 KB)  _ZL31bookerly_14_bolditalicIntervals
    00000300 (   0.75 KB)  _ZL31bookerly_12_bolditalicIntervals
    00000300 (   0.75 KB)  _ZL28bookerly_18_regularIntervals
    00000300 (   0.75 KB)  _ZL28bookerly_16_regularIntervals
    00000300 (   0.75 KB)  _ZL28bookerly_14_regularIntervals
    00000300 (   0.75 KB)  _ZL28bookerly_12_regularIntervals
    00000300 (   0.75 KB)  _ZL27bookerly_18_italicIntervals
    00000300 (   0.75 KB)  _ZL27bookerly_16_italicIntervals
    00000300 (   0.75 KB)  _ZL27bookerly_14_italicIntervals
    00000300 (   0.75 KB)  _ZL27bookerly_12_italicIntervals
    00000300 (   0.75 KB)  _ZL25bookerly_18_boldIntervals
    00000300 (   0.75 KB)  _ZL25bookerly_16_boldIntervals
    00000300 (   0.75 KB)  _ZL25bookerly_14_boldIntervals
    00000300 (   0.75 KB)  _ZL25bookerly_12_boldIntervals
    000002a0 (   0.66 KB)  small_prime
    000002a0 (   0.66 KB)  _ZL35opendyslexic_14_bolditalicIntervals
    000002a0 (   0.66 KB)  _ZL35opendyslexic_12_bolditalicIntervals
    000002a0 (   0.66 KB)  _ZL35opendyslexic_10_bolditalicIntervals
    000002a0 (   0.66 KB)  _ZL34opendyslexic_8_bolditalicIntervals
    000002a0 (   0.66 KB)  _ZL32opendyslexic_14_regularIntervals
    000002a0 (   0.66 KB)  _ZL32opendyslexic_12_regularIntervals
    000002a0 (   0.66 KB)  _ZL32opendyslexic_10_regularIntervals
    000002a0 (   0.66 KB)  _ZL31opendyslexic_8_regularIntervals
    000002a0 (   0.66 KB)  _ZL31opendyslexic_14_italicIntervals
    000002a0 (   0.66 KB)  _ZL31opendyslexic_12_italicIntervals
    000002a0 (   0.66 KB)  _ZL31opendyslexic_10_italicIntervals
    000002a0 (   0.66 KB)  _ZL30opendyslexic_8_italicIntervals
    000002a0 (   0.66 KB)  _ZL29opendyslexic_14_boldIntervals
    000002a0 (   0.66 KB)  _ZL29opendyslexic_12_boldIntervals
    000002a0 (   0.66 KB)  _ZL29opendyslexic_10_boldIntervals
    000002a0 (   0.66 KB)  _ZL28opendyslexic_8_boldIntervals
    00000280 (   0.62 KB)  K
    000001c8 (   0.45 KB)  _ZL26ubuntu_12_regularIntervals
    000001c8 (   0.45 KB)  _ZL26ubuntu_10_regularIntervals
    000001c8 (   0.45 KB)  _ZL23ubuntu_12_boldIntervals
    000001c8 (   0.45 KB)  _ZL23ubuntu_10_boldIntervals
    0000016c (   0.36 KB)  utf8_encoding_ns
    0000016c (   0.36 KB)  utf8_encoding
    0000016c (   0.36 KB)  little2_encoding_ns
    0000016c (   0.36 KB)  little2_encoding
    0000016c (   0.36 KB)  latin1_encoding_ns
    0000016c (   0.36 KB)  latin1_encoding
    0000016c (   0.36 KB)  internal_utf8_encoding_ns
    0000016c (   0.36 KB)  internal_utf8_encoding
    0000016c (   0.36 KB)  big2_encoding_ns
    0000016c (   0.36 KB)  big2_encoding
    0000016c (   0.36 KB)  ascii_encoding_ns
    0000016c (   0.36 KB)  ascii_encoding
    0000016c (   0.36 KB)  __default_global_locale
    00000150 (   0.33 KB)  oid_sig_alg
    00000150 (   0.33 KB)  mbedtls_cipher_definitions
    00000140 (   0.31 KB)  adc_error_coef_atten
    00000140 (   0.31 KB)  NUM_ERROR_CORRECTION_CODEWORDS
    0000012c (   0.29 KB)  _ZL11lookupTable
    00000101 (   0.25 KB)  _ctype_
    00000100 (   0.25 KB)  unhex
    00000100 (   0.25 KB)  tokens
    00000100 (   0.25 KB)  nmstrtPages
    00000100 (   0.25 KB)  namePages
    00000100 (   0.25 KB)  __chclass
    00000100 (   0.25 KB)  FSb4
    00000100 (   0.25 KB)  FSb3
    00000100 (   0.25 KB)  FSb2
    00000100 (   0.25 KB)  FSb
    000000fc (   0.25 KB)  _C_time_locale
    000000f0 (   0.23 KB)  oid_ecp_grp
    000000d4 (   0.21 KB)  _ZL8mapTable
    000000c8 (   0.20 KB)  __mprec_tens
    000000c0 (   0.19 KB)  dh_group5_prime
    000000c0 (   0.19 KB)  dh_group5_order
    000000b4 (   0.18 KB)  _ZL31notosans_18_bolditalicIntervals
    000000b4 (   0.18 KB)  _ZL31notosans_16_bolditalicIntervals
    000000b4 (   0.18 KB)  _ZL31notosans_14_bolditalicIntervals
    000000b4 (   0.18 KB)  _ZL31notosans_12_bolditalicIntervals
    000000b4 (   0.18 KB)  _ZL28notosans_18_regularIntervals

============================================
Top 40 largest symbols in section: .flash.text
Total section size: 1431082 bytes (1397.54 KB)
============================================
    000025b8 (   9.43 KB)  http_parser_execute
    000023aa (   8.92 KB)  _vfprintf_r
    000022ce (   8.70 KB)  _svfprintf_r
    0000225a (   8.59 KB)  _svfwprintf_r
    00001fdc (   7.96 KB)  __ssvfscanf_r
    00001cb0 (   7.17 KB)  _Z15getSettingsListv
    00001bbc (   6.93 KB)  mbedtls_ssl_handshake_server_step
    00001ac2 (   6.69 KB)  __ssvfiscanf_r
    000018fe (   6.25 KB)  mbedtls_ssl_handshake_client_step
    000015fe (   5.50 KB)  mdns_parse_packet
    00001554 (   5.33 KB)  _vfiprintf_r
    0000146e (   5.11 KB)  _svfiprintf_r
    0000123a (   4.56 KB)  doProlog
    0000101e (   4.03 KB)  tcp_input
    0000100e (   4.01 KB)  unsignedCharToPrintable
    00000f4e (   3.83 KB)  pjpeg_decode_mcu
    00000ef6 (   3.74 KB)  nd6_input
    00000d4a (   3.32 KB)  _dtoa_r
    00000d44 (   3.32 KB)  little2_contentTok
    00000d44 (   3.32 KB)  big2_contentTok
    00000d36 (   3.30 KB)  _strtod_l
    00000d30 (   3.30 KB)  mbedtls_high_level_strerr
    00000cec (   3.23 KB)  ieee80211_sta_new_state
    00000ca6 (   3.16 KB)  tcp_receive
    00000c82 (   3.13 KB)  mbedtls_internal_sha512_process
    00000c14 (   3.02 KB)  _ZN9WebServer10_parseFormER10WiFiClient6Stringm
    00000bc0 (   2.94 KB)  qrcode_initBytes
    00000b62 (   2.85 KB)  __multf3
    00000b1c (   2.78 KB)  normal_contentTok
    00000a98 (   2.65 KB)  _ZN16ContentOpfParser12startElementEPvPKcPS2_
    00000a96 (   2.65 KB)  _ZN18JpegToBmpConverter27jpegFileToBmpStreamInternalER6FsFileR5Printiibb
    00000a82 (   2.63 KB)  _mdns_service_task
    00000a64 (   2.60 KB)  __strftime
    00000a64 (   2.60 KB)  _ZNK9BaseTheme19drawRecentBookCoverER11GfxRenderer4RectRKSt6vectorI10RecentBookSaIS4_EEiRbS9_S9_St8functionIFbvEE
    00000a60 (   2.59 KB)  __strftime
    000009cc (   2.45 KB)  _Z16start_ssl_clientP17sslclient_contextRK9IPAddressmPKciS5_bS5_S5_S5_S5_bPS5_
    000009c4 (   2.44 KB)  doContent
    0000099a (   2.40 KB)  wpa_sm_rx_eapol
    00000984 (   2.38 KB)  __divtf3
    00000974 (   2.36 KB)  _ZNSt6locale5_ImplC2Ej

============================================
Top 10 largest symbols in section: .iram0.text
Total section size: 57640 bytes (56.29 KB)
============================================
    00000668 (   1.60 KB)  rmt_driver_isr_default
    00000504 (   1.25 KB)  tlsf_realloc
    00000458 (   1.09 KB)  tlsf_free
    000003e8 (   0.98 KB)  tlsf_malloc
    000003d0 (   0.95 KB)  esp_sleep_start
    00000340 (   0.81 KB)  rtc_sleep_init
    00000218 (   0.52 KB)  spi_flash_mmap_pages
    000001fc (   0.50 KB)  esp_flash_erase_region
    000001fc (   0.50 KB)  call_start_cpu0
    000001de (   0.47 KB)  wdt_hal_init
```

---

### 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-02-19 22:36:43 +11:00
jpirnay c4e3c244ea feat: Add 4bit bmp support (#944)
## Summary

* What is the goal of this PR?
- Allow users to create custom sleep screen images with standard tools
(ImageMagick, GIMP, etc.) that render cleanly on the e-ink display
without dithering artifacts. Previously, avoiding dithering required
non-standard 2-bit BMPs that no standard image editor can produce. ( see
issue #931 )

* What changes are included?
- Add 4-bit BMP format support to Bitmap.cpp (standard format, widely
supported by image tools)
- Auto-detect "native palette" images: if a BMP has ≤4 palette entries
and all luminances map within ±21 of the display's native gray levels
(0, 85, 170, 255), skip dithering entirely and direct-map pixels
- Clarify pixel processing strategy with three distinct paths:
error-diffusion dithering, simple quantization, or direct mapping
- Add scripts/generate_test_bmps.py for generating test images across
all supported BMP formats

## Additional Context

* The e-ink display has 4 native gray levels. When a BMP already uses
exactly those levels, dithering adds noise to what should be clean
output. The native palette detection uses a ±21 tolerance (~10%) to
handle slight rounding from color space conversions in image tools.
Users can now create a 4-color grayscale BMP with (imagemagic example):
```
convert input.png -colorspace Gray -colors 4 -depth 
```
---

### 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-02-19 22:33:29 +11:00
jpirnayandDave Allie 3c1bd77813 fix: prevent UITheme memory leak on theme reload (#975)
## Summary

- `UITheme::currentTheme` was a raw owning pointer with no destructor,
  causing a heap leak every time `setTheme()` was called (e.g. on
  theme change via settings reload)

## Additional Context

- Replaced `const BaseTheme*` with `std::unique_ptr<BaseTheme>` so the
  previous theme object is automatically deleted on reassignment
- Added `<memory>` include to `UITheme.h`; allocations updated to
  `std::make_unique<>` in `UITheme.cpp`

---

### 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**_ (identified by
claude though)

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-19 22:13:12 +11:00
Егор Мартынов 10b7769865 fix: go to prev page on the first one, get teleported to the end of book (#970)
## Summary

1. Go to the first page in a .epub file.
2. Hit `Up` button
3. Get teleported to the last page :)

`TxtRenderActivity` seems to have this if check, but EPUB one does not.

---

### 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-02-19 21:59:15 +11:00
Zach Nelson 448a77f02b perf: Remove hasPrintableChars pass (#971)
## Summary

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

`hasPrintableChars` does a pass over text before rendering. It looks up
glyphs in the font and measures dimensions, returning early if the text
results in zero size.

This additional pass doesn't offer any benefit over moving straight to
rendering the text, because the rendering loop already gracefully
handles missing glyphs. This change saves an extra pass over all
rendered text.

Note that both `hasPrintableChars` and `renderChar` replace missing
glyphs with `glyph = getGlyph(REPLACEMENT_GLYPH)`, so there's no
difference for characters which are not present in the 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-02-19 21:58:09 +11:00
Sam Lord e1074a84c0 fix: Skip large CSS files to prevent crashes (#952)
## Summary

**What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
* Fixes:
https://github.com/crosspoint-reader/crosspoint-reader/issues/947

**What changes are included?**
* Check to see if there's free heap memory before processing CSS (should
we be doing this type of check or is it better to just crash if we
exhaust the memory?)
* Skip CSS files larger than 128kb

## Additional Context

* I found that a copy of `Release it` contained a 250kb+ CSS file, from
the homepage of the publisher. It has nothing to do with the epub, so we
should just skip it
* Major question: Are there better ways to detect CSS that doesn't
belong in a book, or is this size-based approach valid?
* Another question: Are there any epubs we know of that legitimately
include >128kb CSS files?

Code changes themselves created with an agent, all investigation and
write-up done by human. If you (the maintainers) would prefer a
different fix for this issue, let me know.

---

### 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-02-19 21:56:20 +11:00
jpirnay e70066e7c2 fix: add bresenham for arbitrary lines (#923)
## Summary

* GfxRender did handle horizontal and vertical lines but had a TODO for
arbitrary lines.
* Added integer based Bresenham line drawing 
  
## 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-02-19 21:52:13 +11:00
jpirnay 00e25b1a90 fix: Fix kosync repositioning issue (#783)
## Summary

* Original implementation had inconsistent positioning logic:
- When XPath parsing succeeded: incorrectly set pageNumber = 0 (always
beginning of chapter)
- When XPath parsing failed: used percentage for positioning (worked
correctly)
- Result: Positions restored to wrong locations depending on XPath
parsing success
  - Mentioned in Issue #581 
* Solution
- Unified ProgressMapper::toCrossPoint() to use percentage-based
positioning exclusively for both spine identification and intra-chapter
page calculation, eliminating unreliable XPath parsing entirely.

## Additional Context

* ProgressMapper.cpp: Simplified toCrossPoint() to always use percentage
for positioning, removed parseDocFragmentIndex() function
* ProgressMapper.h: Updated comments and removed unused function
declaration
* Tests confirmed appropriate positioning
* __Notabene: the syncing to another device will (most probably) end up
at the current chapter of crosspoints reading position. There is not
much we can do about it, as KOReader needs to have the correct XPath
information - we can only provide an apporximate position (plus
percentage) - the percentage information is not used in KOReaders
current 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? YES
2026-02-19 21:38:46 +11:00
CaptainFritoandDave Allie fdcd71e94d feat: Lyra Icons (#725)
/!\ This PR depends on
https://github.com/crosspoint-reader/crosspoint-reader/pull/732 being
merged first

Also requires the
https://github.com/open-x4-epaper/community-sdk/pull/18 PR

## Summary

Lyra theme icons on the home menu, in the file browser and on empty book
covers

![IMG_8023
Medium](https://github.com/user-attachments/assets/ba7c1407-94d2-4353-80ff-d5b800c6ac5b)
![IMG_8024
Medium](https://github.com/user-attachments/assets/edb59e13-b1c9-4c86-bef3-c61cc8134e64)
![IMG_7958
Medium](https://github.com/user-attachments/assets/d3079ce1-95f0-43f4-bbc7-1f747cc70203)
![IMG_8033
Medium](https://github.com/user-attachments/assets/f3e2e03b-0fa8-47b7-8717-c0b71361b7a8)


## Additional Context

- Added a function to the open-x4-sdk renderer to draw transparent
images
- Added a scripts/convert_icon.py script to convert svg/png icons into a
C array that can be directly imported into the project. Usage:
```bash
python ./scripts/convert_icon.py 'path/to/icon.png' cover 32 32
```
This will create a components/icons/cover.h file with a C array called
CoverIcon, of size 32x32px. Lyra uses icons from
https://lucide.dev/icons with a stroke width of 2px, that can be
downloaded with any desired size on the site.

> The file browser is noticeably slower with the addition of icons, and
using an image buffer like on the home page doesn't help very much. Any
suggestions to optimize this are welcome.

---

### 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**_
The icon conversion python script was generated by Copilot as I am not a
python dev.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-19 21:38:09 +11:00
CaptainFritoandDave Allie e7ee6ff05e feat: Lyra screens (#732)
## Summary

Implements Lyra theme for some more Crosspoint screens:

![IMG_7960
Medium](https://github.com/user-attachments/assets/5d97d91d-e5eb-4296-bbf4-917e142d9095)
![IMG_7961
Medium](https://github.com/user-attachments/assets/02d61964-2632-45ff-83c7-48b95882eb9c)
![IMG_7962
Medium](https://github.com/user-attachments/assets/cf42d20f-3a85-4669-b497-1cac4653fa5a)
![IMG_7963
Medium](https://github.com/user-attachments/assets/a8f59c37-db70-407c-a06d-3e40613a0f55)
![IMG_7964
Medium](https://github.com/user-attachments/assets/0fdaac72-077a-48f6-a8c5-1cd806a58937)
![IMG_7965
Medium](https://github.com/user-attachments/assets/5169f037-8ba8-4488-9a8a-06f5146ec1d9)


## Additional Context

- A bit of refactoring for list scrolling logic

---

### 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: Dave Allie <dave@daveallie.com>
2026-02-19 21:16:55 +11:00
Dave Allie c6ddc5d6a0 Update Ukrainian hyphenation 2026-02-19 21:05:56 +11:00
saslv feff739963 feat: Added Ukrainian language hyphenation support (#646)
## Summary

* **What is the goal of this PR?**  
  Add proper hyphenation support for the Ukrainian language.

* **What changes are included?**  
  - Added Ukrainian hyphenation rules/dictionary

## Additional Context

---

### AI Usage

Did you use AI tools to help write this code? _**NO**_
2026-02-19 20:56:05 +11:00
Zach Nelson f1740dbe1e fix: Correct word width and space calculations (#963)
## Summary

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

This change fixes an issue I noticed while reading where occasionally,
especially in italics, some words would have too much space between
them. The problem was that word width calculations were including any
negative X overhang, and combined with a space before the word, that can
lead to an inconsistently large space.

## Additional Context

Screenshots of some problematic text:

| In CrossPoint 1.0 | With this change |
| -- | -- |
| <img
src="https://github.com/user-attachments/assets/87bf0e4b-341f-4ba9-b3ea-38c13bd26363"
width="400" /> | <img
src="https://github.com/user-attachments/assets/bf11ba20-c297-4ce1-aa07-43477ef86fc2"
width="400" /> |

---

### 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-02-19 20:44:07 +11:00
jpirnay 6be4413c97 fix: Don't extract unsupported formats (#977)
## Summary

* During chapter parsing, every <img> tag triggered ZIP decompression
and an SD card write regardless of whether the image format was
supported. The mandatory delay(50) after each SD write compounded the
cost. A chapter with 6 GIF images (a common decorative element in older
EPUBs) wasted ~750 ms before any text rendering began.
* **What changes are included?**
Added an ``ImageDecoderFactory::isFormatSupported()`` check before any
file I/O in the img-handler. Only JPEG and PNG proceed to extraction;
all other formats (GIF, SVG, WebP, etc.) fall through immediately to
alt-text rendering with no SD card access.
## Additional Context


Measured impact on a representative chapter with 6 GIF decorations:
|  | Before | After|
|-- | -- | --|
|Total parse time | ~882 ms | ~207 ms|
|Image handling | ~750 ms | ~76 ms|
---

### 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-02-19 20:35:13 +11:00
Adrian Wilkins-CaruanaandClaude Opus 4.6 47aa0dda76 perf: Reduce overall flash usage by 30.7% by compressing built-in fonts (#831)
## Summary

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

Compress reader font bitmaps to reduce flash usage by 30.7%.

**What changes are included?**

- New `EpdFontGroup` struct and extended `EpdFontData` with
`groups`/`groupCount` fields
- `--compress` flag in `fontconvert.py`: groups glyphs (ASCII base group
+ groups of 8) and compresses each with raw DEFLATE
- `FontDecompressor` class with 4-slot LRU cache for on-demand
decompression during rendering
- `GfxRenderer` transparently routes bitmap access through
`getGlyphBitmap()` (compressed or direct flash)
- Uses `uzlib` for decompression with minimal heap overhead.
- 48 reader fonts (Bookerly, NotoSans 12-18pt, OpenDyslexic) regenerated
with compression; 5 UI fonts unchanged
- Round-trip verification script (`verify_compression.py`) runs as part
of font generation
## Additional Context

## Flash & RAM

| | baseline | font-compression | Difference |
|--|--------|-----------------|------------|
| Flash (ELF) | 6,302,476 B (96.2%) | 4,365,022 B (66.6%) | -1,937,454 B
(-30.7%) |
| firmware.bin | 6,468,192 B | 4,531,008 B | -1,937,184 B (-29.9%) |
| RAM | 101,700 B (31.0%) | 103,076 B (31.5%) | +1,376 B (+0.5%) |

## Script-Based Grouping (Cold Cache)

Comparison of uncompressed baseline vs script-based group compression
(4-slot LRU cache, cleared each page). Glyphs are grouped by Unicode
block (ASCII, Latin-1, Latin Extended-A, Combining Marks, Cyrillic,
General Punctuation, etc.) instead of sequential groups of 8.

### Render Time

| | Baseline | Compressed (cold cache) | Difference |
|---|---|---|---|
| **Median** | 414.9 ms | 431.6 ms | +16.7 ms (+4.0%) |
| **Pages** | 37 | 37 | |

### Memory Usage

| | Baseline | Compressed (cold cache) | Difference |
|---|---|---|---|
| **Heap free (median)** | 187.0 KB | 176.3 KB | -10.7 KB |
| **Heap free (min)** | 186.0 KB | 166.5 KB | -19.5 KB |
| **Largest block (median)** | 148.0 KB | 128.0 KB | -20.0 KB |
| **Largest block (min)** | 148.0 KB | 120.0 KB | -28.0 KB |

### Cache Effectiveness

| | Misses/page | Hit rate |
|---|---|---|
| **Compressed (cold cache)** | 2.1 | 99.85% |

------

### 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**_
Implementation was done by Claude Code (Opus 4.6) based on a plan
developed collaboratively. All generated font headers were verified with
an automated round-trip decompression test. The firmware was compiled
successfully but has not yet been tested on-device.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:30:15 +11:00
Bram Schulting f16c0e52fd feat: Tweak Lyra popup UI (#768)
## Summary

I want to preface this PR by stating that the proposed changes are
subjective to people's opinions. The following is just my suggestion,
but I'm of course open to changes.

The popups in the currently implemented version of the Lyra theme feel a
bit out of place. This PR suggests an updated version which looks a bit
more polished and in line with the rest of the theme.

I've also taken the liberty to remove the ellipsis behind the text of
the popups, as they made the popup feel a bit off balance (example
below).

With the applied changes, popups will look like this.


![IMG_0012](https://github.com/user-attachments/assets/a954de12-97b8-4102-be17-a702c0fe7d1e)

The vertical position is (more or less) aligned to be in line with the
sleep button. I'm aware the popup is used for other purposes aside from
the sleep message, but this still felt like a good place. It's also a
place where your eyes naturally 'rest'.

The popup has a small 2px white outline, neatly separating it from
whatever is behind it.

### Alternatives considered and rationale behind proposal

Initially I started out worked off the Figma design for the Lyra theme,
which [moves the
popups](https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2011-19296&t=Ppj6B2MrFRfUo9YX-1)
to the bottom of the screen. To me, this results in popups that are much
too easy to miss:


![IMG_0006](https://github.com/user-attachments/assets/b8ce3632-94a9-494e-8256-d87a6ee60cdf)

After this, I tried moving the popup back up (to the position of the
sleep button), but to me it still kinda disappeared into the text of the
book:


![IMG_0008](https://github.com/user-attachments/assets/4b05df7c-932e-432b-9c10-130da3109050)

Inverting the colors of the popup made things stand out the perfect
amount in my opinion. The white outline separates the popup from what is
behind it.


![IMG_0011](https://github.com/user-attachments/assets/77b1e8cc-0a57-4f4b-9abb-a9d10988d919)

This looked much better to me. The only thing that felt a bit off to me,
was the balance due to the ellipsis at the end of the popup text. Also,
"Entering Sleep..." felt a bit.. engineer-y. I felt something a bit more
'conversational' makes at all feel a bit more human-centric. But I'm no
copywriter, and English is not even my native language. So feel free to
chip in!

After tweaking that, I ended up with the final result:

_(Same picture as the first one shown in this PR)_


![IMG_0012](https://github.com/user-attachments/assets/a954de12-97b8-4102-be17-a702c0fe7d1e)

## Additional Context

* Figma design:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2011-19296&t=Ppj6B2MrFRfUo9YX-1

---

### 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-02-19 20:23:34 +11:00
Zach Nelson d02e21a48f fix: Added missing up/down button labels (#935)
## Summary

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

In some places, button labels are omitted intentionally because the
button has no purpose in the activity. I noticed a few obvious cases,
like Home > File Transfer and Settings > System > Language, where the up
and down button labels were missing. This change fixes those and all
similar instances I could find.

---

### 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-02-18 21:54:02 +03:00
Xuan-Son Nguyen 6ec5fc5603 feat: lower CPU freq on idle, add HalPowerManager (#852)
## Summary

Continue my experiment from
https://github.com/crosspoint-reader/crosspoint-reader/pull/801

This PR add the ability to lower the CPU frequency on extended idle
period (currently set to 3 seconds). By default, the esp32c3 CPU is set
to 160MHz, and now on idle, we can reduce it to just 10MHz.

Note that while this functionality is already provided by [esp power
management](https://docs.espressif.com/projects/esp-idf/en/v4.3/esp32c3/api-reference/system/power_management.html),
the current Arduino build lacks of this, and enabling it is just too
complicated (not worth the effort compared to this PR)

Update: more info in
https://github.com/crosspoint-reader/crosspoint-reader/pull/852#issuecomment-3904562827

## Testing

Pre-condition for each test case: the battery is charged to 100%, and is
left plugged in after fully charged for an extra 1 hour.

The table below shows how much battery is **used** for a given duration:

| case / duration | 6 hrs | 12 hrs |
| --- | --- | --- |
| `delay(10)` | 26% | 48% |
| `delay(50)`, PR
https://github.com/crosspoint-reader/crosspoint-reader/pull/801 | 20% |
Not tested |
| `delay(50)` + low CPU freq (This PR) | Not tested | 25% |
| `delay(10)` + low CPU freq (1) | Not tested | Not tested |

(1) I decided not to test this case because it may not make sense. The
problem is that CPU frequency vs power consumption do not follow a
linear relationship, see
[this](https://www.arrow.com/en/research-and-events/articles/esp32-power-consumption-can-be-reduced-with-sleep-modes)
as an example. So, tight loop (10ms) + lower CPU freq significantly
impact battery life, because the active CPU time is now much higher
compared to the wall time.

**So in conclusion, this PR improves ~150% to ~200% battery use time per
charge.**

The projected battery life is now: ~36-48 hrs of reading time (normal
reading, no wifi)

---

### 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-02-18 17:12:29 +03:00
Zach Nelson 9125a7ce68 perf: Avoid redundant font map lookups (#933)
## Summary

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

Several methods in GfxRenderer were doing a `count()` followed by `at()`
on the fonts map, effectively doing the same map lookup unnecessarily.
This can be avoided by doing a single `find()` and reusing the iterator.

---

### 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-02-18 11:55:41 +01:00
Uri Tauber dc6562a51c fix: Fix a dangling pointer (#939)
## Summary

* **What is the goal of this PR?** Fix a dangling pointer issue caused
by using `.c_str()` on a temporary `std::string`.

`basepath.substr()` creates a temporary `std::string`, and calling
`.c_str()` on it returns a pointer to its internal buffer (not a copy).
Since the temporary string is destroyed at the end of the full
expression, `folderName` ends up holding a dangling pointer, leading to
undefined behavior.

To solve this, we stores the result in a persistent `std::string`
object, ensuring the underlying buffer remains valid for the duration of
its use.

A similar pattern caused the behavior reported in
https://github.com/crosspoint-reader/crosspoint-reader/pull/728#issuecomment-3902529697

---

### 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-02-18 11:55:23 +01:00
Uri Tauber 530d43997b fix: Update Translators list (#927)
## Summary

* **What is the goal of this PR?** Update translators.md to include all
the contributors from #728

---

### 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-02-17 19:25:39 +03:00
Zach Nelson 97c33141bd perf: Skip constructing unnecessary std::string (#932)
## Summary

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

Skip constructing a `std::string` just to get the underlying `c_str()`
buffer, when a string literal gives the same end result.

---

### 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-02-16 22:07:08 +01:00
Егор Мартынов 2a32d8a182 chore: improve Russian language support (#926)
## Summary

This PR includes vocabulary and grammar fixes for Russian translation,
originally made as review comments
[here](https://github.com/crosspoint-reader/crosspoint-reader/pull/728).

---

### 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-02-16 23:41:46 +03:00
pablohc d6f38d4441 fix: align battery icon based on context (UI / Reader) (#796)
Issues solved: #729 and #739

## Summary

* **What is the goal of this PR?**
Currently, the battery icon and charge percentage were aligned to the
left even for the UI, where they were positioned on the right side of
the screen. This meant that when changing values of different numbers of
digits, the battery would shift, creating a block of icons and text that
was illegible.

* **What changes are included?**
- Add drawBatteryUi() method for right-aligned battery display in UI
headers
- Keep drawBattery() for left-aligned display in reader mode
- Extract drawBatteryIcon() helper to reduce code duplication
- Battery icon now stays fixed at right edge regardless of percentage
digits
- Text adjusts to left of icon in UI mode, to right of icon in reader
mode

## Additional Context

* Add any other information that might be helpful for the reviewer 
* This fix applies to both themes (Base and Lyra).

---

### 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-02-17 00:36:36 +11:00
Andrew Brandt 513d111634 docs: add translators doc (#792)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
Add a translators document for us to track which individuals have
volunteered to contribute in which languages.

* **What changes are included?**
Add a new document that includes who the translators are and what
languages they have volunteered for.

## Additional Context

This is primarily to keep a handle on the volunteers coming into the
repo. This will serve as a master list of all volunteer translators.

---

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

---------

Signed-off-by: Andrew Brandt <brandt.andrew89@gmail.com>
2026-02-17 00:34:11 +11:00
Lev Roland-Kalb ad9137cfdf fix: added cover image outlines to improve legibility (#907)
## Summary

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

Improve legibility of Cover Icons on the home page and elsewhere. Fixes
#898

* **What changes are included?**
Cover outline is now shown even when cover is found to prevent issues
with low contrast covers blending into the background. Photo is attached
below:

<img width="404" height="510" alt="Group 1 (4)"
src="https://github.com/user-attachments/assets/9d794b51-554b-486d-8520-6ef920548b9a"
/>


## Additional Context

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

Not much else to say here. I did simplify the logic in lyratheme.cpp
based on there no longer being a requirement for any non-cover specific
rendering differences.

---

### 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-02-17 00:33:43 +11:00
Lev Roland-Kalb 5c80cface7 docs: Updating webserver.md documentation to align with 1.0.0 features (#906)
## Summary

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

Updating webserver.md documentation to align with 1.0.0 features

* **What changes are included?**

Added documentation for the following new features (including replacing
screenshots)

- file renaming
- file moving
- support for uploading any file type
- batch uploads

## Additional Context

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

Nothing comes to mind

---

### 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-02-17 00:33:27 +11:00
Lev Roland-Kalb 86d3774a8f fix: Removed white boxes extending passed the bounds of the empty button icon when hint text is blank/null (#884)
## Summary

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

Empty Button Icons (I.E. Back button in the home menu) were still
rendering the full sized white rectangles going passed the boarders of
the little button nub. This was not visible on the home screen due to
the white background, but it does cause issues if we ever want to have
bmp files displayed while buttons are visible or implement a dark mode.

* **What changes are included?**

Made it so that when a button hint text is empty string or null the
displayed mini button nub does not have a white rectangle extending
passed the bounds of the mini button nub

## Additional Context

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

Having that extended rectangle was likely never noticed due to the only
space where that feature is used being the main menu where the
background is completely white. I am working on some new features that
would have an image displayed while there are button hints and noticed
this issue while implementing that.

One other note is that this only affects the Lyra Theme

---

### 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-02-17 00:31:19 +11:00
7ba5978848 feat: User-Interface I18n System (#728)
## Summary

**What is the goal of this PR?**
This PR introduces Internationalization (i18n) support, enabling users
to switch the UI language dynamically.

**What changes are included?**
- Core Logic: Added I18n class (`lib/I18n/I18n.h/cpp`) to manage
language state and string retrieval.

- Data Structures:

- `lib/I18n/I18nStrings.h/cpp`: Static string arrays for each supported
language.
  - `lib/I18n/I18nKeys.h`: Enum definitions for type-safe string access.
  - `lib/I18n/translations.csv`: single source of truth. 

- Documentation: Added `docs/i18n.md` detailing the workflow for
developers and translators.

- New Settings activity:
`src/activities/settings/LanguageSelectActivity.h/cpp`

## Additional Context

This implementation (building on concepts from #505) prioritizes
performance and memory efficiency.

The core approach is to store all localized strings for each language in
dedicated arrays and access them via enums. This provides O(1) access
with zero runtime overhead, and avoids the heap allocations, hashing,
and collision handling required by `std::map` or `std::unordered_map`.

The main trade-off is that enums and string arrays must remain perfectly
synchronized—any mismatch would result in incorrect strings being
displayed in the UI.

To eliminate this risk, I added a Python script that automatically
generates `I18nStrings.h/.cpp` and `I18nKeys.h` from a CSV file, which
will serve as the single source of truth for all translations. The full
design and workflow are documented in `docs/i18n.md`.

### Next Steps

- [x] Python script `generate_i18n.py` to auto-generate C++ files from
CSV
- [x] Populate translations.csv with initial translations.

Currently available translations: English, Español, Français, Deutsch,
Čeština, Português (Brasil), Русский, Svenska.
Thanks, community!

**Status:** EDIT: ready to be merged.

As a proof of concept, the SPANISH strings currently mirror the English
ones, but are fully uppercased.

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_
I used AI for the black work of replacing strings with I18n references
across the project, and for generating the documentation. EDIT: also
some help with merging changes from master.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: yeyeto2788 <juanernestobiondi@gmail.com>
2026-02-17 00:28:42 +11:00
Xuan-Son Nguyen 3d47c081f2 fix: use RAII render lock everywhere (#916)
## Summary

Follow-up to
https://github.com/crosspoint-reader/crosspoint-reader/pull/774

---

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Modernized internal synchronization mechanisms across multiple
components to improve code reliability and maintainability. All
functionality remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 23:53:00 +11:00
Justin MitchellandDave Allie 6702060960 fix: Implement guide-based cover image fallback (#830)
This partially fixes #769 but is dependent upon PR #827 being merged
along side this @daveallie. I removed my PNG conversion code after
finding out that PR was already created. Without this PR though that
book in #769 will still fail to load because of how it's stored in the
file

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-16 23:24:30 +11:00
0bc6747483 feat: Add PNG cover image support for EPUB books (#827)
## Summary
- EPUB books with PNG cover images now display covers on the home screen
instead of blank rectangles
- Adds `PngToBmpConverter` library mirroring the existing
`JpegToBmpConverter` pattern
- Uses miniz (already in the project) for streaming zlib decompression
of PNG IDAT data
- Supports all PNG color types (Grayscale, RGB, RGBA, Palette,
Gray+Alpha)
- Optimized for ESP32-C3: batch grayscale conversion, 2KB read buffer,
same area-averaging scaling and Atkinson dithering as the JPEG path

## Changes
- **New:** `lib/PngToBmpConverter/PngToBmpConverter.h` — Public API
matching JpegToBmpConverter's interface
- **New:** `lib/PngToBmpConverter/PngToBmpConverter.cpp` — Streaming PNG
decoder + BMP converter
- **Modified:** `lib/Epub/Epub.cpp` — Added `.png` handling in
`generateCoverBmp()` and `generateThumbBmp()`

## Test plan
- [x] Tested with EPUB files using PNG covers — covers appear correctly
on home screen
- [ ] Verify with various PNG color types (most stock EPUBs use 8-bit
RGB)
- [ ] Confirm no regressions with JPEG cover EPUBs

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

**New Features**
- Added PNG format support for EPUB cover and thumbnail images. PNG
files are automatically processed and cached alongside existing
supported formats. This enhancement enables users to leverage PNG cover
artwork when generating EPUB files, improving workflow flexibility and
compatibility with common image sources.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Nik Outchcunis <outchy@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-16 22:56:13 +11:00
jpirnay 00666377de fix: Add miniz directive to get rid of compilation warning (#858)
## Summary

* I am getting miniz warning during compilation: "Using fopen, ftello,
fseeko, stat() etc. path for file I/O - this path may not support large
files."
* Disable the io module from miniz as it is not used and get rid of the
warning

## Additional Context

* the ZipFile.cpp implementation only uses tinfl_decompressor,
tinfl_init(), and tinfl_decompress() (low-level API) and does all ZIP
file parsing manually using SD card file I/O
* it never uses miniz's high-level file functions like
mz_zip_reader_init_file()
* so we can disable Miniz io-stack be setting MINIZ_NO_STDIO to 1

### 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, let claude
inspect the codebase
2026-02-16 22:53:49 +11:00
jpirnay 22b77edddf fix: Correct multiple author display (#856)
## Summary

* If an EPUB has:
```
<dc:creator>J.R.R. Tolkien</dc:creator>
<dc:creator>Christopher Tolkien</dc:creator>
```
the current result for epub.author would provide : "J.R.R.
TolkienChristopher Tolkien" (no separator!)
* The fix will seperate multiple authors: "J.R.R. Tolkien, Christopher
Tolkien"

## Additional Context

* Simple fix in ContentOpfParser - I am not seeing any dependence on the
wrong concatenated result.

---

### 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-02-16 22:19:21 +11:00
Dave Allie 2e673c753d docs: Include dictionary as in-scope (#917)
## Summary

* Include dictionary as in-scope

## Additional Context

* Discussion in
https://github.com/crosspoint-reader/crosspoint-reader/discussions/878

---

### 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-02-16 22:13:38 +11:00
ThatCrispyToast 1a30826981 fix: add distro agnostic shebang and clang-format check to clang-format-fix (#840)
## Summary

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

Minor development tooling fix for nonstandard environments (NixOS,
FreeBSD, Guix, etc.)

**What changes are included?**

- environment relative shebang in `clang-format-fix`
- clang-format check in `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? _**NO**_
2026-02-16 22:13:05 +11:00
jpirnayandXuan Son Nguyen 50e6ef9bd8 fix: Auto calculate the settings size on serialization (#832)
## Summary

* The constant SETTINGS_CONST was hardcoded and needed to be updated
whenever an additional setting was added
* This is no longer necessary as the settings size will be determined
automatically on settings persistence

## Additional Context

* New settings need to be added (as previously) in saveToFile - that's
it

---

### 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: Xuan Son Nguyen <son@huggingface.co>
2026-02-16 22:11:26 +11:00
Xuan-Son Nguyenandznelson a616f42cb4 refactor: move render() to Activity super class, use freeRTOS notification (#774)
## Summary

Currently, each activity has to manage their own `displayTaskLoop` which
adds redundant boilerplate code. The loop is a wait loop which is also
not the best practice, as the `updateRequested` boolean is not protected
by a mutex.

In this PR:
- Move `displayTaskLoop` to the super `Activity` class
- Replace `updateRequested` with freeRTOS's [direct to task
notification](https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/03-Direct-to-task-notifications/01-Task-notifications)
- For `ActivityWithSubactivity`, whenever a sub-activity is present, the
parent's `render()` automatically goes inactive

With this change, activities now only need to expose `render()`
function, and anywhere in the code base can call `requestUpdate()` to
request a new rendering pass.

## Additional Context

In theory, this change may also make the battery life a bit better,
since one wait loop is removed. Although the equipment in my home lab
wasn't been able to verify it (the electric current is too noisy and
small). Would appreciate if anyone has any insights on this subject.

Update: I managed to hack [a small piece of
code](https://github.com/ngxson/crosspoint-reader/tree/xsn/measure_cpu_usage)
that allow tracking CPU idle time.

The CPU load does decrease a bit (1.47% down to 1.39%), which make
sense, because the display task is now sleeping most of the time unless
notified. This should translate to a slightly increase in battery life
in the long run.

```
PR:
[40012] [MEM] Free: 185856 bytes, Total: 231004 bytes, Min Free: 123316 bytes
[40012] [IDLE] Idle time: 98.61% (CPU load: 1.39%)
[50017] [MEM] Free: 185856 bytes, Total: 231004 bytes, Min Free: 123316 bytes
[50017] [IDLE] Idle time: 98.61% (CPU load: 1.39%)
[60022] [MEM] Free: 185856 bytes, Total: 231004 bytes, Min Free: 123316 bytes
[60022] [IDLE] Idle time: 98.61% (CPU load: 1.39%)

master:
[20012] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[20012] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[30017] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[30017] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[40022] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[40022] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
```

---

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Streamlined rendering architecture by consolidating update mechanisms
across all activities, improving efficiency and consistency.
* Modernized synchronization patterns for display updates to ensure
reliable, conflict-free rendering.

* **Bug Fixes**
* Enhanced rendering stability through improved locking mechanisms and
explicit update requests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: znelson <znelson@users.noreply.github.com>
2026-02-16 21:11:15 +11:00
Xuan-Son Nguyen 0508bfc1f7 perf: apply (micro) optimization on SerializedHyphenationPatterns (#689)
## Summary

This PR applies a micro optimization on `SerializedHyphenationPatterns`,
which allow reading `rootOffset` directly without having to parse then
cache it.

It should not affect storage space since no new bytes are added.

This also gets rid of the linear cache search whenever
`liangBreakIndexes` is called. In theory, the performance should be
improved a bit, although it may be too small to be noticeable in
practice.

## Testing

master branch:

```
english: 99.1023%
french: 100%
german: 97.7289%
russian: 97.2167%
spanish: 99.0236%
```

This PR:

```
english: 99.1023%
french: 100%
german: 97.7289%
russian: 97.2167%
spanish: 99.0236%
```

---

### 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 - mostly IDE
tab-autocompletions
2026-02-16 20:27:43 +11:00
6c3a615fac feat: add png jpeg support (#556)
## Summary
- Add embedded image support to EPUB rendering with JPEG and PNG
decoders
- Implement pixel caching system to cache decoded/dithered images to SD
card for faster re-rendering
- Add 4-level grayscale support for display
## Changes
### New Image Rendering System
- Add `ImageBlock` class to represent an image with its cached path and
display dimensions
- Add `PageImage` class as a new `PageElement` type for images on pages
- Add `ImageToFramebufferDecoder` interface for format-specific image
decoders
- Add `JpegToFramebufferConverter` - JPEG decoder with Bayer dithering
and scaling
- Add `PngToFramebufferConverter` - PNG decoder with Bayer dithering and
scaling
- Add `ImageDecoderFactory` to select appropriate decoder based on file
extension
- Add `getRenderMode()` to GfxRenderer for grayscale render mode queries
### Dithering and Grayscale
- Implement 4x4 Bayer ordered dithering for 4-level grayscale output
- Stateless algorithm works correctly with MCU block decoding
- Handles scaling without artifacts
- Add grayscale render mode support (BW, GRAYSCALE_LSB, GRAYSCALE_MSB)
- Image decoders and cache renderer respect current render mode
- Enables proper 4-level e-ink grayscale when anti-aliasing is enabled
### Pixel Caching
- Cache decoded/dithered images to `.pxc` files on SD card
- Cache format: 2-bit packed pixels (4 pixels per byte) with
width/height header
- On subsequent renders, load directly from cache instead of re-decoding
- Cache renderer supports grayscale render modes for multi-pass
rendering
- Significantly improves page navigation speed for image-heavy EPUBs
### HTML Parser Integration
- Update `ChapterHtmlSlimParser` to process `<img>` tags and extract
images from EPUB
- Resolve relative image paths within EPUB ZIP structure
- Extract images to cache directory before decoding
- Create `PageImage` elements with proper scaling to fit viewport
- Fall back to alt text display if image processing fails
### Build Configuration
- Add `PNG_MAX_BUFFERED_PIXELS=6402` to support up to 800px wide images

  ### Test Script
                                
  - Generate test EPUBs with annotated JPEG and PNG images
- Test cases cover: grayscale (4 levels), centering, scaling, cache
performance
  
## Test plan
- [x] Open EPUB with JPEG images - verify images display with proper
grayscale
- [x] Open EPUB with PNG images - verify images display correctly and no
crash
- [x] Navigate away from image page and back - verify faster load from
cache
- [x] Verify grayscale tones render correctly (not just black/white
dithering)
- [x] Verify large images are scaled down to fit screen
- [x] Verify images are centered horizontally
- [x] Verify page serialization/deserialization works with images
  - [x] Verify images rendered in landscape mode        

## Test Results
[png](https://photos.app.goo.gl/5zFUb8xA8db3dPd19)
[jpeg](https://photos.app.goo.gl/SwtwaL2DSQwKybhw7)


![20260128_231123790](https://github.com/user-attachments/assets/78855971-4bb8-441a-b207-0a292b9739f5)

![20260128_231012253](https://github.com/user-attachments/assets/f08fb63f-1b73-41d9-a25e-78232ec0c495)

![20260128_231004209](https://github.com/user-attachments/assets/06c94acc-8a06-4955-978e-6e583399478d)

![20260128_230954997](https://github.com/user-attachments/assets/49bc44d5-0f2c-416b-9199-4d680fb0f4c3)

![20260128_230945717](https://github.com/user-attachments/assets/93446da5-2e07-410c-89c9-6a21d14e5acb)

![20260128_230938313](https://github.com/user-attachments/assets/4c74c72a-3d40-4a25-b0f3-acc703f42c00)

![20260128_230925546](https://github.com/user-attachments/assets/8d8f62ee-c8fc-4f19-a12c-da29083bb766)

![20260128_230918374](https://github.com/user-attachments/assets/f007d5db-41cc-4fa6-bb22-9e767ee7b00d)


                                                                       
---

### AI Usage

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

---------

Co-authored-by: Matthías Páll Gissurarson <mpg@mpg.is>
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-16 19:56:59 +11:00
Jake Kenneally 46c2109f1f perf: Improve large CSS files handling (#779)
## Summary

Closes #766. Thank you for the help @bramschulting!

**What is the goal of this PR?** 
- First and foremost, fix issue #766.
- Through working on that, I realized the current CSS parsing/loading
code can be improved dramatically for large files and still had
additional performance improvements to be made, even with EPUBs with
small CSS.

**What changes are included?**
- Stream CSS parsing and reuse normalization buffers to cut allocations
- Add rule limits and selector validation to release rules and free up
memory when needed
- Skip CSS parsing/loading entirely when "Book's Embedded Style" is off

## Additional Context

- My test EPUB has been updated
[here](https://github.com/jdk2pq/css-test-epub) to include a very large
CSS file to test this out

---

### 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**_, Codex
2026-02-15 20:22:42 +03:00
Xuan-Son Nguyen 5816ab2a47 feat: use pre-compressed HTML pages (#861)
## Summary

Pre-compress the HTML file to save flash space. I'm using `gzip` because
it's supported everywhere (indeed, we are using the same optimization on
[llama.cpp server](https://github.com/ggml-org/llama.cpp), our HTML page
is huge 😅 ).

This free up ~40KB flash space.

Some users suggested using `brotli` which is known to further reduce 20%
in size, but it doesn't supported by firefox (only supports if served
via HTTPS), and some reverse proxy like nginx doesn't support it out of
the box (unrelated in this context, but just mention for completeness)

```
PR:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  95.5% (used 6259244 bytes from 6553600 bytes)

master:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  96.2% (used 6302416 bytes from 6553600 bytes)
```

---

### 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**, only the
python part
2026-02-14 18:49:39 +03:00
Max Stoller 2c0a105550 docs: Add requirement device be on when flashing (#877)
## Summary
Flashing requires the device to be unlocked/awake

## 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-02-14 18:46:27 +03:00
252 changed files with 157847 additions and 264608 deletions
+1
View File
@@ -3,6 +3,7 @@
.DS_Store
.vscode
lib/EpdFont/fontsrc
lib/I18n/I18nStrings.cpp
*.generated.h
.vs
build
+1 -1
View File
@@ -51,7 +51,7 @@ For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) do
### Web (latest firmware)
1. Connect your Xteink X4 to your computer via USB-C
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"
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
+4 -2
View File
@@ -25,6 +25,8 @@ usability over "swiss-army-knife" functionality.
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
* **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.
### Out-of-Scope
@@ -34,8 +36,8 @@ usability over "swiss-army-knife" functionality.
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
and complicate the single-core CPU's execution.
* **Media Playback:** No Audio players or Audio-books.
* **Complex Reader Features:** No highlighting, notes, or dictionary lookup. These features are better suited for
devices with better input capabilities and more powerful chips.
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
capabilities and more powerful chips.
## 3. Idea Evaluation
+10 -1
View File
@@ -1,10 +1,18 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if clang-format is availible
command -v clang-format >/dev/null 2>&1 || {
printf "'clang-format' not found in current environment\n"
printf "install 'clang', 'clang-tools', or 'clang-format' depending on your distro/os and tooling requirements\n"
exit 1
}
GIT_LS_FILES_FLAGS=""
if [[ "$1" == "-g" ]]; then
GIT_LS_FILES_FLAGS="--modified"
fi
# --- Main Logic ---
# Format all files (or only modified files if -g is passed)
@@ -18,4 +26,5 @@ 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 -style=file -i
+4 -17
View File
@@ -45,22 +45,9 @@ byte arrays, and emits headers under
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
in flash.
To refresh the firmware assets after updating the `.bin` files, run:
A convenient script `update_hyphenation.sh` is used to update all languages.
To use it, run:
```
./scripts/generate_hyphenation_trie.py \
--input lib/Epub/Epub/hyphenation/tries/en.bin \
--output lib/Epub/Epub/hyphenation/generated/hyph-en.trie.h
./scripts/generate_hyphenation_trie.py \
--input lib/Epub/Epub/hyphenation/tries/fr.bin \
--output lib/Epub/Epub/hyphenation/generated/hyph-fr.trie.h
./scripts/generate_hyphenation_trie.py \
--input lib/Epub/Epub/hyphenation/tries/de.bin \
--output lib/Epub/Epub/hyphenation/generated/hyph-de.trie.h
./scripts/generate_hyphenation_trie.py \
--input lib/Epub/Epub/hyphenation/tries/ru.bin \
--output lib/Epub/Epub/hyphenation/generated/hyph-ru.trie.h
```sh
./scripts/update_hypenation.sh
```
+237
View File
@@ -0,0 +1,237 @@
# Internationalization (I18N)
This guide explains the multi-language support system in CrossPoint Reader.
## Supported Languages
- English
- French
- German
- Portuguese
- Spanish
- Swedish
- Czech
- Russian
---
## For Developers
### Translation System Architecture
The I18N system uses **per-language YAML files** to maintain translations and a Python script to generate C++ code:
```
lib/I18n/
├── translations/ # One YAML file per language
│ ├── english.yaml
│ ├── spanish.yaml
│ ├── french.yaml
│ └── ...
├── I18n.h
├── I18n.cpp
├── I18nKeys.h # Enums (auto-generated)
├── I18nStrings.h # String array declarations (auto-generated)
└── I18nStrings.cpp # String array definitions (auto-generated)
scripts/
└── gen_i18n.py # Code generator script
```
**Key principle:** All translations are managed in the YAML files under `lib/I18n/translations/`. The Python script generates the necessary C++ code automatically.
---
### YAML File Format
Each language has its own file in `lib/I18n/translations/` (e.g. `spanish.yaml`).
A file looks like this:
```yaml
_language_name: "Español"
_language_code: "SPANISH"
_order: "1"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "BOOTING"
STR_BROWSE_FILES: "Buscar archivos"
```
**Metadata keys** (prefixed with `_`):
- `_language_name` — Native display name shown to the user (e.g. "Français")
- `_language_code` — C++ enum name (e.g. "FRENCH"). Must be a valid C++ identifier.
- `_order` — Controls the position in the Language enum (English is always 0)
**Rules:**
- Use UTF-8 encoding
- Every line must follow the format: `KEY: "value"`
- Keys must be valid C++ identifiers (uppercase, strats with STR_)
- Keys must be unique within a file
- String values must be quoted
- Use `\n` for newlines, `\\` for literal backslashes, `\"` for literal quotes inside values
---
### Adding New Strings
To add a new translatable string:
#### 1. Edit the English YAML file
Add the key to `lib/I18n/translations/english.yaml`:
```yaml
STR_MY_NEW_STRING: "My New String"
```
Then add translations in each language file. If a key is missing from a
language file, the generator will automatically use the English text as a
fallback (and print a warning).
#### 2. Run the generator script
```bash
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
```
This automatically:
- Fills missing translations from English
- Updates the `StrId` enum in `I18nKeys.h`
- Regenerates all language arrays in `I18nStrings.cpp`
#### 3. Use in code
```cpp
#include <I18n.h>
// Using the tr() macro (recommended)
renderer.drawText(font, x, y, tr(STR_MY_NEW_STRING));
// Using I18N.get() directly
const char* text = I18N.get(StrId::STR_MY_NEW_STRING);
```
**That's it!** No manual array synchronization needed.
---
### Adding a New Language
To add support for a new language (e.g., Italian):
#### 1. Create a new YAML file
Create `lib/I18n/translations/italian.yaml`:
```yaml
_language_name: "Italiano"
_language_code: "ITALIAN"
_order: "7"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "AVVIO"
```
You only need to include the strings you have translations for. Missing
keys will fall back to English automatically.
#### 2. Run the generator
```bash
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
```
This automatically updates all necessary code.
---
### Modifying Existing Translations
Simply edit the relevant YAML file and regenerate:
```bash
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
```
---
### UTF-8 Encoding
The YAML files use UTF-8 encoding. Special characters are automatically converted to C++ UTF-8 hex sequences by the generator.
---
### I18N API Reference
```cpp
// === Convenience Macros (Recommended) ===
// tr(id) - Get translated string without StrId:: prefix
const char* text = tr(STR_SETTINGS_TITLE);
renderer.drawText(font, x, y, tr(STR_BROWSE_FILES));
Serial.printf("Status: %s\n", tr(STR_CONNECTED));
// I18N - Shorthand for I18n::getInstance()
I18N.setLanguage(Language::SPANISH);
Language lang = I18N.getLanguage();
// === Full API ===
// Get the singleton instance
I18n& instance = I18n::getInstance();
// Get translated string (three equivalent ways)
const char* text = tr(STR_SETTINGS_TITLE); // Macro (recommended)
const char* text = I18N.get(StrId::STR_SETTINGS_TITLE); // Direct call
const char* text = I18N[StrId::STR_SETTINGS_TITLE]; // Operator overload
// Set language
I18N.setLanguage(Language::SPANISH);
// Get current language
Language lang = I18N.getLanguage();
// Save language setting to file
I18N.saveSettings();
// Load language setting from file
I18N.loadSettings();
// Get character set for font subsetting (static method)
const char* chars = I18n::getCharacterSet(Language::FRENCH);
```
---
## File Storage
Language settings are stored in:
```
/.crosspoint/language.bin
```
This file contains:
- Version byte
- Current language selection (1 byte)
---
## Translation Workflow
### For Developers (Adding Features)
1. Add new strings to `lib/I18n/translations/english.yaml`
2. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/`
3. Use the new `StrId` in your code
4. Request translations from translators
### For Translators
1. Open the YAML file for your language in `lib/I18n/translations/`
2. Add or update translations using the format `STR_KEY: "translated text"`
3. Keep translations concise (E-ink space constraints)
4. Make sure the file is in UTF-8 encoding
5. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/` to verify
6. Test on device or submit for review
Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+36
View File
@@ -0,0 +1,36 @@
# Translators
Below is a list of users and languages CrossPoint may support in the future.
Note because a language is below does not mean there is official support for the language at this time.
## Contributing
If you'd like to add your name to this list, please open a PR adding yourself and your Github link. Thank you!
## French
- [Spigaw](https://github.com/Spigaw)
- [CaptainFrito](https://github.com/CaptainFrito)
## German
- [DavidOrtmann](https://github.com/DavidOrtmann)
## Czech
- [brbla](https://github.com/brbla)
## Portuguese (Brazil)
- [yagofarias](https://github.com/yagofarias)
## Italian
- [fragolinux](https://github.com/fragolinux)
## Russian
- [madebyKir](https://github.com/madebyKir)
- [mrtnvgr](https://github.com/mrtnvgr)
## Spanish
- [yeyeto2788](https://github.com/yeyeto2788)
- [Skrzakk](https://github.com/Skrzakk)
- [pablohc](https://github.com/pablohc)
## Swedish
- [dawiik](https://github.com/dawiik)
+32 -22
View File
@@ -1,14 +1,14 @@
# Web Server Guide
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload files from your computer or phone.
## Overview
CrossPoint Reader includes a built-in web server that allows you to:
- Upload EPUB files wirelessly from any device on the same WiFi network
- Upload files wirelessly from any device on the same WiFi network
- Browse and manage files on your device's SD card
- Create folders to organize your ebooks
- Create folders to organize your library
- Delete files and folders
## Prerequisites
@@ -129,34 +129,31 @@ Click **File Manager** to access file management features.
#### Browsing Files
- The file manager displays all files and folders on your SD card
- **Folders** are highlighted in yellow with a 📁 icon
- **EPUB files** are highlighted in green with a 📗 icon
- **Folders** are highlighted in yellow and indicated with a 📁 icon
- **EPUB Files** are highlighted in green and indicated with a 📗 icon
- **All Other Files** are not highlighted and indicated with a 📄 icon
- Click on a folder name to navigate into it
- Use the breadcrumb navigation at the top to go back to parent folders
<img src="./images/wifi/webserver_files.png" width="600">
#### Uploading EPUB Files
#### Uploading Files
1. Click the **+ Add** button in the top-right corner
2. Select **Upload eBook** from the dropdown menu
3. Click **Choose File** and select an `.epub` file from your device
4. Click **Upload**
5. A progress bar will show the upload status
6. The page will automatically refresh when the upload is complete
**Note:** Only `.epub` files are accepted. Other file types will be rejected.
1. Click the **📤 Upload** button in the top-right corner
2. Click **Choose File** and select a file from your device
3. Click **Upload**
4. A progress bar will show the upload status
5. The page will automatically refresh when the upload is complete
<img src="./images/wifi/webserver_upload.png" width="600">
#### Creating Folders
1. Click the **+ Add** button in the top-right corner
2. Select **New Folder** from the dropdown menu
3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
4. Click **Create Folder**
1. Click the **📁 New Folder** button in the top-right corner
2. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
3. Click **Create Folder**
This is useful for organizing your ebooks by genre, author, or series.
This is useful for organizing your library by genre, author, series or file type.
#### Deleting Files and Folders
@@ -168,11 +165,25 @@ This is useful for organizing your ebooks by genre, author, or series.
**Note:** Folders must be empty before they can be deleted.
#### Moving Files
1. Click the **📂** (folder) icon next to any file
2. Enter a folder name or select one from the dropdown
3. Click **Move** to relocate the file
**Note:** Typing in a nonexistent folder name will result in the following error: "Failed to move: Destination not found"
#### Renaming Files
1. Click the **✏️** (pencil) icon next to any file
2. Enter a file name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
3. Click **Rename** to permanently rename the file
---
## Command Line File Management
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode a detailed documentation can be found [here](./webserver-endpoints.md).
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. Detailed documentation can be found [here](./webserver-endpoints.md).
## Security Notes
@@ -189,7 +200,6 @@ For power users, you can manage files directly from your terminal using `curl` w
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
- **Web Server Port:** 80 (HTTP)
- **Maximum Upload Size:** Limited by available SD card space
- **Supported File Format:** `.epub` only
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
---
@@ -198,7 +208,7 @@ For power users, you can manage files directly from your terminal using `curl` w
1. **Organize with folders** - Create folders before uploading to keep your library organized
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
3. **Upload multiple files** - You can select and upload multiple files at once; the manager will queue them and refresh when the batch is finished
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
+29 -13
View File
@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
int cursorX = startX;
const int cursorY = startY;
int lastBaseX = startX;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const EpdGlyph* glyph = getGlyph(cp);
@@ -30,11 +35,30 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
*minX = std::min(*minX, cursorX + glyph->left);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
*maxY = std::max(*maxY, cursorY + glyph->top);
cursorX += glyph->advanceX;
const bool isCombining = utf8IsCombiningMark(cp);
int raiseBy = 0;
if (isCombining && hasBaseGlyph) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseTop = glyph->top;
hasBaseGlyph = true;
cursorX += glyph->advanceX;
}
}
}
@@ -47,14 +71,6 @@ void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
*h = maxY - minY;
}
bool EpdFont::hasPrintableChars(const char* string) const {
int w = 0, h = 0;
getTextDimensions(string, &w, &h);
return w > 0 || h > 0;
}
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals;
const int count = data->intervalCount;
-1
View File
@@ -9,7 +9,6 @@ class EpdFont {
explicit EpdFont(const EpdFontData* data) : data(data) {}
~EpdFont() = default;
void getTextDimensions(const char* string, int* w, int* h) const;
bool hasPrintableChars(const char* string) const;
const EpdGlyph* getGlyph(uint32_t cp) const;
};
+12 -1
View File
@@ -12,9 +12,18 @@ typedef struct {
int16_t left; ///< X dist from cursor pos to UL corner
int16_t top; ///< Y dist from cursor pos to UL corner
uint16_t dataLength; ///< Size of the font data.
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap (or within-group offset for compressed fonts)
} EpdGlyph;
/// Compressed font group: a DEFLATE-compressed block of glyph bitmaps
typedef struct {
uint32_t compressedOffset; ///< Byte offset into compressed data array
uint32_t compressedSize; ///< Compressed DEFLATE stream size
uint32_t uncompressedSize; ///< Decompressed size
uint16_t glyphCount; ///< Number of glyphs in this group
uint16_t firstGlyphIndex; ///< First glyph index in the global glyph array
} EpdFontGroup;
/// Glyph interval structure
typedef struct {
uint32_t first; ///< The first unicode code point of the interval
@@ -32,4 +41,6 @@ typedef struct {
int ascender; ///< Maximal height of a glyph above the base line
int descender; ///< Maximal height of a glyph below the base line
bool is2Bit;
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
uint16_t groupCount; ///< 0 for uncompressed fonts
} EpdFontData;
-4
View File
@@ -22,10 +22,6 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const
getFont(style)->getTextDimensions(string, w, h);
}
bool EpdFontFamily::hasPrintableChars(const char* string, const Style style) const {
return getFont(style)->hasPrintableChars(string);
}
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
-1
View File
@@ -10,7 +10,6 @@ class EpdFontFamily {
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
bool hasPrintableChars(const char* string, Style style = REGULAR) const;
const EpdFontData* getData(Style style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
+147
View File
@@ -0,0 +1,147 @@
#include "FontDecompressor.h"
#include <Logging.h>
#include <uzlib.h>
#include <cstdlib>
#include <cstring>
bool FontDecompressor::init() {
clearCache();
memset(&decomp, 0, sizeof(decomp));
return true;
}
void FontDecompressor::freeAllEntries() {
for (auto& entry : cache) {
if (entry.data) {
free(entry.data);
entry.data = nullptr;
}
entry.valid = false;
}
}
void FontDecompressor::deinit() { freeAllEntries(); }
void FontDecompressor::clearCache() {
freeAllEntries();
accessCounter = 0;
}
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex) {
for (uint16_t i = 0; i < fontData->groupCount; i++) {
uint16_t first = fontData->groups[i].firstGlyphIndex;
if (glyphIndex >= first && glyphIndex < first + fontData->groups[i].glyphCount) {
return i;
}
}
return fontData->groupCount; // sentinel = not found
}
FontDecompressor::CacheEntry* FontDecompressor::findInCache(const EpdFontData* fontData, uint16_t groupIndex) {
for (auto& entry : cache) {
if (entry.valid && entry.font == fontData && entry.groupIndex == groupIndex) {
return &entry;
}
}
return nullptr;
}
FontDecompressor::CacheEntry* FontDecompressor::findEvictionCandidate() {
// Find an invalid slot first
for (auto& entry : cache) {
if (!entry.valid) {
return &entry;
}
}
// Otherwise evict LRU
CacheEntry* lru = &cache[0];
for (auto& entry : cache) {
if (entry.lastUsed < lru->lastUsed) {
lru = &entry;
}
}
return lru;
}
bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry) {
const EpdFontGroup& group = fontData->groups[groupIndex];
// Free old buffer if reusing a slot
if (entry->data) {
free(entry->data);
entry->data = nullptr;
}
entry->valid = false;
// Allocate output buffer
auto* outBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
if (!outBuf) {
LOG_ERR("FDC", "Failed to allocate %u bytes for group %u", group.uncompressedSize, groupIndex);
return false;
}
// Decompress using uzlib
const uint8_t* inputBuf = &fontData->bitmap[group.compressedOffset];
uzlib_uncompress_init(&decomp, NULL, 0);
decomp.source = inputBuf;
decomp.source_limit = inputBuf + group.compressedSize;
decomp.dest_start = outBuf;
decomp.dest = outBuf;
decomp.dest_limit = outBuf + group.uncompressedSize;
int res = uzlib_uncompress(&decomp);
if (res < 0 || decomp.dest != decomp.dest_limit) {
LOG_ERR("FDC", "Decompression failed for group %u (status %d)", groupIndex, res);
free(outBuf);
return false;
}
entry->font = fontData;
entry->groupIndex = groupIndex;
entry->data = outBuf;
entry->dataSize = group.uncompressedSize;
entry->valid = true;
return true;
}
const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex) {
if (!fontData->groups || fontData->groupCount == 0) {
return &fontData->bitmap[glyph->dataOffset];
}
uint16_t groupIndex = getGroupIndex(fontData, glyphIndex);
if (groupIndex >= fontData->groupCount) {
LOG_ERR("FDC", "Glyph %u not found in any group", glyphIndex);
return nullptr;
}
// Check cache
CacheEntry* entry = findInCache(fontData, groupIndex);
if (entry) {
entry->lastUsed = ++accessCounter;
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
glyph->dataLength, groupIndex, entry->dataSize);
return nullptr;
}
return &entry->data[glyph->dataOffset];
}
// Cache miss - decompress
entry = findEvictionCandidate();
if (!decompressGroup(fontData, groupIndex, entry)) {
return nullptr;
}
entry->lastUsed = ++accessCounter;
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
glyph->dataLength, groupIndex, entry->dataSize);
return nullptr;
}
return &entry->data[glyph->dataOffset];
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <uzlib.h>
#include <cstdint>
#include "EpdFontData.h"
class FontDecompressor {
public:
bool init();
void deinit();
// Returns pointer to decompressed bitmap data for the given glyph.
// Valid until LRU eviction (safe for the duration of one glyph render).
const uint8_t* getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex);
// Evict all cached decompressed groups (call between pages for within-page-only caching).
void clearCache();
private:
static constexpr uint8_t CACHE_SLOTS = 4;
struct CacheEntry {
const EpdFontData* font = nullptr;
uint16_t groupIndex = 0;
uint8_t* data = nullptr;
uint32_t dataSize = 0;
uint32_t lastUsed = 0;
bool valid = false;
};
struct uzlib_uncomp decomp = {};
CacheEntry cache[CACHE_SLOTS] = {};
uint32_t accessCounter = 0;
void freeAllEntries();
uint16_t getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex);
CacheEntry* findInCache(const EpdFontData* fontData, uint16_t groupIndex);
CacheEntry* findEvictionCandidate();
bool decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry);
};
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
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
@@ -1619,4 +1619,6 @@ static const EpdFontData notosans_8_regular = {
18,
-5,
false,
nullptr,
0,
};
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
@@ -1522,4 +1522,6 @@ static const EpdFontData ubuntu_10_bold = {
20,
-4,
false,
nullptr,
0,
};
@@ -1436,4 +1436,6 @@ static const EpdFontData ubuntu_10_regular = {
20,
-4,
false,
nullptr,
0,
};
@@ -1832,4 +1832,6 @@ static const EpdFontData ubuntu_12_bold = {
24,
-5,
false,
nullptr,
0,
};
@@ -1724,4 +1724,6 @@ static const EpdFontData ubuntu_12_regular = {
24,
-5,
false,
nullptr,
0,
};
+7 -3
View File
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress > $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 > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
echo "Generated $output_path"
done
done
@@ -34,7 +34,7 @@ for size in ${OPENDYSLEXIC_FONT_SIZES[@]}; do
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
echo "Generated $output_path"
done
done
@@ -53,3 +53,7 @@ for size in ${UI_FONT_SIZES[@]}; do
done
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
echo ""
echo "Running compression verification..."
python verify_compression.py ../builtinFonts/
+114 -5
View File
@@ -15,6 +15,7 @@ parser.add_argument("size", type=int, help="font size to use.")
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
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.")
args = parser.parse_args()
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
@@ -270,21 +271,115 @@ for index, glyph in enumerate(all_glyphs):
glyph_data.extend([b for b in packed])
glyph_props.append(props)
compress = args.compress
# Build groups for compression
if compress:
# Script-based grouping: glyphs that co-occur in typical text rendering
# 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.
SCRIPT_GROUP_RANGES = [
(0x0000, 0x007F), # ASCII
(0x0080, 0x00FF), # Latin-1 Supplement
(0x0100, 0x017F), # Latin Extended-A
(0x0300, 0x036F), # Combining Diacritical Marks
(0x0400, 0x04FF), # Cyrillic
(0x2000, 0x206F), # General Punctuation
(0x2070, 0x209F), # Superscripts & Subscripts
(0x20A0, 0x20CF), # Currency Symbols
(0x2190, 0x21FF), # Arrows
(0x2200, 0x22FF), # Math Operators
(0xFFFD, 0xFFFD), # Replacement Character
]
def get_script_group(code_point):
for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES):
if start <= code_point <= end:
return i
return -1
groups = [] # list of (first_glyph_index, glyph_count)
current_group_id = None
group_start = 0
group_count = 0
for i, (props, packed) in enumerate(all_glyphs):
sg = get_script_group(props.code_point)
if sg != current_group_id:
if group_count > 0:
groups.append((group_start, group_count))
current_group_id = sg
group_start = i
group_count = 1
else:
group_count += 1
if group_count > 0:
groups.append((group_start, group_count))
# Compress each group
compressed_groups = [] # list of (compressed_bytes, uncompressed_size, glyph_count, first_glyph_index)
compressed_bitmap_data = []
compressed_offset = 0
# Also build modified glyph props with within-group offsets
modified_glyph_props = list(glyph_props)
for first_idx, count in groups:
# Concatenate bitmap data for this group
group_data = b''
for gi in range(first_idx, first_idx + count):
props, packed = all_glyphs[gi]
# Update glyph's dataOffset to be within-group offset
within_group_offset = len(group_data)
old_props = modified_glyph_props[gi]
modified_glyph_props[gi] = GlyphProps(
width=old_props.width,
height=old_props.height,
advance_x=old_props.advance_x,
left=old_props.left,
top=old_props.top,
data_length=old_props.data_length,
data_offset=within_group_offset,
code_point=old_props.code_point,
)
group_data += packed
# Compress with raw DEFLATE (no zlib/gzip header)
compressor = zlib.compressobj(level=9, wbits=-15)
compressed = compressor.compress(group_data) + compressor.flush()
compressed_groups.append((compressed, len(group_data), count, first_idx))
compressed_bitmap_data.extend(compressed)
compressed_offset += len(compressed)
glyph_props = modified_glyph_props
total_compressed = len(compressed_bitmap_data)
total_uncompressed = len(glyph_data)
print(f"// Compression: {total_uncompressed} -> {total_compressed} bytes ({100*total_compressed/total_uncompressed:.1f}%), {len(groups)} groups", file=sys.stderr)
print(f"""/**
* generated by fontconvert.py
* name: {font_name}
* size: {size}
* mode: {'2-bit' if is2Bit else '1-bit'}
* mode: {'2-bit' if is2Bit else '1-bit'}{' compressed: true' if compress else ''}
* Command used: {' '.join(sys.argv)}
*/
#pragma once
#include "EpdFontData.h"
""")
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
for c in chunks(glyph_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))
print ("};\n");
if compress:
print(f"static const uint8_t {font_name}Bitmaps[{len(compressed_bitmap_data)}] = {{")
for c in chunks(compressed_bitmap_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))
print ("};\n");
else:
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
for c in chunks(glyph_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))
print ("};\n");
print(f"static const EpdGlyph {font_name}Glyphs[] = {{")
for i, g in enumerate(glyph_props):
@@ -298,6 +393,14 @@ for i_start, i_end in intervals:
offset += i_end - i_start + 1
print ("};\n");
if compress:
print(f"static const EpdFontGroup {font_name}Groups[] = {{")
compressed_offset = 0
for compressed, uncompressed_size, count, first_idx in compressed_groups:
print(f" {{ {compressed_offset}, {len(compressed)}, {uncompressed_size}, {count}, {first_idx} }},")
compressed_offset += len(compressed)
print("};\n")
print(f"static const EpdFontData {font_name} = {{")
print(f" {font_name}Bitmaps,")
print(f" {font_name}Glyphs,")
@@ -307,4 +410,10 @@ print(f" {norm_ceil(face.size.height)},")
print(f" {norm_ceil(face.size.ascender)},")
print(f" {norm_floor(face.size.descender)},")
print(f" {'true' if is2Bit else 'false'},")
if compress:
print(f" {font_name}Groups,")
print(f" {len(compressed_groups)},")
else:
print(f" nullptr,")
print(f" 0,")
print("};")
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Round-trip verification for compressed font headers.
Parses each generated .h file in the given directory, identifies compressed fonts
(those with a Groups array), decompresses each group, and verifies that
decompression succeeds and all glyph offsets/lengths fall within bounds.
"""
import os
import re
import sys
import zlib
def parse_hex_array(text):
"""Extract bytes from a C hex array string like '{ 0xAB, 0xCD, ... }'"""
hex_vals = re.findall(r'0x([0-9A-Fa-f]{2})', text)
return bytes(int(h, 16) for h in hex_vals)
def parse_groups(text):
"""Parse EpdFontGroup array entries: { compressedOffset, compressedSize, uncompressedSize, glyphCount, firstGlyphIndex }"""
groups = []
for match in re.finditer(r'\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}', text):
groups.append({
'compressedOffset': int(match.group(1)),
'compressedSize': int(match.group(2)),
'uncompressedSize': int(match.group(3)),
'glyphCount': int(match.group(4)),
'firstGlyphIndex': int(match.group(5)),
})
return groups
def parse_glyphs(text):
"""Parse EpdGlyph array entries: { width, height, advanceX, left, top, dataLength, dataOffset }"""
glyphs = []
for match in re.finditer(r'\{\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\}', text):
glyphs.append({
'width': int(match.group(1)),
'height': int(match.group(2)),
'advanceX': int(match.group(3)),
'left': int(match.group(4)),
'top': int(match.group(5)),
'dataLength': int(match.group(6)),
'dataOffset': int(match.group(7)),
})
return glyphs
def verify_font_file(filepath):
"""Verify a single font header file. Returns (font_name, success, message)."""
with open(filepath, 'r') as f:
content = f.read()
# Check if this is a compressed font (has Groups array)
groups_match = re.search(r'static const EpdFontGroup (\w+)Groups\[\]', content)
if not groups_match:
return (os.path.basename(filepath), None, "uncompressed, skipping")
font_name = groups_match.group(1)
# Extract bitmap data
bitmap_match = re.search(
r'static const uint8_t ' + re.escape(font_name) + r'Bitmaps\[\d+\]\s*=\s*\{([^}]+)\}',
content, re.DOTALL
)
if not bitmap_match:
return (font_name, False, "could not find Bitmaps array")
compressed_data = parse_hex_array(bitmap_match.group(1))
# Extract groups
groups_array_match = re.search(
r'static const EpdFontGroup ' + re.escape(font_name) + r'Groups\[\]\s*=\s*\{(.+?)\};',
content, re.DOTALL
)
if not groups_array_match:
return (font_name, False, "could not find Groups array")
groups = parse_groups(groups_array_match.group(1))
if not groups:
return (font_name, False, "Groups array parsed to 0 entries; check format")
# Extract glyphs
glyphs_match = re.search(
r'static const EpdGlyph ' + re.escape(font_name) + r'Glyphs\[\]\s*=\s*\{(.+?)\};',
content, re.DOTALL
)
if not glyphs_match:
return (font_name, False, "could not find Glyphs array")
glyphs = parse_glyphs(glyphs_match.group(1))
# Verify each group
for gi, group in enumerate(groups):
# Extract compressed chunk
chunk = compressed_data[group['compressedOffset']:group['compressedOffset'] + group['compressedSize']]
if len(chunk) != group['compressedSize']:
return (font_name, False, f"group {gi}: compressed data truncated (expected {group['compressedSize']}, got {len(chunk)})")
# Decompress with raw DEFLATE
try:
decompressed = zlib.decompress(chunk, -15)
except zlib.error as e:
return (font_name, False, f"group {gi}: decompression failed: {e}")
if len(decompressed) != group['uncompressedSize']:
return (font_name, False, f"group {gi}: size mismatch (expected {group['uncompressedSize']}, got {len(decompressed)})")
# Verify each glyph's data within the group
first = group['firstGlyphIndex']
for j in range(group['glyphCount']):
glyph_idx = first + j
if glyph_idx >= len(glyphs):
return (font_name, False, f"group {gi}: glyph index {glyph_idx} out of range")
glyph = glyphs[glyph_idx]
offset = glyph['dataOffset']
length = glyph['dataLength']
if offset + length > len(decompressed):
return (font_name, False, f"group {gi}, glyph {glyph_idx}: data extends beyond decompressed buffer "
f"(offset={offset}, length={length}, decompressed_size={len(decompressed)})")
return (font_name, True, f"{len(groups)} groups, {len(glyphs)} glyphs OK")
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <font_headers_directory>", file=sys.stderr)
sys.exit(1)
font_dir = sys.argv[1]
if not os.path.isdir(font_dir):
print(f"Error: {font_dir} is not a directory", file=sys.stderr)
sys.exit(1)
files = sorted(f for f in os.listdir(font_dir) if f.endswith('.h') and f != 'all.h')
passed = 0
failed = 0
skipped = 0
for filename in files:
filepath = os.path.join(font_dir, filename)
_font_name, success, message = verify_font_file(filepath)
if success is None:
skipped += 1
elif success:
passed += 1
print(f" PASS: {filename} ({message})")
else:
failed += 1
print(f" FAIL: {filename} - {message}")
print(f"\nResults: {passed} passed, {failed} failed, {skipped} skipped (uncompressed)")
if failed > 0:
sys.exit(1)
if __name__ == '__main__':
main()
+192 -58
View File
@@ -4,6 +4,7 @@
#include <HalStorage.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h"
@@ -76,6 +77,54 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
bookMetadata.author = opfParser.author;
bookMetadata.language = opfParser.language;
bookMetadata.coverItemHref = opfParser.coverItemHref;
// Guide-based cover fallback: if no cover found via metadata/properties,
// try extracting the image reference from the guide's cover page XHTML
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
size_t coverPageSize;
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
if (coverPageData) {
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
free(coverPageData);
// Determine base path of the cover page for resolving relative image references
std::string coverPageBase;
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
if (lastSlash != std::string::npos) {
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
}
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
std::string imageRef;
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
auto pos = coverPageHtml.find(pattern);
while (pos != std::string::npos) {
pos += strlen(pattern);
const auto endPos = coverPageHtml.find('"', pos);
if (endPos != std::string::npos) {
const auto ref = coverPageHtml.substr(pos, endPos - pos);
// Check if it's an image file
if (ref.length() >= 4) {
const auto ext = ref.substr(ref.length() - 4);
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
imageRef = ref;
break;
}
}
}
pos = coverPageHtml.find(pattern, pos);
}
if (!imageRef.empty()) break;
}
if (!imageRef.empty()) {
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
}
}
}
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
if (!opfParser.tocNcxPath.empty()) {
@@ -208,68 +257,80 @@ bool Epub::parseTocNavFile() const {
return true;
}
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile;
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close();
LOG_DBG("EBP", "Loaded CSS rules from cache");
return true;
}
cssCacheFile.close();
LOG_DBG("EBP", "CSS cache invalid, reparsing");
}
return false;
}
void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed)
// Larger files risk memory exhaustion on ESP32
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
// Minimum heap required before attempting CSS parsing
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
if (cssFiles.empty()) {
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
}
// Try to load from CSS cache first
if (!loadCssRulesFromCache()) {
// Cache miss - parse CSS files
for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// See if we have a cached version of the CSS rules
if (cssParser->hasCache()) {
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
return;
}
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
// No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Check heap before parsing - CSS parsing allocates heavily
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
continue;
}
// Check CSS file size before decompressing - skip files that are too large
size_t cssFileSize = 0;
if (getItemSize(cssPath, &cssFileSize)) {
if (cssFileSize > MAX_CSS_FILE_SIZE) {
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
cssPath.c_str());
continue;
}
cssParser->loadFromStream(tempCssFile);
}
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Save to cache for next time
FsFile cssCacheFile;
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
cssParser->saveToCache(cssCacheFile);
cssCacheFile.close();
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
continue;
}
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
cssParser->loadFromStream(tempCssFile);
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
}
// Save to cache for next time
if (!cssParser->saveToCache()) {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
}
cssParser->clear();
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
}
// load in the meta data for the epub file
@@ -279,18 +340,24 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath));
// Always create CssParser - needed for inline style parsing even without CSS files
cssParser.reset(new CssParser());
cssParser.reset(new CssParser(cachePath));
// Try to load existing cache first
if (bookMetadataCache->load()) {
if (!skipLoadingCss && !loadCssRulesFromCache()) {
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS
if (!skipLoadingCss) {
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
cssParser->deleteCache();
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS
}
parseCssFiles();
// Invalidate section caches so they are rebuilt with the new CSS
Storage.removeDir((cachePath + "/sections").c_str());
}
parseCssFiles();
}
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true;
@@ -391,6 +458,7 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
if (!skipLoadingCss) {
// Parse CSS files after cache reload
parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
}
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
@@ -503,12 +571,44 @@ bool Epub::generateCoverBmp(bool cropped) const {
LOG_ERR("EBP", "Failed to generate BMP from cover image");
Storage.remove(getCoverBmpPath(cropped).c_str());
}
LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverPng.close();
return false;
}
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
coverPng.close();
coverBmp.close();
Storage.remove(coverPngTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
Storage.remove(getCoverBmpPath(cropped).c_str());
}
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
return success;
}
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
return false;
}
@@ -566,6 +666,40 @@ bool Epub::generateThumbBmp(int height) const {
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(coverImageHref, coverPng, 1024);
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
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);
coverPng.close();
thumbBmp.close();
Storage.remove(coverPngTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
Storage.remove(getThumbBmpPath(height).c_str());
}
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
}
+1 -3
View File
@@ -35,8 +35,6 @@ class Epub {
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
void parseCssFiles() const;
std::string getCssRulesCache() const;
bool loadCssRulesFromCache() const;
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@@ -73,5 +71,5 @@ class Epub {
size_t getBookSize() const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
const CssParser* getCssParser() const { return cssParser.get(); }
CssParser* getCssParser() const { return cssParser.get(); }
};
+29 -2
View File
@@ -25,6 +25,29 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
// Images don't use fontId or text rendering
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
}
bool PageImage::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
// serialize ImageBlock
return imageBlock->serialize(file);
}
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
int16_t xPos;
int16_t yPos;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
auto ib = ImageBlock::deserialize(file);
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
}
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@@ -36,8 +59,9 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count);
for (const auto& el : elements) {
// Only PageLine exists currently
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
// Use getTag() method to determine type
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
if (!el->serialize(file)) {
return false;
}
@@ -59,6 +83,9 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl));
} else if (tag == TAG_PageImage) {
auto pi = PageImage::deserialize(file);
page->elements.push_back(std::move(pi));
} else {
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr;
+53
View File
@@ -1,13 +1,16 @@
#pragma once
#include <HalStorage.h>
#include <algorithm>
#include <utility>
#include <vector>
#include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h"
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageImage = 2, // New tag
};
// represents something that has been added to a page
@@ -19,6 +22,7 @@ class PageElement {
virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0;
virtual PageElementTag getTag() const = 0; // Add type identification
};
// a line from a block element
@@ -30,9 +34,24 @@ class PageLine final : public PageElement {
: PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageLine; }
static std::unique_ptr<PageLine> deserialize(FsFile& file);
};
// New PageImage class
class PageImage final : public PageElement {
std::shared_ptr<ImageBlock> imageBlock;
public:
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; }
static std::unique_ptr<PageImage> deserialize(FsFile& file);
const ImageBlock& getImageBlock() const { return *imageBlock; }
};
class Page {
public:
// the list of block index and line numbers on this page
@@ -40,4 +59,38 @@ class Page {
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file);
// Check if page contains any images (used to force full refresh)
bool hasImages() const {
return std::any_of(elements.begin(), elements.end(),
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
}
// Get bounding box of all images on the page (union of image rects)
// Returns false if no images. Coordinates are relative to page origin.
bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const {
bool found = false;
int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN;
for (const auto& el : elements) {
if (el->getTag() == TAG_PageImage) {
const auto& img = static_cast<const PageImage&>(*el);
int16_t x = img.xPos;
int16_t y = img.yPos;
int16_t right = x + img.getImageBlock().getWidth();
int16_t bottom = y + img.getImageBlock().getHeight();
minX = std::min(minX, x);
minY = std::min(minY, y);
maxX = std::max(maxX, right);
maxY = std::max(maxY, bottom);
found = true;
}
}
if (found) {
outX = minX;
outY = minY;
outW = maxX - minX;
outH = maxY - minY;
}
return found;
}
};
+30 -13
View File
@@ -29,15 +29,17 @@ void stripSoftHyphensInPlace(std::string& word) {
}
}
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
// Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
// Uses advance width (sum of glyph advances) rather than bounding box width so that italic glyph overhangs
// don't inflate inter-word spacing.
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
const EpdFontFamily::Style style, const bool appendHyphen = false) {
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
return renderer.getSpaceWidth(fontId);
return renderer.getSpaceWidth(fontId, style);
}
const bool hasSoftHyphen = containsSoftHyphen(word);
if (!hasSoftHyphen && !appendHyphen) {
return renderer.getTextWidth(fontId, word.c_str(), style);
return renderer.getTextAdvanceX(fontId, word.c_str(), style);
}
std::string sanitized = word;
@@ -47,7 +49,7 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
if (appendHyphen) {
sanitized.push_back('-');
}
return renderer.getTextWidth(fontId, sanitized.c_str(), style);
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
}
} // namespace
@@ -376,20 +378,35 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
// The remainder inherits whatever continuation status the original word had with the word after it.
// Find the continues entry for the original word and insert the remainder's entry after it.
// Continuation flag handling after splitting a word into prefix + remainder.
//
// The prefix keeps the original word's continuation flag so that no-break-space groups
// stay linked. The remainder always gets continues=false because it starts on the next
// line and is not attached to the prefix.
//
// Example: "200&#xA0;Quadratkilometer" produces tokens:
// [0] "200" continues=false
// [1] " " continues=true
// [2] "Quadratkilometer" continues=true <-- the word being split
//
// After splitting "Quadratkilometer" at "Quadrat-" / "kilometer":
// [0] "200" continues=false
// [1] " " continues=true
// [2] "Quadrat-" continues=true (KEPT — still attached to the no-break group)
// [3] "kilometer" continues=false (NEW — starts fresh on the next line)
//
// This lets the backtracking loop keep the entire prefix group ("200 Quadrat-") on one
// line, while "kilometer" moves to the next line.
auto continuesIt = wordContinues.begin();
std::advance(continuesIt, wordIndex);
const bool originalContinuedToNext = *continuesIt;
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
*continuesIt = false;
// *continuesIt is intentionally left unchanged — the prefix keeps its original attachment.
const auto insertContinuesIt = std::next(continuesIt);
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
wordContinues.insert(insertContinuesIt, false);
// Keep the indexed vector in sync if provided
// Keep the indexed vector in sync if provided.
if (continuesVec) {
(*continuesVec)[wordIndex] = false;
continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext);
// (*continuesVec)[wordIndex] stays unchanged — prefix keeps its attachment.
continuesVec->insert(continuesVec->begin() + wordIndex + 1, false);
}
// Update cached widths to reflect the new prefix/remainder pairing.
+25 -3
View File
@@ -4,12 +4,13 @@
#include <Logging.h>
#include <Serialization.h>
#include "Epub/css/CssParser.h"
#include "Page.h"
#include "hyphenation/Hyphenator.h"
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 12;
constexpr uint8_t SECTION_FILE_VERSION = 13;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint32_t);
@@ -181,11 +182,26 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
viewportHeight, hyphenationEnabled, embeddedStyle);
std::vector<uint32_t> lut = {};
// Derive the content base directory and image cache path prefix for the parser
size_t lastSlash = localPath.find_last_of('/');
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
CssParser* cssParser = nullptr;
if (embeddedStyle) {
cssParser = epub->getCssParser();
if (cssParser) {
if (!cssParser->loadFromCache()) {
LOG_ERR("SCT", "Failed to load CSS from cache");
}
}
}
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -194,6 +210,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
LOG_ERR("SCT", "Failed to parse XML and build pages");
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return false;
}
@@ -220,6 +239,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
file.close();
if (cssParser) {
cssParser->clear();
}
return true;
}
+1 -1
View File
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
class Block {
public:
virtual ~Block() = default;
virtual void layout(GfxRenderer& renderer) = 0;
virtual BlockType getType() = 0;
virtual bool isEmpty() = 0;
virtual void finish() {}
+174
View File
@@ -0,0 +1,174 @@
#include "ImageBlock.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include "../converters/DitherUtils.h"
#include "../converters/ImageDecoderFactory.h"
// Cache file format:
// - uint16_t width
// - uint16_t height
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
: imagePath(imagePath), width(width), height(height) {}
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
namespace {
std::string getCachePath(const std::string& imagePath) {
// Replace extension with .pxc (pixel cache)
size_t dotPos = imagePath.rfind('.');
if (dotPos != std::string::npos) {
return imagePath.substr(0, dotPos) + ".pxc";
}
return imagePath + ".pxc";
}
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
FsFile cacheFile;
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
}
uint16_t cachedWidth, cachedHeight;
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
cacheFile.close();
return false;
}
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
int widthDiff = abs(cachedWidth - expectedWidth);
int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
expectedHeight);
cacheFile.close();
return false;
}
// Use cached dimensions for rendering (they're the actual decoded size)
expectedWidth = cachedWidth;
expectedHeight = cachedHeight;
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
LOG_ERR("IMG", "Failed to allocate row buffer");
cacheFile.close();
return false;
}
for (int row = 0; row < cachedHeight; row++) {
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
LOG_ERR("IMG", "Cache read error at row %d", row);
free(rowBuffer);
cacheFile.close();
return false;
}
int destY = y + row;
for (int col = 0; col < cachedWidth; col++) {
int byteIdx = col / 4;
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
}
}
free(rowBuffer);
cacheFile.close();
LOG_DBG("IMG", "Cache render complete");
return true;
}
} // namespace
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Bounds check render position using logical screen dimensions
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
screenHeight);
return;
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
return; // Successfully rendered from cache
}
// No cache - need to decode the image
// Check if image file exists
FsFile file;
if (!Storage.openFileForRead("IMG", imagePath, file)) {
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
return;
}
size_t fileSize = file.size();
file.close();
if (fileSize == 0) {
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
return;
}
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
RenderConfig config;
config.x = x;
config.y = y;
config.maxWidth = width;
config.maxHeight = height;
config.useGrayscale = true;
config.useDithering = true;
config.performanceMode = false;
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
config.cachePath = cachePath; // Enable caching during decode
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
if (!decoder) {
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
return;
}
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
if (!success) {
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
return;
}
LOG_DBG("IMG", "Decode successful");
}
bool ImageBlock::serialize(FsFile& file) {
serialization::writeString(file, imagePath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
}
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
std::string path;
serialization::readString(file, path);
int16_t w, h;
serialization::readPod(file, w);
serialization::readPod(file, h);
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
#include "Block.h"
class ImageBlock final : public Block {
public:
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
~ImageBlock() override = default;
const std::string& getImagePath() const { return imagePath; }
int16_t getWidth() const { return width; }
int16_t getHeight() const { return height; }
bool imageExists() const;
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
void render(GfxRenderer& renderer, const int x, const int y);
bool serialize(FsFile& file);
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
private:
std::string imagePath;
int16_t width;
int16_t height;
};
+1 -1
View File
@@ -33,7 +33,7 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
static_cast<uint8_t>(w[2]) == 0x83) {
const char* visiblePtr = w.c_str() + 3;
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
startX = wordX + prefixWidth;
underlineWidth = visibleWidth;
-1
View File
@@ -28,7 +28,6 @@ class TextBlock final : public Block {
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; }
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <GfxRenderer.h>
#include <stdint.h>
// 4x4 Bayer matrix for ordered dithering
inline const uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
{12, 4, 14, 6},
{3, 11, 1, 9},
{15, 7, 13, 5},
};
// Apply Bayer dithering and quantize to 4 levels (0-3)
// Stateless - works correctly with any pixel processing order
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
int bayer = bayer4x4[y & 3][x & 3];
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
int adjusted = gray + dither;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
if (adjusted < 64) return 0;
if (adjusted < 128) return 1;
if (adjusted < 192) return 2;
return 3;
}
// Draw a pixel respecting the current render mode for grayscale support
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
renderer.drawPixel(x, y, true);
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
renderer.drawPixel(x, y, false);
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
renderer.drawPixel(x, y, false);
}
}
@@ -0,0 +1,42 @@
#include "ImageDecoderFactory.h"
#include <Logging.h>
#include <memory>
#include <string>
#include "JpegToFramebufferConverter.h"
#include "PngToFramebufferConverter.h"
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
std::string ext = imagePath;
size_t dotPos = ext.rfind('.');
if (dotPos != std::string::npos) {
ext = ext.substr(dotPos);
for (auto& c : ext) {
c = tolower(c);
}
} else {
ext = "";
}
if (JpegToFramebufferConverter::supportsFormat(ext)) {
if (!jpegDecoder) {
jpegDecoder.reset(new JpegToFramebufferConverter());
}
return jpegDecoder.get();
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
if (!pngDecoder) {
pngDecoder.reset(new PngToFramebufferConverter());
}
return pngDecoder.get();
}
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
return nullptr;
}
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter;
class PngToFramebufferConverter;
class ImageDecoderFactory {
public:
// Returns non-owning pointer - factory owns the decoder lifetime
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
static bool isFormatSupported(const std::string& imagePath);
private:
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
};
@@ -0,0 +1,17 @@
#include "ImageToFramebufferDecoder.h"
#include <Logging.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
if (width * height > MAX_SOURCE_PIXELS) {
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
format.c_str(), MAX_SOURCE_PIXELS);
return false;
}
return true;
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
imagePath.c_str());
}
@@ -0,0 +1,40 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
class GfxRenderer;
struct ImageDimensions {
int16_t width;
int16_t height;
};
struct RenderConfig {
int x, y;
int maxWidth, maxHeight;
bool useGrayscale = true;
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
class ImageToFramebufferDecoder {
public:
virtual ~ImageToFramebufferDecoder() = default;
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
virtual const char* getFormatName() const = 0;
protected:
// Size validation helpers
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
bool validateImageDimensions(int width, int height, const std::string& format);
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
};
@@ -0,0 +1,297 @@
#include "JpegToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include "DitherUtils.h"
#include "PixelCache.h"
struct JpegContext {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
};
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
file.close();
if (status != 0) {
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
return true;
}
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
LOG_ERR("JPG", "picojpeg init failed: %d", status);
file.close();
return false;
}
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
file.close();
return false;
}
// Calculate output dimensions
int destWidth, destHeight;
float scale;
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
destWidth = config.maxWidth;
destHeight = config.maxHeight;
scale = (float)destWidth / imageInfo.m_width;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
? (float)config.maxWidth / imageInfo.m_width
: 1.0f;
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
? (float)config.maxHeight / imageInfo.m_height
: 1.0f;
scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
destWidth = (int)(imageInfo.m_width * scale);
destHeight = (int)(imageInfo.m_height * scale);
}
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
file.close();
return false;
}
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Allocate pixel cache if cachePath is provided
PixelCache cache;
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
caching = false;
}
}
int mcuX = 0;
int mcuY = 0;
while (mcuY < imageInfo.m_MCUSPerCol) {
status = pjpeg_decode_mcu();
if (status == PJPG_NO_MORE_BLOCKS) {
break;
}
if (status != 0) {
LOG_ERR("JPG", "MCU decode failed: %d", status);
file.close();
return false;
}
// Source position in image coordinates
int srcStartX = mcuX * imageInfo.m_MCUWidth;
int srcStartY = mcuY * imageInfo.m_MCUHeight;
switch (imageInfo.m_scanType) {
case PJPG_GRAYSCALE:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (col < 8) ? 0 : 1;
int pixelIndex = row * 8 + (col % 8);
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (row < 8) ? 0 : 1;
int pixelIndex = (row % 8) * 8 + col;
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockX = (col < 8) ? 0 : 1;
int blockY = (row < 8) ? 0 : 1;
int blockIndex = blockY * 2 + blockX;
int pixelIndex = (row % 8) * 8 + (col % 8);
int blockOffset = blockIndex * 64;
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
}
mcuX++;
if (mcuX >= imageInfo.m_MCUSPerRow) {
mcuX = 0;
mcuY++;
}
}
LOG_DBG("JPG", "Decoding complete");
file.close();
// Write cache file if caching was enabled
if (caching) {
cache.writeToFile(config.cachePath);
}
return true;
}
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
if (context->bufferPos >= context->bufferFilled) {
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
if (readCount <= 0) {
*pBytes_actually_read = 0;
return 0;
}
context->bufferFilled = readCount;
context->bufferPos = 0;
}
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
context->bufferPos += bytesToCopy;
*pBytes_actually_read = bytesToCopy;
return 0;
}
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".jpg" || ext == ".jpeg");
}
@@ -0,0 +1,24 @@
#pragma once
#include <stdint.h>
#include <string>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "JPEG"; }
private:
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
};
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <HalStorage.h>
#include <Logging.h>
#include <stdint.h>
#include <cstring>
#include <string>
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
// Packs 4 pixels per byte, MSB first.
struct PixelCache {
uint8_t* buffer;
int width;
int height;
int bytesPerRow;
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
PixelCache(const PixelCache&) = delete;
PixelCache& operator=(const PixelCache&) = delete;
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
bool allocate(int w, int h, int ox, int oy) {
width = w;
height = h;
originX = ox;
originY = oy;
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
size_t bufferSize = (size_t)bytesPerRow * h;
if (bufferSize > MAX_CACHE_BYTES) {
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
return false;
}
buffer = (uint8_t*)malloc(bufferSize);
if (buffer) {
memset(buffer, 0, bufferSize);
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
}
return buffer != nullptr;
}
void setPixel(int screenX, int screenY, uint8_t value) {
if (!buffer) return;
int localX = screenX - originX;
int localY = screenY - originY;
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
int byteIdx = localY * bytesPerRow + localX / 4;
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
bool writeToFile(const std::string& cachePath) {
if (!buffer) return false;
FsFile cacheFile;
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
return false;
}
uint16_t w = width;
uint16_t h = height;
cacheFile.write(&w, 2);
cacheFile.write(&h, 2);
cacheFile.write(buffer, bytesPerRow * height);
cacheFile.close();
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
return true;
}
~PixelCache() {
if (buffer) {
free(buffer);
buffer = nullptr;
}
}
};
@@ -0,0 +1,400 @@
#include "PngToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include <PNGdec.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <cstdlib>
#include <new>
#include "DitherUtils.h"
#include "PixelCache.h"
namespace {
// Context struct passed through PNGdec callbacks to avoid global mutable state.
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
// Scaling state
float scale;
int srcWidth;
int srcHeight;
int dstWidth;
int dstHeight;
int lastDstY; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching;
uint8_t* grayLineBuffer;
PngContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scale(1.0f),
srcWidth(0),
srcHeight(0),
dstWidth(0),
dstHeight(0),
lastDstY(-1),
caching(false),
grayLineBuffer(nullptr) {}
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
void* pngOpenWithHandle(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
delete f;
return nullptr;
}
*size = f->size();
return f;
}
void pngCloseWithHandle(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
if (f) {
f->close();
delete f;
}
}
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
return f->read(pBuf, len);
}
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return -1;
return f->seek(pos);
}
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
// We heap-allocate it on demand rather than using a static instance, so this memory
// is only consumed while actually decoding/querying PNG images. This is critical on
// the ESP32-C3 where total RAM is ~320 KB.
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
// and each scanline includes a leading filter byte.
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
// PNGdec can overrun its internal buffer before our draw callback executes.
int bytesPerPixelFromType(int pixelType) {
switch (pixelType) {
case PNG_PIXEL_TRUECOLOR:
return 3;
case PNG_PIXEL_GRAY_ALPHA:
return 2;
case PNG_PIXEL_TRUECOLOR_ALPHA:
return 4;
case PNG_PIXEL_GRAYSCALE:
case PNG_PIXEL_INDEXED:
default:
return 1;
}
}
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
return ((pitch + 1) * 2) + 32;
}
// Convert entire source line to grayscale with alpha blending to white background.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
memcpy(grayLine, pPixels, width);
break;
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = pPixels[x];
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t* p = &palette[pPixels[x] * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
}
} else {
memcpy(grayLine, pPixels, width);
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
default:
memset(grayLine, 128, width);
break;
}
}
int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
// Calculate destination Y with scaling
int dstY = (int)(srcY * ctx->scale);
// Skip if we already rendered this destination row (multiple source rows map to same dest)
if (dstY == ctx->lastDstY) return 1;
ctx->lastDstY = dstY;
// Check bounds
if (dstY >= ctx->dstHeight) return 1;
int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) return 1;
// Convert entire source line to grayscale (improves cache locality)
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
pDraw->iHasAlpha);
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
int outXBase = ctx->config->x;
int screenWidth = ctx->screenWidth;
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
int srcX = 0;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
}
}
return 1;
}
} // namespace
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
return false;
}
PNG* png = new (std::nothrow) PNG();
if (!png) {
LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions");
return false;
}
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
nullptr);
if (rc != 0) {
LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc);
delete png;
return false;
}
out.width = png->getWidth();
out.height = png->getHeight();
png->close();
delete png;
return true;
}
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
return false;
}
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
PNG* png = new (std::nothrow) PNG();
if (!png) {
LOG_ERR("PNG", "Failed to allocate PNG decoder");
return false;
}
PngContext ctx;
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
pngDrawCallback);
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Failed to open PNG: %d", rc);
delete png;
return false;
}
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
png->close();
delete png;
return false;
}
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
ctx.scale, png->getBpp());
const int pixelType = png->getPixelType();
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
LOG_ERR("PNG",
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
png->close();
delete png;
return false;
}
if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
}
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
if (!ctx.grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
png->close();
delete png;
return false;
}
// Allocate cache buffer using SCALED dimensions
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
ctx.caching = false;
}
}
unsigned long decodeStart = millis();
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
free(ctx.grayLineBuffer);
ctx.grayLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
png->close();
delete png;
return false;
}
png->close();
delete png;
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled and buffer was allocated
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
}
return true;
}
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".png");
}
@@ -0,0 +1,17 @@
#pragma once
#include "ImageToFramebufferDecoder.h"
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
static bool supportsFormat(const std::string& extension);
const char* getFormatName() const override { return "PNG"; }
};
+431 -278
View File
@@ -1,144 +1,57 @@
#include "CssParser.h"
#include <Arduino.h>
#include <Logging.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <string_view>
namespace {
// Stack-allocated string buffer to avoid heap reallocations during parsing
// Provides string-like interface with fixed capacity
struct StackBuffer {
static constexpr size_t CAPACITY = 1024;
char data[CAPACITY];
size_t len = 0;
void push_back(char c) {
if (len < CAPACITY - 1) {
data[len++] = c;
}
}
void clear() { len = 0; }
bool empty() const { return len == 0; }
size_t size() const { return len; }
// Get string view of current content (zero-copy)
std::string_view view() const { return std::string_view(data, len); }
// Convert to string for passing to functions (single allocation)
std::string str() const { return std::string(data, len); }
};
// Buffer size for reading CSS files
constexpr size_t READ_BUFFER_SIZE = 512;
// Maximum CSS file size we'll process (prevent memory issues)
constexpr size_t MAX_CSS_SIZE = 64 * 1024;
// Maximum number of CSS rules to store in the selector map
// Prevents unbounded memory growth from pathological CSS files
constexpr size_t MAX_RULES = 1500;
// Minimum free heap required to apply CSS during rendering
// If below this threshold, we skip CSS to avoid display artifacts.
constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
// Maximum length for a single selector string
// Prevents parsing of extremely long or malformed selectors
constexpr size_t MAX_SELECTOR_LENGTH = 256;
// Check if character is CSS whitespace
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
// Read entire file into string (with size limit)
std::string readFileContent(FsFile& file) {
std::string content;
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
char buffer[READ_BUFFER_SIZE];
while (file.available() && content.size() < MAX_CSS_SIZE) {
const int bytesRead = file.read(buffer, sizeof(buffer));
if (bytesRead <= 0) break;
content.append(buffer, bytesRead);
}
return content;
}
// Remove CSS comments (/* ... */) from content
std::string stripComments(const std::string& css) {
std::string result;
result.reserve(css.size());
size_t pos = 0;
while (pos < css.size()) {
// Look for start of comment
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
// Find end of comment
const size_t endPos = css.find("*/", pos + 2);
if (endPos == std::string::npos) {
// Unterminated comment - skip rest of file
break;
}
pos = endPos + 2;
} else {
result.push_back(css[pos]);
++pos;
}
}
return result;
}
// Skip @-rules (like @media, @import, @font-face)
// Returns position after the @-rule
size_t skipAtRule(const std::string& css, const size_t start) {
// Find the end - either semicolon (simple @-rule) or matching brace
size_t pos = start + 1; // Skip the '@'
// Skip identifier
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
++pos;
}
// Look for { or ;
int braceDepth = 0;
while (pos < css.size()) {
const char c = css[pos];
if (c == '{') {
++braceDepth;
} else if (c == '}') {
--braceDepth;
if (braceDepth == 0) {
return pos + 1;
}
} else if (c == ';' && braceDepth == 0) {
return pos + 1;
}
++pos;
}
return css.size();
}
// Extract next rule from CSS content
// Returns true if a rule was found, with selector and body filled
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
selector.clear();
body.clear();
// Skip whitespace and @-rules until we find a regular rule
while (pos < css.size()) {
// Skip whitespace
while (pos < css.size() && isCssWhitespace(css[pos])) {
++pos;
}
if (pos >= css.size()) return false;
// Handle @-rules iteratively (avoids recursion/stack overflow)
if (css[pos] == '@') {
pos = skipAtRule(css, pos);
continue; // Try again after skipping the @-rule
}
break; // Found start of a regular rule
}
if (pos >= css.size()) return false;
// Find opening brace
const size_t bracePos = css.find('{', pos);
if (bracePos == std::string::npos) return false;
// Extract selector (everything before the brace)
selector = css.substr(pos, bracePos - pos);
// Find matching closing brace
int depth = 1;
const size_t bodyStart = bracePos + 1;
size_t bodyEnd = bodyStart;
while (bodyEnd < css.size() && depth > 0) {
if (css[bodyEnd] == '{')
++depth;
else if (css[bodyEnd] == '}')
--depth;
++bodyEnd;
}
// Extract body (between braces)
if (bodyEnd > bodyStart) {
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
}
pos = bodyEnd;
return true;
}
} // anonymous namespace
// String utilities implementation
@@ -161,12 +74,34 @@ std::string CssParser::normalized(const std::string& s) {
}
// Remove trailing space
if (!result.empty() && result.back() == ' ') {
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
result.pop_back();
}
return result;
}
void CssParser::normalizedInto(const std::string& s, std::string& out) {
out.clear();
out.reserve(s.size());
bool inSpace = true; // Start true to skip leading space
for (const char c : s) {
if (isCssWhitespace(c)) {
if (!inSpace) {
out.push_back(' ');
inSpace = true;
}
} else {
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
inSpace = false;
}
}
if (!out.empty() && out.back() == ' ') {
out.pop_back();
}
}
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
std::vector<std::string> parts;
size_t start = 0;
@@ -254,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
}
CssLength CssParser::interpretLength(const std::string& val) {
CssLength result;
tryInterpretLength(val, result);
return result;
}
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
const std::string v = normalized(val);
if (v.empty()) return CssLength{};
if (v.empty()) {
out = CssLength{};
return false;
}
// Find where the number ends
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
@@ -270,12 +213,13 @@ CssLength CssParser::interpretLength(const std::string& val) {
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
// Parse numeric value
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
if (endPtr == numPart.c_str()) {
out = CssLength{};
return false; // No number parsed (e.g. auto, inherit, initial)
}
// Determine unit type (preserve for deferred resolution)
auto unit = CssUnit::Pixels;
if (unitPart == "em") {
unit = CssUnit::Em;
@@ -286,133 +230,112 @@ CssLength CssParser::interpretLength(const std::string& val) {
} else if (unitPart == "%") {
unit = CssUnit::Percent;
}
// px and unitless default to Pixels
return CssLength{numericValue, unit};
}
int8_t CssParser::interpretSpacing(const std::string& val) {
const std::string v = normalized(val);
if (v.empty()) return 0;
// For spacing, we convert to "lines" (discrete units for e-ink)
// 1em ≈ 1 line, percentages based on ~30 lines per page
float multiplier = 0.0f;
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
unitStart = i;
break;
}
}
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
if (unitPart == "em" || unitPart == "rem") {
multiplier = 1.0f; // 1em = 1 line
} else if (unitPart == "%") {
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
} else {
return 0; // Unsupported unit for spacing
}
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) return 0;
int lines = static_cast<int>(numericValue * multiplier);
// Clamp to reasonable range (0-2 lines)
if (lines < 0) lines = 0;
if (lines > 2) lines = 2;
return static_cast<int8_t>(lines);
out = CssLength{numericValue, unit};
return true;
}
// Declaration parsing
void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf) {
const size_t colonPos = decl.find(':');
if (colonPos == std::string::npos || colonPos == 0) return;
normalizedInto(decl.substr(0, colonPos), propNameBuf);
normalizedInto(decl.substr(colonPos + 1), propValueBuf);
if (propNameBuf.empty() || propValueBuf.empty()) return;
if (propNameBuf == "text-align") {
style.textAlign = interpretAlignment(propValueBuf);
style.defined.textAlign = 1;
} else if (propNameBuf == "font-style") {
style.fontStyle = interpretFontStyle(propValueBuf);
style.defined.fontStyle = 1;
} else if (propNameBuf == "font-weight") {
style.fontWeight = interpretFontWeight(propValueBuf);
style.defined.fontWeight = 1;
} else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
style.textDecoration = interpretDecoration(propValueBuf);
style.defined.textDecoration = 1;
} else if (propNameBuf == "text-indent") {
style.textIndent = interpretLength(propValueBuf);
style.defined.textIndent = 1;
} else if (propNameBuf == "margin-top") {
style.marginTop = interpretLength(propValueBuf);
style.defined.marginTop = 1;
} else if (propNameBuf == "margin-bottom") {
style.marginBottom = interpretLength(propValueBuf);
style.defined.marginBottom = 1;
} else if (propNameBuf == "margin-left") {
style.marginLeft = interpretLength(propValueBuf);
style.defined.marginLeft = 1;
} else if (propNameBuf == "margin-right") {
style.marginRight = interpretLength(propValueBuf);
style.defined.marginRight = 1;
} else if (propNameBuf == "margin") {
const auto values = splitWhitespace(propValueBuf);
if (!values.empty()) {
style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
}
} else if (propNameBuf == "padding-top") {
style.paddingTop = interpretLength(propValueBuf);
style.defined.paddingTop = 1;
} else if (propNameBuf == "padding-bottom") {
style.paddingBottom = interpretLength(propValueBuf);
style.defined.paddingBottom = 1;
} else if (propNameBuf == "padding-left") {
style.paddingLeft = interpretLength(propValueBuf);
style.defined.paddingLeft = 1;
} else if (propNameBuf == "padding-right") {
style.paddingRight = interpretLength(propValueBuf);
style.defined.paddingRight = 1;
} else if (propNameBuf == "padding") {
const auto values = splitWhitespace(propValueBuf);
if (!values.empty()) {
style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
1;
}
} else if (propNameBuf == "height") {
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.imageHeight = len;
style.defined.imageHeight = 1;
}
} else if (propNameBuf == "width") {
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.imageWidth = len;
style.defined.imageWidth = 1;
}
}
}
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
CssStyle style;
std::string propNameBuf;
std::string propValueBuf;
// Split declarations by semicolon
const auto declarations = splitOnChar(declBlock, ';');
for (const auto& decl : declarations) {
// Find colon separator
const size_t colonPos = decl.find(':');
if (colonPos == std::string::npos || colonPos == 0) continue;
std::string propName = normalized(decl.substr(0, colonPos));
std::string propValue = normalized(decl.substr(colonPos + 1));
if (propName.empty() || propValue.empty()) continue;
// Match property and set value
if (propName == "text-align") {
style.textAlign = interpretAlignment(propValue);
style.defined.textAlign = 1;
} else if (propName == "font-style") {
style.fontStyle = interpretFontStyle(propValue);
style.defined.fontStyle = 1;
} else if (propName == "font-weight") {
style.fontWeight = interpretFontWeight(propValue);
style.defined.fontWeight = 1;
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
style.textDecoration = interpretDecoration(propValue);
style.defined.textDecoration = 1;
} else if (propName == "text-indent") {
style.textIndent = interpretLength(propValue);
style.defined.textIndent = 1;
} else if (propName == "margin-top") {
style.marginTop = interpretLength(propValue);
style.defined.marginTop = 1;
} else if (propName == "margin-bottom") {
style.marginBottom = interpretLength(propValue);
style.defined.marginBottom = 1;
} else if (propName == "margin-left") {
style.marginLeft = interpretLength(propValue);
style.defined.marginLeft = 1;
} else if (propName == "margin-right") {
style.marginRight = interpretLength(propValue);
style.defined.marginRight = 1;
} else if (propName == "margin") {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
}
} else if (propName == "padding-top") {
style.paddingTop = interpretLength(propValue);
style.defined.paddingTop = 1;
} else if (propName == "padding-bottom") {
style.paddingBottom = interpretLength(propValue);
style.defined.paddingBottom = 1;
} else if (propName == "padding-left") {
style.paddingLeft = interpretLength(propValue);
style.defined.paddingLeft = 1;
} else if (propName == "padding-right") {
style.paddingRight = interpretLength(propValue);
style.defined.paddingRight = 1;
} else if (propName == "padding") {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
style.defined.paddingLeft = 1;
size_t start = 0;
for (size_t i = 0; i <= declBlock.size(); ++i) {
if (i == declBlock.size() || declBlock[i] == ';') {
if (i > start) {
const size_t len = i - start;
std::string decl = declBlock.substr(start, len);
if (!decl.empty()) {
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
}
}
start = i + 1;
}
}
@@ -421,20 +344,83 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Rule processing
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) {
const CssStyle style = parseDeclarations(declarations);
// Only store if any properties were set
if (!style.defined.anySet()) return;
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
// Check if we've reached the rule limit before processing
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
return;
}
// Handle comma-separated selectors
const auto selectors = splitOnChar(selectorGroup, ',');
for (const auto& sel : selectors) {
// Validate selector length before processing
if (sel.size() > MAX_SELECTOR_LENGTH) {
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
continue;
}
// Normalize the selector
std::string key = normalized(sel);
if (key.empty()) continue;
// TODO: Consider adding support for sibling css selectors in the future
// Ensure no + in selector as we don't support adjacent CSS selectors for now
if (key.find('+') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for direct nested css selectors in the future
// Ensure no > in selector as we don't support nested CSS selectors for now
if (key.find('>') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for attribute css selectors in the future
// Ensure no [ in selector as we don't support attribute CSS selectors for now
if (key.find('[') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for pseudo selectors in the future
// Ensure no : in selector as we don't support pseudo CSS selectors for now
if (key.find(':') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for ID css selectors in the future
// Ensure no # in selector as we don't support ID CSS selectors for now
if (key.find('#') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for general sibling combinator selectors in the future
// Ensure no ~ in selector as we don't support general sibling combinator CSS selectors for now
if (key.find('~') != std::string_view::npos) {
continue;
}
// TODO: Consider adding support for wildcard css selectors in the future
// Ensure no * in selector as we don't support wildcard CSS selectors for now
if (key.find('*') != std::string_view::npos) {
continue;
}
// TODO: Add support for more complex selectors in the future
// At the moment, we only ever check for `tag`, `tag.class1` or `.class1`
// If the selector has whitespace in it, then it's either a CSS selector for a descendant element (e.g. `tag1 tag2`)
// or some other slightly more advanced CSS selector which we don't support yet
if (key.find(' ') != std::string_view::npos) {
continue;
}
// Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
return;
}
// Store or merge with existing
auto it = rulesBySelector_.find(key);
if (it != rulesBySelector_.end()) {
@@ -453,30 +439,158 @@ bool CssParser::loadFromStream(FsFile& source) {
return false;
}
// Read file content
const std::string content = readFileContent(source);
if (content.empty()) {
return true; // Empty file is valid
size_t totalRead = 0;
// Use stack-allocated buffers for parsing to avoid heap reallocations
StackBuffer selector;
StackBuffer declBuffer;
// Keep these as std::string since they're passed by reference to parseDeclarationIntoStyle
std::string propNameBuf;
std::string propValueBuf;
bool inComment = false;
bool maybeSlash = false;
bool prevStar = false;
bool inAtRule = false;
int atDepth = 0;
int bodyDepth = 0;
bool skippingRule = false;
CssStyle currentStyle;
auto handleChar = [&](const char c) {
if (inAtRule) {
if (c == '{') {
++atDepth;
} else if (c == '}') {
if (atDepth > 0) --atDepth;
if (atDepth == 0) inAtRule = false;
} else if (c == ';' && atDepth == 0) {
inAtRule = false;
}
return;
}
if (bodyDepth == 0) {
if (selector.empty() && isCssWhitespace(c)) {
return;
}
if (c == '@' && selector.empty()) {
inAtRule = true;
atDepth = 0;
return;
}
if (c == '{') {
bodyDepth = 1;
currentStyle = CssStyle{};
declBuffer.clear();
if (selector.size() > MAX_SELECTOR_LENGTH * 4) {
skippingRule = true;
}
return;
}
selector.push_back(c);
return;
}
// bodyDepth > 0
if (c == '{') {
++bodyDepth;
return;
}
if (c == '}') {
--bodyDepth;
if (bodyDepth == 0) {
if (!skippingRule && !declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
}
if (!skippingRule) {
processRuleBlockWithStyle(selector.str(), currentStyle);
}
selector.clear();
declBuffer.clear();
skippingRule = false;
return;
}
return;
}
if (bodyDepth > 1) {
return;
}
if (!skippingRule) {
if (c == ';') {
if (!declBuffer.empty()) {
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
declBuffer.clear();
}
} else {
declBuffer.push_back(c);
}
}
};
char buffer[READ_BUFFER_SIZE];
while (source.available()) {
int bytesRead = source.read(buffer, sizeof(buffer));
if (bytesRead <= 0) break;
totalRead += static_cast<size_t>(bytesRead);
for (int i = 0; i < bytesRead; ++i) {
const char c = buffer[i];
if (inComment) {
if (prevStar && c == '/') {
inComment = false;
prevStar = false;
continue;
}
prevStar = c == '*';
continue;
}
if (maybeSlash) {
if (c == '*') {
inComment = true;
maybeSlash = false;
prevStar = false;
continue;
}
handleChar('/');
maybeSlash = false;
// fall through to process current char
}
if (c == '/') {
maybeSlash = true;
continue;
}
handleChar(c);
}
}
// Remove comments
const std::string cleaned = stripComments(content);
// Parse rules
size_t pos = 0;
std::string selector, body;
while (extractNextRule(cleaned, pos, selector, body)) {
processRuleBlock(selector, body);
if (maybeSlash) {
handleChar('/');
}
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
LOG_DBG("CSS", "Parsed %zu rules from %zu bytes", rulesBySelector_.size(), totalRead);
return true;
}
// Style resolution
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
static bool lowHeapWarningLogged = false;
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) {
if (!lowHeapWarningLogged) {
lowHeapWarningLogged = true;
LOG_DBG("CSS", "Warning: low heap (%u bytes) below MIN_FREE_HEAP_FOR_CSS (%u), returning empty style",
ESP.getFreeHeap(), static_cast<unsigned>(MIN_FREE_HEAP_FOR_CSS));
}
return CssStyle{};
}
CssStyle result;
const std::string tag = normalized(tagName);
@@ -486,6 +600,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
result.applyOver(tagIt->second);
}
// TODO: Support combinations of classes (e.g. style on .class1.class2)
// 2. Apply class styles (medium priority)
if (!classAttr.empty()) {
const auto classes = splitWhitespace(classAttr);
@@ -499,6 +614,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
}
}
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
// 3. Apply element.class styles (higher priority)
for (const auto& cls : classes) {
std::string combinedKey = tag + "." + normalized(cls);
@@ -519,16 +635,27 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache serialization
// Cache format version - increment when format changes
constexpr uint8_t CSS_CACHE_VERSION = 2;
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
constexpr char rulesCache[] = "/css_rules.cache";
bool CssParser::saveToCache(FsFile& file) const {
if (!file) {
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
void CssParser::deleteCache() const {
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
}
bool CssParser::saveToCache() const {
if (cachePath.empty()) {
return false;
}
FsFile file;
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
return false;
}
// Write version
file.write(CSS_CACHE_VERSION);
file.write(CssParser::CSS_CACHE_VERSION);
// Write rule count
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
@@ -563,6 +690,8 @@ bool CssParser::saveToCache(FsFile& file) const {
writeLength(style.paddingBottom);
writeLength(style.paddingLeft);
writeLength(style.paddingRight);
writeLength(style.imageHeight);
writeLength(style.imageWidth);
// Write defined flags as uint16_t
uint16_t definedBits = 0;
@@ -579,15 +708,23 @@ bool CssParser::saveToCache(FsFile& file) const {
if (style.defined.paddingBottom) definedBits |= 1 << 10;
if (style.defined.paddingLeft) definedBits |= 1 << 11;
if (style.defined.paddingRight) definedBits |= 1 << 12;
if (style.defined.imageHeight) definedBits |= 1 << 13;
if (style.defined.imageWidth) definedBits |= 1 << 14;
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
}
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
file.close();
return true;
}
bool CssParser::loadFromCache(FsFile& file) {
if (!file) {
bool CssParser::loadFromCache() {
if (cachePath.empty()) {
return false;
}
FsFile file;
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
return false;
}
@@ -596,14 +733,18 @@ bool CssParser::loadFromCache(FsFile& file) {
// Read and verify version
uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
CssParser::CSS_CACHE_VERSION);
file.close();
Storage.remove((cachePath + rulesCache).c_str());
return false;
}
// Read rule count
uint16_t ruleCount = 0;
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
file.close();
return false;
}
@@ -613,6 +754,7 @@ bool CssParser::loadFromCache(FsFile& file) {
uint16_t selectorLen = 0;
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -620,6 +762,7 @@ bool CssParser::loadFromCache(FsFile& file) {
selector.resize(selectorLen);
if (file.read(&selector[0], selectorLen) != selectorLen) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -629,24 +772,28 @@ bool CssParser::loadFromCache(FsFile& file) {
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textAlign = static_cast<CssTextAlign>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontStyle = static_cast<CssFontStyle>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontWeight = static_cast<CssFontWeight>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
@@ -666,8 +813,10 @@ bool CssParser::loadFromCache(FsFile& file) {
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
!readLength(style.imageHeight) || !readLength(style.imageWidth)) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -675,6 +824,7 @@ bool CssParser::loadFromCache(FsFile& file) {
uint16_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear();
file.close();
return false;
}
style.defined.textAlign = (definedBits & 1 << 0) != 0;
@@ -690,10 +840,13 @@ bool CssParser::loadFromCache(FsFile& file) {
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
rulesBySelector_[selector] = style;
}
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
file.close();
return true;
}
+25 -7
View File
@@ -4,6 +4,7 @@
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "CssStyle.h"
@@ -29,7 +30,10 @@
*/
class CssParser {
public:
CssParser() = default;
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 3;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
// Non-copyable
@@ -76,28 +80,40 @@ class CssParser {
*/
void clear() { rulesBySelector_.clear(); }
/**
* Check if CSS rules cache file exists
*/
bool hasCache() const;
/**
* Delete CSS rules cache file exists
*/
void deleteCache() const;
/**
* Save parsed CSS rules to a cache file.
* @param file Open file handle to write to
* @return true if cache was written successfully
*/
bool saveToCache(FsFile& file) const;
bool saveToCache() const;
/**
* Load CSS rules from a cache file.
* Clears any existing rules before loading.
* @param file Open file handle to read from
* @return true if cache was loaded successfully
*/
bool loadFromCache(FsFile& file);
bool loadFromCache();
private:
// Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_;
std::string cachePath;
// Internal parsing helpers
void processRuleBlock(const std::string& selectorGroup, const std::string& declarations);
void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
static CssStyle parseDeclarations(const std::string& declBlock);
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf);
// Individual property value parsers
static CssTextAlign interpretAlignment(const std::string& val);
@@ -105,10 +121,12 @@ class CssParser {
static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val);
static CssLength interpretLength(const std::string& val);
static int8_t interpretSpacing(const std::string& val);
/** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */
static bool tryInterpretLength(const std::string& val, CssLength& out);
// String utilities
static std::string normalized(const std::string& s);
static void normalizedInto(const std::string& s, std::string& out);
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
static std::vector<std::string> splitWhitespace(const std::string& s);
};
+21 -2
View File
@@ -69,6 +69,8 @@ struct CssPropertyFlags {
uint16_t paddingBottom : 1;
uint16_t paddingLeft : 1;
uint16_t paddingRight : 1;
uint16_t imageHeight : 1;
uint16_t imageWidth : 1;
CssPropertyFlags()
: textAlign(0),
@@ -83,17 +85,21 @@ struct CssPropertyFlags {
paddingTop(0),
paddingBottom(0),
paddingLeft(0),
paddingRight(0) {}
paddingRight(0),
imageHeight(0),
imageWidth(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
imageWidth;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
imageHeight = imageWidth = 0;
}
};
@@ -115,6 +121,8 @@ struct CssStyle {
CssLength paddingBottom; // Padding after
CssLength paddingLeft; // Padding left
CssLength paddingRight; // Padding right
CssLength imageHeight; // Height for img (e.g. 2em) width derived from aspect ratio when only height set
CssLength imageWidth; // Width for img when both or only width set
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -173,6 +181,14 @@ struct CssStyle {
paddingRight = base.paddingRight;
defined.paddingRight = 1;
}
if (base.hasImageHeight()) {
imageHeight = base.imageHeight;
defined.imageHeight = 1;
}
if (base.hasImageWidth()) {
imageWidth = base.imageWidth;
defined.imageWidth = 1;
}
}
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -188,6 +204,8 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
void reset() {
textAlign = CssTextAlign::Left;
@@ -197,6 +215,7 @@ struct CssStyle {
textIndent = CssLength{};
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
imageHeight = imageWidth = CssLength{};
defined.clearAll();
}
};
@@ -174,6 +174,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
while (*ptr != 0) {
const unsigned char* current = ptr;
const uint32_t cp = utf8NextCodepoint(&ptr);
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
// a previous base character that can be composed into a single
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
// composition here. This provides lightweight NFC-like behavior for
// common Western European diacritics (acute, grave, circumflex, tilde,
// diaeresis, cedilla) without pulling in a full Unicode normalization
// library.
if (!cps.empty()) {
uint32_t prev = cps.back().value;
uint32_t composed = 0;
switch (cp) {
case 0x0300: // grave
switch (prev) {
case 0x0041:
composed = 0x00C0;
break; // A -> À
case 0x0061:
composed = 0x00E0;
break; // a -> à
case 0x0045:
composed = 0x00C8;
break; // E -> È
case 0x0065:
composed = 0x00E8;
break; // e -> è
case 0x0049:
composed = 0x00CC;
break; // I -> Ì
case 0x0069:
composed = 0x00EC;
break; // i -> ì
case 0x004F:
composed = 0x00D2;
break; // O -> Ò
case 0x006F:
composed = 0x00F2;
break; // o -> ò
case 0x0055:
composed = 0x00D9;
break; // U -> Ù
case 0x0075:
composed = 0x00F9;
break; // u -> ù
default:
break;
}
break;
case 0x0301: // acute
switch (prev) {
case 0x0041:
composed = 0x00C1;
break; // A -> Á
case 0x0061:
composed = 0x00E1;
break; // a -> á
case 0x0045:
composed = 0x00C9;
break; // E -> É
case 0x0065:
composed = 0x00E9;
break; // e -> é
case 0x0049:
composed = 0x00CD;
break; // I -> Í
case 0x0069:
composed = 0x00ED;
break; // i -> í
case 0x004F:
composed = 0x00D3;
break; // O -> Ó
case 0x006F:
composed = 0x00F3;
break; // o -> ó
case 0x0055:
composed = 0x00DA;
break; // U -> Ú
case 0x0075:
composed = 0x00FA;
break; // u -> ú
case 0x0059:
composed = 0x00DD;
break; // Y -> Ý
case 0x0079:
composed = 0x00FD;
break; // y -> ý
default:
break;
}
break;
case 0x0302: // circumflex
switch (prev) {
case 0x0041:
composed = 0x00C2;
break; // A -> Â
case 0x0061:
composed = 0x00E2;
break; // a -> â
case 0x0045:
composed = 0x00CA;
break; // E -> Ê
case 0x0065:
composed = 0x00EA;
break; // e -> ê
case 0x0049:
composed = 0x00CE;
break; // I -> Î
case 0x0069:
composed = 0x00EE;
break; // i -> î
case 0x004F:
composed = 0x00D4;
break; // O -> Ô
case 0x006F:
composed = 0x00F4;
break; // o -> ô
case 0x0055:
composed = 0x00DB;
break; // U -> Û
case 0x0075:
composed = 0x00FB;
break; // u -> û
default:
break;
}
break;
case 0x0303: // tilde
switch (prev) {
case 0x0041:
composed = 0x00C3;
break; // A -> Ã
case 0x0061:
composed = 0x00E3;
break; // a -> ã
case 0x004E:
composed = 0x00D1;
break; // N -> Ñ
case 0x006E:
composed = 0x00F1;
break; // n -> ñ
default:
break;
}
break;
case 0x0308: // diaeresis/umlaut
switch (prev) {
case 0x0041:
composed = 0x00C4;
break; // A -> Ä
case 0x0061:
composed = 0x00E4;
break; // a -> ä
case 0x0045:
composed = 0x00CB;
break; // E -> Ë
case 0x0065:
composed = 0x00EB;
break; // e -> ë
case 0x0049:
composed = 0x00CF;
break; // I -> Ï
case 0x0069:
composed = 0x00EF;
break; // i -> ï
case 0x004F:
composed = 0x00D6;
break; // O -> Ö
case 0x006F:
composed = 0x00F6;
break; // o -> ö
case 0x0055:
composed = 0x00DC;
break; // U -> Ü
case 0x0075:
composed = 0x00FC;
break; // u -> ü
case 0x0059:
composed = 0x0178;
break; // Y -> Ÿ
case 0x0079:
composed = 0x00FF;
break; // y -> ÿ
default:
break;
}
break;
case 0x0327: // cedilla
switch (prev) {
case 0x0043:
composed = 0x00C7;
break; // C -> Ç
case 0x0063:
composed = 0x00E7;
break; // c -> ç
default:
break;
}
break;
default:
break;
}
if (composed != 0) {
cps.back().value = composed;
continue; // skip pushing the combining mark itself
}
}
cps.push_back({cp, static_cast<size_t>(current - base)});
}
+49 -1
View File
@@ -1,8 +1,10 @@
#include "Hyphenator.h"
#include <algorithm>
#include <vector>
#include "HyphenationCommon.h"
#include "LanguageHyphenator.h"
#include "LanguageRegistry.h"
const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr;
@@ -32,10 +34,19 @@ size_t byteOffsetForIndex(const std::vector<CodepointInfo>& cps, const size_t in
}
// Builds a vector of break information from explicit hyphen markers in the given codepoints.
// Only hyphens that appear between two alphabetic characters are considered valid breaks.
//
// Example: "US-Satellitensystems" (cps: U, S, -, S, a, t, ...)
// -> finds '-' at index 2 with alphabetic neighbors 'S' and 'S'
// -> returns one BreakInfo at the byte offset of 'S' (the char after '-'),
// with requiresInsertedHyphen=false because '-' is already visible.
//
// Example: "Satel\u00ADliten" (soft-hyphen between 'l' and 'l')
// -> returns one BreakInfo with requiresInsertedHyphen=true (soft-hyphen
// is invisible and needs a visible '-' when the break is used).
std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<CodepointInfo>& cps) {
std::vector<Hyphenator::BreakInfo> breaks;
// Scan every codepoint looking for explicit/soft hyphen markers that are surrounded by letters.
for (size_t i = 1; i + 1 < cps.size(); ++i) {
const uint32_t cp = cps[i].value;
if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) {
@@ -63,6 +74,43 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
// Explicit hyphen markers (soft or hard) take precedence over language breaks.
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
if (!explicitBreakInfos.empty()) {
// When a word contains explicit hyphens we also run Liang patterns on each alphabetic
// segment between them. Without this, "US-Satellitensystems" would only offer one split
// point (after "US-"), making it impossible to break mid-"Satellitensystems" even when
// "US-Satelliten-" would fit on the line.
//
// Example: "US-Satellitensystems"
// Segments: ["US", "Satellitensystems"]
// Explicit break: after "US-" -> @3 (no inserted hyphen)
// Pattern breaks on "Satellitensystems" -> @5 Sa|tel (+hyphen)
// @8 Satel|li (+hyphen)
// @10 Satelli|ten (+hyphen)
// @13 Satelliten|sys (+hyphen)
// @16 Satellitensys|tems (+hyphen)
// Result: 6 sorted break points; the line-breaker picks the widest prefix that fits.
if (hyphenator) {
size_t segStart = 0;
for (size_t i = 0; i <= cps.size(); ++i) {
const bool atEnd = (i == cps.size());
const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value);
if (atEnd || atHyphen) {
if (i > segStart) {
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
auto segIndexes = hyphenator->breakIndexes(segment);
for (const size_t idx : segIndexes) {
const size_t cpIdx = segStart + idx;
if (cpIdx < cps.size()) {
explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true});
}
}
}
segStart = i + 1;
}
}
// Merge explicit and pattern breaks into ascending byte-offset order.
std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(),
[](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; });
}
return explicitBreakInfos;
}
+17 -4
View File
@@ -9,11 +9,24 @@ class LanguageHyphenator;
class Hyphenator {
public:
struct BreakInfo {
size_t byteOffset;
bool requiresInsertedHyphen;
size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur.
bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks).
// false = the word already contains a hyphen at this position (explicit '-').
};
// Returns byte offsets where the word may be hyphenated. When includeFallback is true, all positions obeying the
// minimum prefix/suffix constraints are returned even if no language-specific rule matches.
// Returns byte offsets where the word may be hyphenated.
//
// Break sources (in priority order):
// 1. Explicit hyphens already present in the word (e.g. '-' or soft-hyphen U+00AD).
// When found, language patterns are additionally run on each alphabetic segment
// between hyphens so compound words can break within their parts.
// Example: "US-Satellitensystems" yields breaks after "US-" (no inserted hyphen)
// plus pattern breaks inside "Satellitensystems" (Sa|tel|li|ten|sys|tems).
// 2. Language-specific Liang patterns (e.g. German de_patterns).
// Example: "Quadratkilometer" -> Qua|drat|ki|lo|me|ter.
// 3. Fallback every-N-chars splitting (only when includeFallback is true AND no
// pattern breaks were found). Used as a last resort to prevent a single oversized
// word from overflowing the page width.
static std::vector<BreakInfo> breakOffsets(const std::string& word, bool includeFallback);
// Provide a publication-level language hint (e.g. "en", "en-US", "ru") used to select hyphenation rules.

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