Compare commits

...
70 Commits
Author SHA1 Message Date
Dave Allie 56ec3dfb6d chore: Cut release 0.14.0
Compile Release / build-release (push) Canceled after 0s
2026-01-15 01:01:47 +11:00
Maeve AndrewsandMaeve Andrews e517945aaa Add a bullet to the beginning of any <li> (#368)
Currently there is no visual indication whatsoever if something is in a
list. An `<li>` is essentially just another paragraph.

As a partial remedy for this, add a bullet character to the beginning of
`<li>` text blocks so that the user can see that they're list items.
This is incomplete in that an `<ol>` should also have a counter so that
its list items can get numbers instead of bullets (right now I don't
think we track if we're in a `<ul>` or an `<ol>` at all), but it's
strictly better than the current situation.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-14 23:23:03 +11:00
Maeve AndrewsandMaeve Andrews 489220832f Only indent paragraphs for justify/left-align (#367)
Currently, when Extra Paragraph Spacing is off, an em-space is added to
the beginning of each ParsedText even for blocks like headers that are
centered. This whitespace makes the centering slightly off. Change the
calculation here to only add the em-space for left/justified text.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-14 23:21:48 +11:00
Dave Allie 3ee10b31ab Update OTA updater URL 2026-01-14 23:14:00 +11:00
swwilshubandClaude a946c83a07 Turbocharge WiFi uploads with WebSocket + watchdog stability (#364)
## Summary

* **What is the goal of this PR?** Fix WiFi file transfer stability
issues (especially crashes during uploads) and improve upload speed via
WebSocket binary protocol. File transfers now don't really crash as
much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s.

* **What changes are included?**
- **WebSocket upload support** - Adds WebSocket binary protocol for file
uploads, achieving faster speeds 335 KB/s vs HTTP multipart)
- **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls
throughout upload path to prevent watchdog timeouts during:
    - File creation (FAT allocation can be slow)
    - SD card write operations
    - HTTP header parsing
    - WebSocket chunk processing
- **4KB write buffering** - Batches SD card writes to reduce I/O
overhead
- **WiFi health monitoring** - Detects WiFi disconnection in STA mode
and exits gracefully
- **Improved handleClient loop** - 500 iterations with periodic watchdog
resets and button checks for responsiveness
- **Progress bar improvements** - Fixed jumping/inaccurate progress by
capping local progress at 95% until server confirms completion
- **Exit button responsiveness** - Button now checked inside the
handleClient loop every 64 iterations
- **Reduced exit delays** - Decreased shutdown delays from ~850ms to
~140ms

**Files changed:**
- `platformio.ini` - Added WebSockets library dependency
- `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering,
watchdog resets
- `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop,
button handling
- `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback

## Additional Context

- WebSocket uses 4KB chunks with backpressure management to prevent
ESP32 buffer overflow
- Falls back to HTTP automatically if WebSocket connection fails
- The main bottleneck now is SD card write speed (~44% of transfer
time), not WiFi
- STA mode was more prone to crashes than AP mode due to external
network factors; WiFi health monitoring helps detect and handle
disconnections gracefully

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_ Claude did it
ALL, I have no idea what I am doing, but my books transfer fast now.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 23:11:28 +11:00
Justin MitchellandDave Allie 847786e342 Fixes issue with Calibre web expecting SSL (#347)
http urls now work with Calibre web

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-14 11:54:14 +00:00
Luke Stein c2fb8ce55d Update User Guide to reflect release 0.13.1 (#337)
Please note I have not tested the Calibre features and am not yet in a
position to offer detailed documentation of how they work.
2026-01-14 22:48:43 +11:00
Armando Cerna ed05554d74 feat: Add setting to toggle long-press chapter skip (#341)
## Summary

Adds a new "Long-press Chapter Skip" toggle in Settings to control
whether holding the side buttons skips chapters.

I kept accidentally triggering chapter skips while reading, which caused
me to lose my place in the middle of long chapters.

## Additional Context

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

---

### AI Usage

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

Did you use AI tools to help write this code? _**PARTIALLY **_
2026-01-14 22:47:24 +11:00
Jonas Diemer 9a9dc044ce ifdef around optional fonts to reduce flash size/time. (#339)
## Summary

Adds define to omit optional fonts from the build. This reduces time to
flash from >31s to <13s. Useful for development that doesn't require
fonts. Addresses #193

Invoke it like this during development:
`PLATFORMIO_BUILD_FLAGS="-D OMIT_FONTS" pio run --target upload && pio
device monitor`

Changing the define causes `pio` to do a full rebuild (but it will be
quick if you keep the define).

---

### 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-01-14 22:40:40 +11:00
Jonas Diemer 1c027ce2cd Skip BOM character (sometimes used in front of em-dashes) (#340)
## Summary

Skip BOM character (sometimes used in front of em-dashes) - they are not
part of the glyph set and would render `?` otherwise.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_
2026-01-14 22:38:30 +11:00
Eunchurn Park 49f97b69ca Add TXT file reader support (#240)
## Summary

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

Add support for reading plain text (.txt) files, enabling users to
browse, read, and track progress in TXT documents alongside existing
EPUB and XTC formats.

* **What changes are included?**

- New Txt library for loading and parsing plain text files
- New TxtReaderActivity with streaming page rendering using 8KB chunks
to handle large files without memory issues on ESP32-C3
- Page index caching system (index.bin) for instant re-open after sleep
or app restart
- Progress bar UI during initial file indexing (matching EPUB style)
- Word wrapping with proper UTF-8 support
- Cover image support for TXT files:
- Primary: image with same filename as TXT (e.g., book.jpg for book.txt)
  - Fallback: cover.bmp/jpg/jpeg in the same folder
  - JPG to BMP conversion using existing converter
  - Sleep screen cover mode now works with TXT files
- File browser now shows .txt files

## Additional Context

* Add any other information that might be helpful for the reviewer

* Memory constraints: The streaming approach was necessary because
ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely
into memory, so we read 8KB chunks and build a page offset index
instead.

* Cache invalidation: The page index cache automatically invalidates
when file size, viewport width, or lines per page changes (e.g., font
size or orientation change).

* Performance: First open requires indexing (with progress bar),
subsequent opens load from cache instantly.

* Cover image format: PNG is detected but not supported for conversion
(no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 21:36:40 +11:00
Dave Allie 14643d0225 Move string helpers out of HomeActivity into StringUtils 2026-01-14 21:24:45 +11:00
Eunchurn Park fecd1849b9 Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,

Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.

* **What changes are included?**

- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering

## Additional Context

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

- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 21:24:02 +11:00
Will Morrow 2040e088e7 Ensure new custom sleep image every time (#300)
When picking a random sleep image from a set of custom images, compare
the randomly chosen index against a cached value in settings. If the
value matches, use the next image (rolling over if it's the last image).
Cache the chosen image index to settings in either case.

## Summary

Implements a tweak on the custom sleep image feature that ensures that
the user gets a new image every time the device goes to sleep.
This change adds a new setting (perhaps there's a better place to cache
this?) that stores the most recently used file index. During picking the
random image index, we compare this against the random index and choose
the next one (modulo the number of image files) if it matches, ensuring
we get a new image.

## Additional Context

As mentioned, I used settings to cache this value since it is a
persisted store, perhaps that's overkill. Open to suggestions on if
there's a better way.
2026-01-14 21:05:08 +11:00
Andrew Brandt 65d23910a3 ci: add PR format check workflow (#328)
**Description**:

Add a new workflow to check the PR formatting to ensure consistency on
PR titles. We can also use this for semantic release versioning later,
if we so desire.

**Related Issue(s)**:

Implements first portion of #327

---------

Signed-off-by: Andrew Brandt <brandt.andrew89@gmail.com>
2026-01-14 21:04:02 +11:00
Dave Allie 52995fa722 chore: Cut release 0.13.1
Compile Release / build-release (push) Canceled after 0s
2026-01-13 02:09:39 +11:00
Dave Allie d4f8eda154 fix: Increase home activity stack size (#333)
## Summary

* fix: Increase home activity stack size

## Additional Context

* Home activity can crash occasionally depending on book

---

### 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-01-13 02:09:06 +11:00
Dave Allie 33b8fa0e19 chore: Cut release 0.13.0
Compile Release / build-release (push) Canceled after 0s
2026-01-13 00:59:13 +11:00
Dave Allie 16c760b2d2 copy: Tweak pull request template wording 2026-01-13 00:59:04 +11:00
Dave Allie 8f3df7e10e fix: Handle EPUB 3 TOC to spine mapping when nav file in subdirectory (#332)
## Summary

- Nav file in EPUB 3 file is a HTML file with relative hrefs
- If this file exists anywhere but in the same location as the
content.opf file, navigating in the book will fail
- Bump the book cache version to rebuild potentially broken books

## Additional Context

- Fixes https://github.com/daveallie/crosspoint-reader/issues/264

---

### 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
- [x] No
2026-01-13 00:57:34 +11:00
Jonas Diemer 0165fab581 Fix BMP rendering gamma/brightness (#302)
1. Refactor Bitmap.cpp/h to expose the options for FloydSteinberg and
brightness/gamma correction at runtime
2. Fine-tune the thresholds for Floyd Steiberg and simple quantization
to better match the display's colors

Turns out that 2 is enough to make the images render properly, so the
brightness boost and gamma adjustment doesn't seem necessary currently
(at least for my test image).
2026-01-12 22:36:19 +11:00
danoobanddanoooob 66b100c6ca fix: Wi-Fi Selection on Calibre Library launch (#313)
## Summary

* **What is the goal of this PR?** 
Fixes the Wi-Fi connection issue when launching the Calibre Library
(OPDS browser). The previous implementation always attempted to connect
using the first saved WiFi credential, which caused connection failures
when users were in locations where only other saved networks (not the
first one) were available. Now, the activity launches a WiFi selection
screen allowing users to choose from available networks.

* **What changes are included?**

## Additional Context
**Bug Fixed**: Previously, the code used `credentials[0]` (always the
first saved WiFi), so users in areas with only their secondary/tertiary
saved networks available could never connect.

---------

Co-authored-by: danoooob <danoooob@example.com>
2026-01-12 21:49:42 +11:00
Andrew Brandt 41bda43899 chore: update formatting in github workflows (#320)
**Description**:

The purpose of this change is to modify the spacing in the
`.github/workflow` files to ensure consistency.

**Related Issue(s)**:

Implements #319

Signed-off-by: Andrew Brandt <brandt.andrew89@gmail.com>
2026-01-12 21:37:23 +11:00
Dave Allie 82f21f3c1d Add AI usage question to the PR template 2026-01-12 21:35:18 +11:00
Jonas Diemer a9242fe61f Generate different .bmp for cropped covers so settings have effect. (#330)
Addresses
https://github.com/daveallie/crosspoint-reader/pull/225#issuecomment-3735150337
2026-01-12 20:55:47 +11:00
Jonas DiemerandDave Allie 88d0d90471 Add option to hide battery percentage. (#297)
with option to always hide or hide in reader only.

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-12 20:53:58 +11:00
David Fischer 97c4871316 Add page turn on power button press (#286)
## Summary

* **What is the goal of this PR?** 
* This PR adds a setting to (additionally) map the forward page turn
onto the powerbutton when in `EPUBReaderActivity` and powerbutton short
press is not mapped to sleep mode. I find the powerbutton to be exactly
where my thumb is while reading so it is very convenient to map the
forwardpage turn to that. Maybe Im not alone with this ^^
* **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).
2026-01-12 20:07:26 +11:00
Seth 66811bf50b Add navigation hints to ChapterSelectionActivities (#294)
## Summary

Add navigation hints to Chapter Select - #190 

### Before

![Mi 11X_20260108_214114_lmc_8
4](https://github.com/user-attachments/assets/45031d21-2c6c-4b7d-a5cc-6ad111bf5a70)

### After
![Mi 11X_20260108_213803_lmc_8
4](https://github.com/user-attachments/assets/1fa4ef22-63e4-4adb-8fc5-5fb8c7fa79fa)
2026-01-12 19:59:02 +11:00
Samuel Carpentier 87287012ba Updated user guide (sleep screens list) (#293)
## Summary

Updated sleep screens list by adding the newly available "Blank" option
2026-01-12 19:58:08 +11:00
Luke Stein d4ae108d9b Docs: Add instructions for file management via curl (#282)
Per a [reddit
thread](https://www.reddit.com/r/xteinkereader/comments/1q0fk9r/if_using_crosspoint_firmware_you_can_upload_using/),
the file manager can be accessed via curl.

Given file upload or deletion via curl may be useful for advanced users,
I've added instructions.
2026-01-09 08:58:58 +11:00
Jonas Diemer 7240cd52a9 Move battery status on home screen to top left (#253)
So it doesn't look so lost on a row on its own.

Also sligthly (1px) moved symbol in on reader view.
2026-01-09 08:57:50 +11:00
Dave Allie 0bae3bbf64 Support up to 500 character file names (#275)
## Summary

- Support up to 500 character file names

## Additional Context

- Fixes #265
2026-01-07 23:43:19 +11:00
Dave Allie 2b12a65011 Remove HTML entity parsing (#274)
## Summary

* Remove HTML entity parsing
  * This has been completely useless since the introduction of expat
* expat tries to parse all entities in the document, but only knows of
HTML ones
* Parsing will never end with HTML entities in the text, so the
additional step to parse them that we had went completely unused
* We should figure out the best way to parse that content in the future,
but for now remove that module as it generates a lot of heap allocations
with its map and strings
2026-01-07 23:08:43 +11:00
Dave Allie 46fa186b82 Make extension checks case-insensitive (#273)
## Summary

* Implement new `StringUtils::checkFileExtension` which does case
insensitive checking
* Move all checks over to this
2026-01-07 21:07:23 +11:00
Luke Stein 0cc2c64df2 Update User Guide to reflect release 0.12.0 (#269)
## Summary

* Update the User Guide per current firmware release.
* Make some formatting improvements including for readability and
consistency.
2026-01-07 20:28:32 +11:00
Stanislav KhromovandDave Allie 1f956e972b Allow disabling anti-aliasing via a setting (#241)
## Summary

Fixes https://github.com/daveallie/crosspoint-reader/issues/233

## Additional Context

By disabling the Text Anti-Aliasing in the settings, you can get faster
black-and-white page turns that work exactly like the stock firmware,
instead of having an additional flash after rendering (see issue linked
above for example).

Tested on the X4 and confirmed working.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-07 20:14:35 +11:00
Dave Allie 9c573e6f7f Ensure new settings are at the end of the settings file 2026-01-07 20:02:33 +11:00
Justin 0edb2baced feat: remember parent folder index in menu when ascending folders (#260)
## Summary

Adds feature to file selection activity for better user navigation when
ascending folders. The activity now remembers the index of the parent
folder instead of always resetting to the first element.

I don't have any means of testing this, so if someone could test it
that'd be great

Resolves #259
2026-01-07 19:58:49 +11:00
Justin MitchellandDave Allie b792b792bf Calibre Web Epub Downloading + Calibre Wireless Device Syncing (#219)
## Summary

Adds support for browsing and downloading books from a Calibre-web
server via OPDS.
How it works
1. Configure server URL in Settings → Calibre Web URL (e.g.,
https://myserver.com:port I use Cloudflare tunnel to make my server
accessible anywhere fwiw)
2. "Calibre Library" will now show on the the home screen
3. Browse the catalog - navigate through categories like "By Newest",
"By Author", "By Series", etc.
4. Download books - select a book and press Confirm to download the EPUB
to your device
Navigation
- Up/Down - Move through entries
- Confirm - Open folder or download book
- Back - Go to parent catalog, or exit to home if at root
- Navigation entries show with > prefix, books show title and author
- Button hints update dynamically ("Open" for folders, "Download" for
books)
Technical details
- Fetches OPDS catalog from {server_url}/opds
- Parses both navigation feeds (catalog links) and acquisition feeds
(downloadable books)
- Maintains navigation history stack for back navigation
- Handles absolute paths in OPDS links correctly (e.g.,
/books/opds/navcatalog/...)
- Downloads EPUBs directly to the SD card root
Note
The server URL should be typed to include https:// if the server
requires it - HTTP→HTTPS redirects may cause SSL errors on ESP32.

## Additional Context

* I also changed the home titles to use uppercase for each word and
added a setting to change the size of the side margins

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-07 19:58:37 +11:00
Jonas DiemerandDave Allie afe9672156 Feature/cover crop mode (#225)
Added a setting to select `fit` or `crop` for cover image on sleep
screen.

Might add a `expand` feature in the future that does not crop but rather
fills the blank space with a mirror of the image.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-05 21:07:27 +11:00
David FischerandDave Allie 9f95b31de5 add settings for reader screen margin (#223)
## Summary

* **What is the goal of this PR?** 
* This PR adds a setting to control the top left and right margins of
the reader screen in 4 sizes (5, 10, 20, 40 pt?) and defaults to `SMALL`
which is equivalent to the fixed margin of 5 that was already in use
before.
* **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).

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-05 20:29:08 +11:00
Stanislav Khromov c76507c937 Add blank sleep screen option (#242)
## Summary

Very small change to add a blank ("None") sleep screen option, for those
who prefer a clean aesthetic.

Tested on X4 device.
2026-01-05 20:08:39 +11:00
Justin 881aa2e005 Fix scrolling wrap-around in settings menu (#249)
## Summary

Fixes a bug in the settings menu, where previously wrap-around only
worked when scrolling upwards. Now, scrolling downwards on the last list
element wraps around to the top as expected.

Resolves #236.
2026-01-05 19:25:27 +11:00
Dave Allie 14972b34cb Remove authentication type from hotspot QR code (#235)
## Summary

* Remove the `T` parameter (authentication type), and `P` parameter
(password) for the SoftAP wifi config QR code

## Additional Context

* It is optional according to the spec:
https://www.wi-fi.org/system/files/WPA3%20Specification%20v3.2.pdf#page=25
so should either be omitted or set to nopass
* Fixes https://github.com/daveallie/crosspoint-reader/issues/229
2026-01-04 16:08:32 +11:00
Jonas Diemer c8f4870d7c Improved battery symbol (#228)
Rounded corners, smaller positive terminal thingy.
2026-01-04 15:27:34 +11:00
Dave Allie 5fdf23f1d2 Cut release 0.12.0
Compile Release / build-release (push) Canceled after 0s
2026-01-03 19:42:14 +11:00
2fb417ee90 Feat/sleep and refresh settings (#209)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)
* **What changes are included?**

## Additional Context

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

---------

Co-authored-by: ratedcounsel <hello@ratedcounsel.com>
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-03 19:33:42 +11:00
V c8f6160fbc Refactor semantic version comparison for OTA updates (#216)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)

This PR refactors the semantic version comparison logic used during OTA
update checks.

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5617830** bytes from 6553600 bytes)

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5616870** bytes from 6553600 bytes)


* **What changes are included?**

Replaced std::string::substr() and std::stoi() based parsing with a
lightweight, heap-free approach.
Version parsing is now done in a single pass without creating temporary
std::string objects.
Behavior remains identical: versions are still compared as
MAJOR.MINOR.PATCH.

## Additional Context

`std::string::substr() ` creates a new string and performs heap
allocation

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
2026-01-03 19:19:53 +11:00
Brendan O'Leary 8e4484cd22 Add buton hints to keyboard screen (#205)
## Summary

This adds the correctly styled button hints to the keyboard screen as
well as the ability to add hints to the side buttons (and up/down hints
to that screen)

## Additional Context

N/A
2026-01-03 19:17:53 +11:00
Pavel Liashkov 0332e1103a Add EPUB 3 nav.xhtml TOC support (#197)
## Summary

* **What is the goal of this PR?** Add EPUB 3 support by implementing
native navigation document (nav.xhtml) parsing with NCX fallback,
addressing issue Fixes: #143.

  * **What changes are included?**
- New `TocNavParser` for parsing EPUB 3 HTML5 navigation documents
(`<nav epub:type="toc">`)
- Detection of nav documents via `properties="nav"` attribute in OPF
manifest
- Fallback logic: try EPUB 3 nav first, fall back to NCX (EPUB 2) if
unavailable
- Graceful degradation: books without any TOC now load with a warning
instead of failing

  ## Additional Context

* The implementation follows the existing streaming XML parser pattern
using Expat to minimize RAM usage on the ESP32-C3
* EPUB 3 books that include both nav.xhtml and toc.ncx will prefer the
nav document (per EPUB 3 spec recommendation)
* No breaking changes - existing EPUB 2 books continue to work as before
* Tested on examples from
https://idpf.github.io/epub3-samples/30/samples.html
2026-01-03 19:10:35 +11:00
Jonas Diemer 5790d6f5dc Subtract time it took reaching the evaluation from the press duration. (#208)
Adresses #206
2026-01-03 18:54:23 +11:00
Jake Lyell 062d69dc2a Add support for uploading multiple epubs (#202)
Upload multiple files at once in sequence. Add retry button for files
that fail

## Summary

* **What is the goal of this PR?**
Add support for selecting multiple epub files in one go, before
uploading them all to the device
* **What changes are included?**
Allow multiple selections to be submitted to the input field.
Sends each file to the device one by one in a loop
Adds retry logic and UI for easy re-trying of failed uploads

Addresses #201 


button now says "Choose Files", and shows the number of files you
selected
<img width="506" height="199" alt="image"
src="https://github.com/user-attachments/assets/64b0b921-1e67-438e-9cd7-57d5466f2456"
/>

Shows which file is uploading:
<img width="521" height="283" alt="image"
src="https://github.com/user-attachments/assets/17b4d349-0698-4712-984c-b72fcdcb0918"
/>

Failed upload dialog:
<img width="851" height="441" alt="image"
src="https://github.com/user-attachments/assets/e8bf4aa6-d3d2-4c0b-9c7a-420e8c413033"
/>
<img width="834" height="641" alt="image"
src="https://github.com/user-attachments/assets/656a9732-3963-4844-94e3-4d8736f6d9d5"
/>
2026-01-02 18:32:26 +11:00
Maeve AndrewsandMaeve Andrews 5e9626eb2a Add paragraph alignment setting (justify/left/center/right) (#191)
## Summary

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

Add a new user setting for paragraph alignment, instead of hard-coding
full justification.

* **What changes are included?**

One new line in the settings screen, with 4 options
(justify/left/center/right). Default is justified since that's what it
was already. I personally only wanted to disable justification and use
"left", but I included the other options for completeness since they
were already supported.

## Additional Context

Tested on my X4 and looks as expected for each alignment.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-02 18:21:48 +11:00
Jonas Diemer 00e83af4e8 Show "Entering Sleep" on black, so it's quicker to notice (in book). (#181)
Black popup is easier to notice at higher contrast.
2026-01-02 17:55:21 +11:00
Jonas Diemer 39080c0e51 Skip soft hyphens. (#195)
For now, let's skip the soft hyphens (later, we can treat them in the
layouter). See
https://github.com/daveallie/crosspoint-reader/discussions/17#discussioncomment-15378475
2026-01-02 17:54:46 +11:00
Brendan O'Leary 9e59a5106b Fix race condition with keyboard and Wifi entry (#204)
## Summary

* **What is the goal of this PR?** Fixes a bug -
https://github.com/daveallie/crosspoint-reader/issues/187 - where the
screen would freeze after entering a WiFi password, causing the device
to appear hung.

* **What changes are included?**
- Fixed a race condition in `WifiSelectionActivity::displayTaskLoop()`
that caused rendering of an empty screen when transitioning from the
keyboard subactivity
- Added `vTaskDelay()` when a subactivity is active to prevent CPU
starvation from a tight `continue` loop
- Added a check to skip rendering when in `PASSWORD_ENTRY` state,
allowing the state machine to properly transition to `CONNECTING` before
the display updates

## Additional Context

* **Root cause:** When the keyboard subactivity exited after password
entry, the display task would wake up and attempt to render. However,
the `state` was still `PASSWORD_ENTRY` (before `attemptConnection()`
changed it to `CONNECTING`), and since there was no render case for
`PASSWORD_ENTRY`, the display would show a cleared/empty buffer,
appearing frozen.

* **Performance implications:** The added `vTaskDelay(10)` calls when
subactivity is active or in `PASSWORD_ENTRY` state actually improve
performance by preventing CPU starvation - previously the display task
would spin in a tight loop with `continue` while a subactivity was
present.

* **Testing focus:** Test the full WiFi connection flow:
  1. Enter network selection
  2. Select a network requiring a password
  3. Enter password and press OK
  4. Verify "Connecting..." screen appears
  5. Verify connection completes and prompts to save password
2026-01-02 17:49:16 +11:00
Pavel Liashkov a922e553ed Prevent device sleep during WiFi file transfer and OTA updates (#203)
## Summary

* **What is the goal of this PR?** Fixes #199 - Device falls asleep
during WiFi file transfer after 10 minutes of inactivity, disconnecting
the web server.

  * **What changes are included?**
    - Add `preventAutoSleep()` virtual method to `Activity` base class
- Modify main loop to reset inactivity timer when `preventAutoSleep()`
returns true
- Override `preventAutoSleep()` in `CrossPointWebServerActivity`
(returns true when web server running)
- Override `preventAutoSleep()` in `OtaUpdateActivity` (returns true
during update check/download)

  ## Additional Context

* The existing `skipLoopDelay()` method controls loop timing (yield vs
delay) for HTTP responsiveness. The new `preventAutoSleep()` method is
semantically separate - it explicitly signals that an activity should
keep the device awake.
* `CrossPointWebServerActivity` uses both methods: `skipLoopDelay()` for
responsive HTTP handling, `preventAutoSleep()` for staying awake.
* `OtaUpdateActivity` only needs `preventAutoSleep()` since the OTA
library handles HTTP internally.
2026-01-02 17:44:17 +11:00
Dave Allie 04ad4e5aa4 Replace book and section bin format images with ImHex hexpat definition (#189)
## Summary

* Replace book and section bin format images with ImHex hexpat
definition
* This should give readers an understanding of the file format but also
supply some utility when validating content/output
2025-12-31 13:28:24 +11:00
Dave Allie 6e9ba1006a Use sane smaller data types for data in section.bin (#188)
## Summary

* Update EpdFontFamily::Style to be u8 instead of u32 (saving 3 bytes
per word)
* Update layout width/height to be u16 from int
* Update page element count to be u16 from u32
* Update text block element count to be u16 from u32
* Bumped section bin version to version 8
2025-12-31 13:11:36 +11:00
Luke Stein 40f9ed485c Enhance USER_GUIDE with links and clarifications (#185)
Updated USER_GUIDE.md for clarity and added links to sections.

## Summary

* Clarify and improve documentation
2025-12-31 10:01:48 +11:00
Dave Allie b82e044ac3 Cut release 0.11.2
Compile Release / build-release (push) Canceled after 0s
2025-12-31 09:28:14 +11:00
Dave Allie 026733a4fe Hide "System Volume Information" folder (#184)
## Summary

* Hide "System Volume Information" folder
* It's a FAT filesystem folder, shouldn't be used

## Additional Context

* Fixes https://github.com/daveallie/crosspoint-reader/issues/177
2025-12-31 09:27:17 +11:00
Dave Allie 57b075ec97 Update README.md features 2025-12-31 09:18:05 +11:00
dangson 648c688642 Update button hints on OTA update screen and update user guide to reflect current settings (#183)
## Summary

- Update the button hints on the OTA update screen to reflect the
current front button layout.
- Update user guide to reflect current available settings. A lot has
been added recently!
2025-12-31 09:15:40 +11:00
Jonas Diemer 06065dfd8b Show book title instead of "Select Chapter". (#169)
I think this is nicer ;)
2025-12-31 09:10:41 +11:00
Eunchurn Park 93226c9fbb Fix file browser navigation for non-ASCII folder names (#178)
## Summary

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

Fix file browser failing to navigate into subdirectories with non-ASCII
(Korean/Unicode) folder names.

* **What changes are included?**

- Enable UTF-8 long file names in SdFat (`USE_UTF8_LONG_NAMES=1`)
- Add directory validation before iterating files
- Add `rewindDirectory()` call for stability

## Additional Context
2025-12-31 09:08:31 +11:00
Dave Allie 941643cf97 Cut release 0.11.1
Compile Release / build-release (push) Canceled after 0s
2025-12-31 02:47:24 +11:00
Dave Allie 9bba41ed96 Move home screen battery indicator to avoid clashing with button hints (#174)
## Summary

* Move home screen battery indicator to avoid clashing with button hints
* Default button mapping was fine, but others clashes with the indicator
2025-12-31 02:46:46 +11:00
Dave Allie 34cf5f0636 Add button mapping for Left, Back, Confirm, Right (#173)
## Summary

* Add button mapping for Left, Back, Confirm, Right for front buttons

## Additional Context

* Asked for in
https://github.com/daveallie/crosspoint-reader/discussions/78#discussioncomment-15375326
2025-12-31 02:46:35 +11:00
Dave Allie f2ca65d752 Swap from Aleo to Bookerly for wider glyph support (#172)
## Summary

* Swap from Aleo to Bookerly for wider glyph support
* Swap from Space Grotesk to a small Noto Sans

## Additional Context

* 0.11.0 swapped to Aleo which has a few issues (things like Cyrillic
support for eg)
2025-12-31 02:28:25 +11:00
146 changed files with 99283 additions and 46766 deletions
+12 -3
View File
@@ -1,9 +1,18 @@
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for
file uploading.)
* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.)
* **What changes are included?**
## Additional Context
* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on).
* 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 >**_
+1 -1
View File
@@ -7,11 +7,11 @@ name: CI
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
+26
View File
@@ -0,0 +1,26 @@
name: "PR Formatting"
on:
pull_request_target:
types:
- opened
- reopened
- edited
permissions:
statuses: write
jobs:
title-check:
name: Title Check
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Check PR Title
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -1
View File
@@ -7,17 +7,18 @@ on:
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6
with:
python-version: '3.14'
+7 -5
View File
@@ -25,7 +25,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
## Features & Usage
- [x] EPUB parsing and rendering
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
- [ ] Image support within EPUB
- [x] Saved reading position
- [x] File explorer with file picker
@@ -33,11 +33,13 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [x] Support nested folders
- [ ] EPUB picker with cover art
- [x] Custom sleep screen
- [ ] Cover sleep screen
- [x] Cover sleep screen
- [x] Wifi book upload
- [ ] Wifi OTA updates
- [ ] Configurable font, layout, and display options
- [ ] Screen rotation
- [x] Wifi OTA updates
- [x] Configurable font, layout, and display options
- [ ] User provided fonts
- [ ] Full UTF support
- [x] Screen rotation
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
+77 -37
View File
@@ -1,17 +1,18 @@
# CrossPoint User Guide
Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of
the device.
Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of the device.
## 1. Hardware Overview
The device utilises the standard buttons on the Xtink X4 in the same layout:
The device utilises the standard buttons on the Xtink X4 (in the same layout as the manufacturer firmware, by default):
### Button Layout
| Location | Buttons |
|-----------------|--------------------------------------------|
| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** |
| **Right Side** | **Power**, **Volume Up**, **Volume Down** |
| Location | Buttons |
| --------------- | ---------------------------------------------------- |
| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** |
| **Right Side** | **Power**, **Volume Up**, **Volume Down**, **Reset** |
Button layout can be customized in **[Settings](#35-settings)**.
---
@@ -19,14 +20,17 @@ The device utilises the standard buttons on the Xtink X4 in the same layout:
### Power On / Off
To turn the device on or off, **press and hold the Power button for half a second**. In **Settings** you can configure
the power button to trigger on a short press instead of a long one.
To turn the device on or off, **press and hold the Power button for approximately half a second**.
In **[Settings](#35-settings)** you can configure the power button to turn the device off with a short press instead of a long one.
To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then quickly press and hold the Power button for a few seconds.
### First Launch
Upon turning the device on for the first time, you will be placed on the **Home** screen.
Upon turning the device on for the first time, you will be placed on the **[Home](#31-home-screen)** screen.
> **Note:** On subsequent restarts, the firmware will automatically reopen the last book you were reading.
> [!NOTE]
> On subsequent restarts, the firmware will automatically reopen the last book you were reading.
---
@@ -34,49 +38,83 @@ Upon turning the device on for the first time, you will be placed on the **Home*
### 3.1 Home Screen
The Home Screen is the main entry point to the firmware. From here you can navigate to the **Book Selection** screen,
**Settings** screen, or **File Upload** screen.
The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**, **[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen.
### 3.2 Book Selection (Read)
### 3.2 Book Selection
The Book Selection acts as a folder and file browser.
* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up
and down through folders and books.
* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down.
* **Open Selection:** Press **Confirm** to open a folder or read a selected book.
### 3.3 Reading Screen
### 3.3 Reading Mode
See [4. Reading Mode](#4-reading-mode) below for more information.
See [Reading Mode](#4-reading-mode) below for more information.
### 3.4 File Upload Screen
The File Upload screen allows you to upload new e-books to the device. When you enter the screen you'll be prompted with
a WiFi selection dialog and then your X4 will start hosting a web server.
The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with a WiFi selection dialog and then your X4 will start hosting a web server.
See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
> [!TIP]
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are:
- "Dark" (default) - The default dark sleep screen
- **Sleep Screen**: Which sleep screen to display when the device sleeps:
- "Dark" (default) - The default dark Crosspoint logo sleep screen
- "Light" - The same default sleep screen, on a white background
- "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information
- "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled,
paragraphs will not have vertical space between them, but will have first word indentation.
- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press.
- **Front Button Layout**: Swap the order of the bottom edge buttons from Back/Confirm/Left/Right to Left/Right/Back/Confirm.
- "None" - A blank screen
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
- "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected)
- **Status Bar**: Configure the status bar displayed while reading:
- "None" - No status bar
- "No Progress" - Show status bar without reading progress
- "Full" - Show status bar with reading progress
- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown:
- "Never" - Always show battery percentage (default)
- "In Reader" - Show battery percentage everywhere except in reading mode
- "Always" - Always hide battery percentage
- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book. If disabled, paragraphs will not have vertical space between them, but will have first-line indentation.
- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly.
- **Short Power Button Click**: Controls the effect of a short click of the power button:
- "Ignore" - Require a long press to turn off the device
- "Sleep" - A short press powers the device off
- "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off
- **Reading Orientation**: Set the screen orientation for reading EPUB files:
- "Portrait" (default) - Standard portrait orientation
- "Landscape CW" - Landscape, rotated clockwise
- "Inverted" - Portrait, upside down
- "Landscape CCW" - Landscape, rotated counter-clockwise
- **Front Button Layout**: Configure the order of the bottom edge buttons:
- Back, Confirm, Left, Right (default)
- Left, Right, Back, Confirm
- Left, Back, Confirm, Right
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
- **Reader Font Family**: Choose the font used for reading:
- "Bookerly" (default) - Amazon's reading font
- "Noto Sans" - Google's sans-serif font
- "Open Dyslexic" - Font designed for readers with dyslexia
- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large".
- **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide".
- **Reader Screen Margin**: Controls the screen margins in reader mode between 5 and 40 pixels in 5 pixel increments.
- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right".
- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep.
- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting.
- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device.
- **Check for updates**: Check for firmware updates over WiFi.
### 3.6 Sleep Screen
You can customize the sleep screen by placing custom images in specific locations on the SD card:
- **Single Image:** Place a file named `sleep.bmp` in the root directory.
- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images
inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be
randomly selected each time the device sleeps.
- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.bmp` file, and one will be randomly selected each time the device sleeps.
> [!NOTE]
> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images.
@@ -94,17 +132,22 @@ Once you have opened a book, the button layout changes to facilitate reading.
### Page Turning
| Action | Buttons |
|-------------------|--------------------------------------|
| ----------------- | ------------------------------------ |
| **Previous Page** | Press **Left** _or_ **Volume Up** |
| **Next Page** | Press **Right** _or_ **Volume Down** |
The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**.
If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button.
### Chapter Navigation
* **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release.
* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release.
### System Navigation
* **Return to Home:** Press **Back** to close the book and return to the Book Selection screen.
* **Chapter Menu:** Press **Confirm** to open the Table of Contents/Chapter Selection screen.
* **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen.
* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**.
---
@@ -120,9 +163,6 @@ Accessible by pressing **Confirm** while inside a book.
## 6. Current Limitations & Roadmap
Please note that this firmware is currently in active development. The following features are **not yet supported** but
are planned for future updates:
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
* **Images:** Embedded images in e-books will not render.
* **Text Formatting:** There are currently no settings to adjust font type, size, line spacing, or margins.
* **Rotation**: Different rotation options are not supported.
+214 -2
View File
@@ -2,8 +2,220 @@
## `book.bin`
![](./images/file-formats/book-bin.png)
### Version 3
ImHex Pattern:
```c++
import std.mem;
import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 3
#define MAX_STRING_LENGTH 65535
// === String Structure ===
struct String {
u32 length [[hidden, comment("String byte length")]];
if (length > MAX_STRING_LENGTH) {
std::warning(std::format("Unusually large string length: {} bytes", length));
}
char data[length] [[comment("UTF-8 string data")]];
} [[sealed, format("format_string"), comment("Length-prefixed UTF-8 string")]];
fn format_string(String s) {
return s.data;
};
// === Metadata Structure ===
struct Metadata {
String title [[comment("Book title")]];
String author [[comment("Book author")]];
String coverItemHref [[comment("Path to cover image")]];
String textReferenceHref [[comment("Path to guided first text reference")]];
} [[comment("Book metadata information")]];
// === Spine Entry Structure ===
struct SpineEntry {
String href [[comment("Resource path")]];
u32 cumulativeSize [[comment("Cumulative size in bytes"), color("FF6B6B")]];
s16 tocIndex [[comment("Index into TOC (-1 if none)"), color("4ECDC4")]];
} [[comment("Spine entry defining reading order")]];
// === TOC Entry Structure ===
struct TocEntry {
String title [[comment("Chapter/section title")]];
String href [[comment("Resource path")]];
String anchor [[comment("Fragment identifier")]];
u8 level [[comment("Nesting level (0-255)"), color("95E1D3")]];
s16 spineIndex [[comment("Index into spine (-1 if none)"), color("F38181")]];
} [[comment("Table of contents entry")]];
// === Book Bin Structure ===
struct BookBin {
// Header
u8 version [[comment("Format version"), color("FFD93D")]];
// Version validation
if (version != EXPECTED_VERSION) {
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
}
u32 lutOffset [[comment("Offset to lookup tables"), color("6BCB77")]];
u16 spineCount [[comment("Number of spine entries"), color("4D96FF")]];
u16 tocCount [[comment("Number of TOC entries"), color("FF6B9D")]];
// Metadata section
Metadata metadata [[comment("Book metadata")]];
// Validate LUT offset alignment
u32 currentOffset = $;
if (currentOffset != lutOffset) {
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
}
// Lookup Tables
u32 spineLut[spineCount] [[comment("Spine entry offsets"), color("4D96FF")]];
u32 tocLut[tocCount] [[comment("TOC entry offsets"), color("FF6B9D")]];
// Data Entries
SpineEntry spines[spineCount] [[comment("Spine entries (reading order)")]];
TocEntry toc[tocCount] [[comment("Table of contents entries")]];
};
// === File Parsing ===
BookBin book @ 0x00;
// Validate we've consumed the entire file
u32 fileSize = std::mem::size();
u32 parsedSize = $;
if (parsedSize != fileSize) {
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
}
```
## `section.bin`
![](./images/file-formats/section-bin.png)
### Version 8
ImHex Pattern:
```c++
import std.mem;
import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 8
#define MAX_STRING_LENGTH 65535
// === String Structure ===
struct String {
u32 length [[hidden, comment("String byte length")]];
if (length > MAX_STRING_LENGTH) {
std::warning(std::format("Unusually large string length: {} bytes", length));
}
char data[length] [[comment("UTF-8 string data")]];
} [[sealed, format("format_string"), comment("Length-prefixed UTF-8 string")]];
fn format_string(String s) {
return s.data;
};
// === Page Structure ===
enum StorageType : u8 {
PageLine = 1
};
enum WordStyle : u8 {
REGULAR = 0,
BOLD = 1,
ITALIC = 2,
BOLD_ITALIC = 3
};
enum BlockStyle : u8 {
JUSTIFIED = 0,
LEFT_ALIGN = 1,
CENTER_ALIGN = 2,
RIGHT_ALIGN = 3,
};
struct PageLine {
s16 xPos;
s16 yPos;
u16 wordCount;
String words[wordCount];
u16 wordXPos[wordCount];
WordStyle wordStyle[wordCount];
BlockStyle blockStyle;
};
struct PageElement {
u8 pageElementType;
if (pageElementType == 1) {
PageLine pageLine [[inline]];
} else {
std::error(std::format("Unknown page element type: {}", pageElementType));
}
};
struct Page {
u16 elementCount;
PageElement elements[elementCount] [[inline]];
};
// === Section Bin Structure ===
struct SectionBin {
// Header
u8 version [[comment("Format version"), color("FFD93D")]];
// Version validation
if (version != EXPECTED_VERSION) {
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
}
// Cache busting parameters
s32 fontId;
float lineCompression;
bool extraParagraphSpacing;
u16 viewportWidth;
u16 vieportHeight;
u16 pageCount;
u32 lutOffset;
Page page[pageCount];
// Validate LUT offset alignment
u32 currentOffset = $;
if (currentOffset != lutOffset) {
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
}
// Lookup Tables
u32 lut[pageCount];
};
// === File Parsing ===
SectionBin book @ 0x00;
// Validate we've consumed the entire file
u32 fileSize = std::mem::size();
u32 parsedSize = $;
if (parsedSize != fileSize) {
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
}
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

+34
View File
@@ -170,6 +170,40 @@ This is useful for organizing your ebooks by genre, author, or series.
---
## 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.
### Uploading a File
To upload a file to the root directory, use the following command:
```bash
curl -F "file=@book.epub" "http://crosspoint.local/upload?path=/"
```
* **`-F "file=@filename"`**: Points to the local file on your computer.
* **`path=/`**: The destination folder on the device SD card.
### Deleting a File
To delete a specific file, provide the full path on the SD card:
```bash
curl -F "path=/folder/file.epub" "http://crosspoint.local/delete"
```
### Advanced Flags
For more reliable transfers of large EPUB files, consider adding these flags:
* `-#`: Shows a simple progress bar.
* `--connect-timeout 30`: Limits how long curl waits to establish a connection (in seconds).
* `--max-time 300`: Sets a maximum duration for the entire transfer (5 minutes).
> [!NOTE]
> These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`).
---
## Troubleshooting
### Cannot See the Device on the Network
+5 -5
View File
@@ -1,6 +1,6 @@
#include "EpdFontFamily.h"
const EpdFont* EpdFontFamily::getFont(const EpdFontStyle style) const {
const EpdFont* EpdFontFamily::getFont(const Style style) const {
if (style == BOLD && bold) {
return bold;
}
@@ -22,16 +22,16 @@ const EpdFont* EpdFontFamily::getFont(const EpdFontStyle style) const {
return regular;
}
void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const EpdFontStyle style) const {
void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const Style style) const {
getFont(style)->getTextDimensions(string, w, h);
}
bool EpdFontFamily::hasPrintableChars(const char* string, const EpdFontStyle style) const {
bool EpdFontFamily::hasPrintableChars(const char* string, const Style style) const {
return getFont(style)->hasPrintableChars(string);
}
const EpdFontData* EpdFontFamily::getData(const EpdFontStyle style) const { return getFont(style)->data; }
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const EpdFontStyle style) const {
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
return getFont(style)->getGlyph(cp);
};
+14 -14
View File
@@ -1,24 +1,24 @@
#pragma once
#include "EpdFont.h"
enum EpdFontStyle { REGULAR, BOLD, ITALIC, BOLD_ITALIC };
class EpdFontFamily {
public:
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 };
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
: 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;
private:
const EpdFont* regular;
const EpdFont* bold;
const EpdFont* italic;
const EpdFont* boldItalic;
const EpdFont* getFont(EpdFontStyle style) const;
public:
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
void getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style = REGULAR) const;
bool hasPrintableChars(const char* string, EpdFontStyle style = REGULAR) const;
const EpdFontData* getData(EpdFontStyle style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, EpdFontStyle style = REGULAR) const;
const EpdFont* getFont(Style style) const;
};
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
+17 -17
View File
@@ -1,21 +1,22 @@
#pragma once
#include <builtinFonts/aleo_12_bold.h>
#include <builtinFonts/aleo_12_bolditalic.h>
#include <builtinFonts/aleo_12_italic.h>
#include <builtinFonts/aleo_12_regular.h>
#include <builtinFonts/aleo_14_bold.h>
#include <builtinFonts/aleo_14_bolditalic.h>
#include <builtinFonts/aleo_14_italic.h>
#include <builtinFonts/aleo_14_regular.h>
#include <builtinFonts/aleo_16_bold.h>
#include <builtinFonts/aleo_16_bolditalic.h>
#include <builtinFonts/aleo_16_italic.h>
#include <builtinFonts/aleo_16_regular.h>
#include <builtinFonts/aleo_18_bold.h>
#include <builtinFonts/aleo_18_bolditalic.h>
#include <builtinFonts/aleo_18_italic.h>
#include <builtinFonts/aleo_18_regular.h>
#include <builtinFonts/bookerly_12_bold.h>
#include <builtinFonts/bookerly_12_bolditalic.h>
#include <builtinFonts/bookerly_12_italic.h>
#include <builtinFonts/bookerly_12_regular.h>
#include <builtinFonts/bookerly_14_bold.h>
#include <builtinFonts/bookerly_14_bolditalic.h>
#include <builtinFonts/bookerly_14_italic.h>
#include <builtinFonts/bookerly_14_regular.h>
#include <builtinFonts/bookerly_16_bold.h>
#include <builtinFonts/bookerly_16_bolditalic.h>
#include <builtinFonts/bookerly_16_italic.h>
#include <builtinFonts/bookerly_16_regular.h>
#include <builtinFonts/bookerly_18_bold.h>
#include <builtinFonts/bookerly_18_bolditalic.h>
#include <builtinFonts/bookerly_18_italic.h>
#include <builtinFonts/bookerly_18_regular.h>
#include <builtinFonts/notosans_8_regular.h>
#include <builtinFonts/notosans_12_bold.h>
#include <builtinFonts/notosans_12_bolditalic.h>
#include <builtinFonts/notosans_12_italic.h>
@@ -48,7 +49,6 @@
#include <builtinFonts/opendyslexic_8_bolditalic.h>
#include <builtinFonts/opendyslexic_8_italic.h>
#include <builtinFonts/opendyslexic_8_regular.h>
#include <builtinFonts/spacegrotesk.h>
#include <builtinFonts/ubuntu_10_bold.h>
#include <builtinFonts/ubuntu_10_regular.h>
#include <builtinFonts/ubuntu_12_bold.h>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
Copyright 2018 The Aleo Project Authors (https://github.com/AlessioLaiso/aleo)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-826
View File
@@ -1,826 +0,0 @@
/**
* generated by fontconvert.py
* name: spacegrotesk
* size: 8
* mode: 1-bit
*/
#pragma once
#include "EpdFontData.h"
static const uint8_t spacegroteskBitmaps[5331] = {
0x66, 0x66, 0x66, 0x66, 0x60, 0x67, 0x20, 0x00, 0xF9, 0xF1, 0xA6, 0xC0, 0x00, 0x33, 0x0C, 0xC3,
0x31, 0xFF, 0xFF, 0xCC, 0xC3, 0x33, 0xFF, 0xFF, 0xCC, 0xC3, 0x30, 0xCC, 0x0C, 0x03, 0x03, 0xF1,
0xFE, 0x6D, 0x9B, 0x77, 0xC8, 0xF8, 0x1F, 0x93, 0x7E, 0xCD, 0xB3, 0x7F, 0x8F, 0xE0, 0xC0, 0x30,
0x78, 0x37, 0xE3, 0x33, 0x39, 0x9B, 0x87, 0xD8, 0x1D, 0x80, 0x19, 0x01, 0xFE, 0x1D, 0xB8, 0xCC,
0xCC, 0x66, 0xC3, 0xE0, 0x04, 0x00, 0x3E, 0x1F, 0x86, 0x03, 0x84, 0x63, 0x1F, 0xF7, 0xFD, 0x8C,
0xC3, 0x38, 0xC7, 0xF0, 0xFC, 0x06, 0x73, 0x60, 0x19, 0xEE, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C,
0x30, 0xC3, 0x0E, 0x1E, 0x3C, 0xC3, 0xC3, 0x86, 0x1C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x61,
0xBC, 0xE0, 0x08, 0x6A, 0x3E, 0xFF, 0x7F, 0x3E, 0x6A, 0x08, 0x18, 0x18, 0x18, 0xFF, 0xFF, 0x18,
0x18, 0x18, 0x06, 0x73, 0x60, 0xFF, 0xE0, 0x06, 0x72, 0x0C, 0x18, 0x30, 0xE1, 0x83, 0x06, 0x18,
0x30, 0x61, 0x83, 0x06, 0x18, 0x30, 0x40, 0x3E, 0x3F, 0xB0, 0xF8, 0x3C, 0x1E, 0x0F, 0x07, 0x83,
0xC1, 0xE0, 0xF9, 0xCF, 0xE1, 0x80, 0x1C, 0xF7, 0xDB, 0xEF, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x3F,
0x1F, 0xE6, 0x19, 0x87, 0x41, 0xC0, 0x60, 0xF8, 0xF8, 0x70, 0x18, 0x07, 0xF9, 0xFF, 0x7F, 0x9F,
0xF0, 0x38, 0x3C, 0x1C, 0x07, 0xC1, 0xF8, 0x06, 0xC1, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x07,
0x03, 0xC0, 0xF0, 0x6C, 0x3B, 0x0C, 0xC6, 0x33, 0x8C, 0xFF, 0xFF, 0xF0, 0x30, 0x0C, 0x7F, 0x9F,
0xE6, 0x01, 0x80, 0x7F, 0x1F, 0xE7, 0x18, 0x07, 0x61, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x7E,
0x7F, 0xB0, 0xD8, 0x3F, 0xE7, 0xFB, 0x0D, 0x83, 0xC1, 0xE1, 0xF9, 0xCF, 0xC1, 0x80, 0xFF, 0xFF,
0xC0, 0x60, 0x60, 0x30, 0x30, 0x38, 0x18, 0x1C, 0x0C, 0x0E, 0x06, 0x00, 0x3F, 0x1F, 0xE6, 0x1D,
0x83, 0x61, 0xCF, 0xE7, 0xF9, 0x87, 0xE0, 0xF8, 0x37, 0x3C, 0xFE, 0x0C, 0x00, 0x3F, 0x1F, 0xE6,
0x19, 0x87, 0xE1, 0xD8, 0x77, 0xFC, 0xFF, 0x41, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x06, 0x72,
0x00, 0x06, 0x72, 0x06, 0x72, 0x00, 0x06, 0x73, 0x60, 0x01, 0x0F, 0x3E, 0xF0, 0xF0, 0x7C, 0x1F,
0x03, 0xFF, 0xFF, 0x7F, 0xFF, 0x00, 0xC0, 0xF0, 0x7E, 0x0F, 0x07, 0x3E, 0xF8, 0xC0, 0x3F, 0x3F,
0xD8, 0x78, 0x34, 0x18, 0x3C, 0x3C, 0x18, 0x0C, 0x00, 0x07, 0x03, 0x80, 0x80, 0x0F, 0xE0, 0x3F,
0xF0, 0xE0, 0x73, 0x18, 0x6E, 0x7F, 0x79, 0xDE, 0xF3, 0x1D, 0xEE, 0x1B, 0xDC, 0x37, 0x98, 0xEF,
0x3B, 0xFB, 0x3F, 0xE7, 0x10, 0x07, 0x80, 0x07, 0xFC, 0x01, 0xF0, 0x0E, 0x03, 0xC0, 0x7C, 0x0D,
0x83, 0xB0, 0x66, 0x0C, 0x63, 0xFC, 0x7F, 0x8C, 0x39, 0x83, 0x60, 0x60, 0xFF, 0x9F, 0xF8, 0xC3,
0x18, 0x63, 0x0C, 0x7F, 0x8F, 0xE1, 0x86, 0x30, 0xC6, 0x19, 0xFF, 0x7F, 0xC0, 0x3E, 0x3F, 0xB8,
0x78, 0x3C, 0x1E, 0x03, 0x01, 0x83, 0xC1, 0xE0, 0xFD, 0xEF, 0xE1, 0xC0, 0xFF, 0x1F, 0xF0, 0xC7,
0x18, 0x63, 0x0E, 0x61, 0xCC, 0x39, 0x87, 0x30, 0xC6, 0x19, 0xFF, 0x7F, 0xC0, 0xFF, 0xFF, 0xC0,
0xC0, 0xC0, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE,
0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0x3E, 0x3F, 0xB8, 0x78, 0x3C, 0x1E, 0x03, 0x3F, 0x9F, 0xC1, 0xE0,
0xFD, 0xEF, 0xF1, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0,
0x78, 0x30, 0xFF, 0xFF, 0xFF, 0x0F, 0x83, 0xE0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x33, 0x0C, 0xC3,
0x38, 0xC7, 0x71, 0xF8, 0x18, 0x00, 0xC3, 0xE3, 0xB3, 0x9B, 0x8F, 0x87, 0x83, 0xC1, 0xF0, 0xDC,
0x67, 0x31, 0xD8, 0x70, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF,
0xE0, 0x77, 0x83, 0xBC, 0x3D, 0xE1, 0xEF, 0x8F, 0x6C, 0x7B, 0x66, 0xDB, 0x36, 0xCD, 0xB6, 0x7D,
0xB3, 0xCD, 0x9E, 0x60, 0xE1, 0xF8, 0xFC, 0x7F, 0x3D, 0x9E, 0xCF, 0x37, 0x9B, 0xCF, 0xE3, 0xF1,
0xF8, 0xF0, 0x3E, 0x1F, 0xEE, 0x1B, 0x06, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xB0, 0x6F, 0x79,
0xFC, 0x1C, 0x00, 0xFE, 0x7F, 0xB0, 0xF8, 0x3C, 0x1E, 0x1B, 0xFD, 0xF8, 0xC0, 0x60, 0x30, 0x18,
0x00, 0x3E, 0x1F, 0xEE, 0x1B, 0x06, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xB0, 0x6F, 0x79, 0xFC,
0x1C, 0x03, 0x80, 0xE0, 0xFE, 0x7F, 0xB0, 0x78, 0x3C, 0x1E, 0x1F, 0xFD, 0xFF, 0xC3, 0xE1, 0xF0,
0xF8, 0x70, 0x3F, 0x1F, 0xE6, 0x19, 0x87, 0x70, 0x8F, 0x81, 0xF9, 0x07, 0xE0, 0xD8, 0x37, 0x38,
0xFE, 0x0C, 0x00, 0xFF, 0xFF, 0xF0, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00,
0xC0, 0x30, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xFD, 0xEF, 0xE1,
0xC0, 0xC0, 0xF8, 0x36, 0x1D, 0x86, 0x61, 0x8C, 0x63, 0x30, 0xCC, 0x1B, 0x07, 0xC1, 0xE0, 0x78,
0xC3, 0x8F, 0x9E, 0x36, 0x78, 0xD9, 0xB3, 0x66, 0xCD, 0x9B, 0x76, 0xED, 0x9B, 0x36, 0x3C, 0xF8,
0xF1, 0xE3, 0xC7, 0x8F, 0x1E, 0xE0, 0xCC, 0x38, 0xC6, 0x1D, 0x81, 0xF0, 0x3C, 0x03, 0x80, 0xF8,
0x3B, 0x06, 0x31, 0xC7, 0x70, 0x60, 0xC0, 0xF8, 0x76, 0x18, 0xCE, 0x3B, 0x07, 0x81, 0xE0, 0x30,
0x0C, 0x03, 0x00, 0xC0, 0x30, 0x7F, 0xBF, 0xC0, 0xE0, 0x60, 0x60, 0x70, 0x70, 0x70, 0x30, 0x30,
0x3F, 0xFF, 0xF0, 0x77, 0xF1, 0x8C, 0x63, 0x18, 0xC6, 0x31, 0x8C, 0x63, 0x1E, 0xF8, 0x60, 0x60,
0x60, 0x30, 0x30, 0x30, 0x18, 0x18, 0x18, 0x0C, 0x0C, 0x0C, 0x06, 0x06, 0x06, 0x02, 0xF7, 0xC6,
0x31, 0x8C, 0x63, 0x18, 0xC6, 0x31, 0x8C, 0x7F, 0xF0, 0x1C, 0x1E, 0x1D, 0x8C, 0xEC, 0x30, 0xFF,
0x7F, 0x80, 0x00, 0x67, 0x30, 0x1C, 0x0F, 0xC6, 0x31, 0x8C, 0x3F, 0x1F, 0xCE, 0x31, 0x9E, 0x7F,
0xC6, 0x00, 0xC0, 0x60, 0x30, 0x1B, 0x8F, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xFC, 0xFF, 0xE0,
0xC0, 0x0E, 0x0F, 0xC7, 0x39, 0x86, 0x60, 0x18, 0x06, 0x19, 0xCE, 0x3F, 0x03, 0x00, 0x00, 0xC0,
0x30, 0x0C, 0x73, 0x3F, 0xDC, 0x76, 0x1D, 0x83, 0x60, 0xD8, 0x77, 0x3C, 0xFF, 0x0C, 0x00, 0x0E,
0x1F, 0x9C, 0xEC, 0x37, 0xFB, 0xFD, 0x86, 0xE7, 0x3F, 0x06, 0x00, 0x1E, 0x3C, 0x63, 0xFF, 0xE3,
0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x1C, 0x8F, 0xF7, 0x1D, 0x87, 0x60, 0xD8, 0x36, 0x1D, 0xEF,
0x3F, 0xC0, 0x30, 0x0C, 0xFF, 0x3F, 0x80, 0xC0, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xDB, 0x0D, 0xB6, 0xDB, 0x60, 0x31, 0x8C, 0x03, 0x18, 0xC6, 0x31, 0x8C, 0x63,
0x1B, 0xCC, 0xC0, 0xC0, 0xC0, 0xC2, 0xCE, 0xDC, 0xF8, 0xF0, 0xF0, 0xD8, 0xCE, 0xC7, 0xFF, 0xFF,
0xFF, 0x18, 0xC7, 0xFF, 0xB9, 0xCD, 0x8C, 0x6C, 0x63, 0x63, 0x1B, 0x18, 0xD8, 0xC6, 0xC6, 0x30,
0x1C, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0x0E, 0x0F, 0xC7, 0x39, 0x87, 0x60, 0xD8,
0x36, 0x19, 0xCE, 0x3F, 0x03, 0x00, 0x1C, 0x7F, 0xB8, 0xF8, 0x3C, 0x1E, 0x0F, 0x07, 0xE7, 0xFF,
0x66, 0x30, 0x18, 0x0C, 0x00, 0x1C, 0x8F, 0xF7, 0x1D, 0x87, 0x60, 0xD8, 0x36, 0x1D, 0xCF, 0x3F,
0xC3, 0x30, 0x0C, 0x03, 0x00, 0x80, 0x1F, 0xF9, 0x8C, 0x63, 0x18, 0xC0, 0x1C, 0x7E, 0x63, 0x62,
0x7E, 0x1F, 0x63, 0x63, 0x3F, 0x0C, 0x00, 0x30, 0x63, 0xFF, 0xE3, 0x06, 0x0C, 0x18, 0x30, 0x70,
0xF0, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x41, 0x71, 0xD8, 0xCC, 0x67,
0x71, 0xB0, 0xD8, 0x38, 0x1C, 0x00, 0x42, 0x13, 0x38, 0xD9, 0xEE, 0xCF, 0x66, 0xDB, 0x36, 0xD8,
0xF3, 0xC7, 0x9E, 0x38, 0xE0, 0x40, 0x98, 0x63, 0x30, 0xF8, 0x1E, 0x07, 0x83, 0xF1, 0xCC, 0x61,
0x80, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x13, 0x03, 0x7F, 0x7E, 0xFD, 0xFC,
0x38, 0xE3, 0x8E, 0x38, 0x7E, 0xFE, 0x1E, 0x3C, 0x60, 0xC1, 0x83, 0x06, 0x0C, 0xF1, 0xE0, 0x60,
0xC1, 0x83, 0x06, 0x0F, 0x1E, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xF1, 0xF0, 0x60, 0xC1, 0x83, 0x06,
0x0C, 0x1E, 0x3C, 0x60, 0xC1, 0x83, 0x06, 0x7C, 0xF0, 0x10, 0x7B, 0xFF, 0x4C, 0x67, 0x60, 0x66,
0x66, 0x66, 0x66, 0x60, 0x08, 0x0C, 0x07, 0x0F, 0xCF, 0xF6, 0xDB, 0x61, 0xB0, 0xDB, 0x7F, 0x9F,
0x83, 0x01, 0x80, 0x1F, 0x0F, 0xE3, 0x1C, 0xC3, 0x30, 0xDF, 0x07, 0xC0, 0xC0, 0x30, 0x0C, 0x07,
0xF9, 0xFF, 0x00, 0x1F, 0xF7, 0xF9, 0x86, 0x60, 0xD8, 0x36, 0x19, 0xCE, 0x7F, 0xD3, 0x20, 0xC0,
0xCC, 0x39, 0xC6, 0x19, 0xC3, 0xB0, 0x3C, 0x1F, 0xE1, 0xFC, 0x7F, 0x87, 0xF0, 0x30, 0x06, 0x00,
0xFF, 0xFF, 0x0F, 0xFF, 0xF0, 0x3E, 0x7E, 0x63, 0x62, 0x7C, 0x7E, 0x7B, 0x3F, 0x47, 0xC3, 0x67,
0x7E, 0x18, 0x6C, 0xD9, 0xB0, 0x3F, 0x1F, 0xEC, 0xDF, 0x7B, 0xF3, 0xFC, 0xBF, 0x0F, 0xCF, 0xFE,
0xF7, 0xB6, 0x39, 0xFC, 0x0C, 0x00, 0x78, 0xD8, 0xF3, 0xEC, 0xCF, 0xCC, 0x00, 0x0C, 0x63, 0xBD,
0xEE, 0x73, 0x87, 0x70, 0x77, 0x87, 0x38, 0x23, 0xFF, 0xFF, 0x03, 0x03, 0xFF, 0xE0, 0x3E, 0x7F,
0x5D, 0x5D, 0x55, 0x77, 0x3E, 0x00, 0x7C, 0xF8, 0x7B, 0xFC, 0xF3, 0x78, 0x40, 0x08, 0x18, 0x18,
0xFF, 0xFF, 0x18, 0x18, 0x18, 0x7F, 0xFF, 0x01, 0xEC, 0xC3, 0x7B, 0x8F, 0xC0, 0xFC, 0x73, 0x8F,
0xCD, 0xF3, 0x00, 0x36, 0x60, 0x00, 0x61, 0xB0, 0xD8, 0x6C, 0x36, 0x1B, 0x0D, 0xCE, 0xFF, 0xE8,
0x30, 0x18, 0x0C, 0x00, 0x3F, 0xBF, 0xF3, 0x79, 0xBC, 0xDB, 0xED, 0xF6, 0x1B, 0x0D, 0x86, 0xC3,
0x61, 0xB0, 0x1F, 0xA0, 0x21, 0x8E, 0xF7, 0x00, 0x77, 0xF3, 0x33, 0x3C, 0xFF, 0x1E, 0x3C, 0x6F,
0x8E, 0x00, 0xCC, 0x3B, 0xC7, 0xB8, 0x67, 0x39, 0xDD, 0xEE, 0xE2, 0x30, 0x70, 0x21, 0xC1, 0x8F,
0x0C, 0x0C, 0x60, 0x33, 0x80, 0xCC, 0x00, 0x67, 0x03, 0x1C, 0x18, 0xD0, 0xE7, 0xE3, 0x1F, 0xD8,
0x04, 0x00, 0x00, 0x70, 0x21, 0xC1, 0x8F, 0x0C, 0x0C, 0x60, 0x33, 0x80, 0xCC, 0x00, 0x6F, 0x83,
0x33, 0x18, 0x18, 0xE1, 0xE3, 0x0C, 0x18, 0x3F, 0x00, 0x00, 0xFC, 0x18, 0x38, 0x60, 0xE1, 0xC1,
0xE3, 0x0C, 0xCC, 0x0F, 0xB0, 0x0C, 0xC7, 0x03, 0x9E, 0x0E, 0x6C, 0x19, 0xFC, 0x63, 0xF9, 0x80,
0x60, 0x00, 0x00, 0x0C, 0x06, 0x03, 0x00, 0x00, 0xC0, 0xE0, 0xE0, 0xE0, 0xE1, 0xF0, 0xD8, 0x6F,
0xE1, 0xE0, 0x1C, 0x01, 0x80, 0x10, 0x07, 0x01, 0xE0, 0x3E, 0x06, 0xC1, 0xD8, 0x33, 0x06, 0x31,
0xFE, 0x3F, 0xC6, 0x1C, 0xC1, 0xB0, 0x30, 0x06, 0x01, 0xC0, 0x30, 0x07, 0x01, 0xE0, 0x3E, 0x06,
0xC1, 0xD8, 0x33, 0x06, 0x31, 0xFE, 0x3F, 0xC6, 0x1C, 0xC1, 0xB0, 0x30, 0x0E, 0x03, 0xE0, 0xCC,
0x07, 0x01, 0xE0, 0x3E, 0x06, 0xC1, 0xD8, 0x33, 0x06, 0x31, 0xFE, 0x3F, 0xC6, 0x1C, 0xC1, 0xB0,
0x30, 0x1D, 0x07, 0xE0, 0x18, 0x07, 0x01, 0xE0, 0x3E, 0x06, 0xC1, 0xD8, 0x33, 0x06, 0x31, 0xFE,
0x3F, 0xC6, 0x1C, 0xC1, 0xB0, 0x30, 0x1B, 0x07, 0x60, 0x4C, 0x07, 0x01, 0xE0, 0x3E, 0x06, 0xC1,
0xD8, 0x33, 0x06, 0x31, 0xFE, 0x3F, 0xC6, 0x1C, 0xC1, 0xB0, 0x30, 0x0E, 0x03, 0xC0, 0x78, 0x06,
0x00, 0xE0, 0x3C, 0x07, 0xC0, 0xD8, 0x3B, 0x06, 0x60, 0xC6, 0x3F, 0xC7, 0xF8, 0xC3, 0x98, 0x36,
0x06, 0x07, 0xF8, 0x3F, 0xE0, 0xF0, 0x07, 0xC0, 0x1B, 0x00, 0x6F, 0xE3, 0x3F, 0x8F, 0xC0, 0x7F,
0x01, 0x8C, 0x06, 0x3F, 0xB0, 0xFE, 0x3E, 0x3F, 0xB8, 0x78, 0x3C, 0x1E, 0x03, 0x01, 0x83, 0xC1,
0xE0, 0xFD, 0xEF, 0xE1, 0xC0, 0x70, 0x78, 0x38, 0x30, 0x18, 0x18, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0,
0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x0C, 0x18, 0x18, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE,
0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x18, 0x3E, 0x66, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE,
0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x66, 0x7E, 0x24, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE, 0xC0,
0xC0, 0xC0, 0xFF, 0xFF, 0xCD, 0xB6, 0xDB, 0x6D, 0xB6, 0xD8, 0x7B, 0x6D, 0xB6, 0xDB, 0x6D, 0xB0,
0x39, 0xFC, 0xCC, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x00, 0xCF, 0xF4, 0x8C, 0x30,
0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x00, 0xFF, 0x1F, 0xF0, 0xC7, 0x18, 0x63, 0x0E, 0xF9,
0xFF, 0x39, 0x87, 0x30, 0xC6, 0x19, 0xFF, 0x7F, 0xC0, 0x3A, 0x1F, 0x09, 0x1C, 0x3F, 0x1F, 0x8F,
0xE7, 0xB3, 0xD9, 0xE6, 0xF3, 0x79, 0xFC, 0x7E, 0x3F, 0x1E, 0x18, 0x06, 0x00, 0xC0, 0xF8, 0x7F,
0xB8, 0x6C, 0x1B, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70, 0x0C, 0x03,
0x00, 0x80, 0xF8, 0x7F, 0xB8, 0x6C, 0x1B, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7,
0xF0, 0x70, 0x1C, 0x0F, 0x83, 0x20, 0xF8, 0x7F, 0xB8, 0x6C, 0x1B, 0x07, 0xC1, 0xF0, 0x7C, 0x1F,
0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70, 0x3A, 0x0F, 0x82, 0x40, 0xF8, 0x7F, 0xB8, 0x6C, 0x1B, 0x07,
0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70, 0x36, 0x0D, 0xC3, 0x20, 0xF8, 0x7F,
0xB8, 0x6C, 0x1B, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70, 0x00, 0x67,
0x7E, 0x3C, 0x3C, 0x7E, 0x67, 0x42, 0x03, 0x0F, 0xC7, 0xFB, 0x9E, 0xC7, 0xB3, 0x7C, 0xDF, 0x27,
0xD9, 0xF6, 0x6F, 0x1B, 0xDE, 0x7F, 0x1F, 0x06, 0x00, 0x18, 0x0C, 0x02, 0x18, 0x3C, 0x1E, 0x0F,
0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x0C, 0x06, 0x02, 0x18, 0x3C,
0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x1C, 0x1F, 0x0C,
0x98, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x36,
0x1B, 0x8C, 0x98, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC,
0x38, 0x06, 0x03, 0x80, 0xC3, 0x03, 0xE1, 0xD8, 0x63, 0x38, 0xEC, 0x1E, 0x07, 0x80, 0xC0, 0x30,
0x0C, 0x03, 0x00, 0xC0, 0xC0, 0x60, 0x3F, 0x9F, 0xEC, 0x3E, 0x0F, 0x07, 0x87, 0xFF, 0x7F, 0x30,
0x18, 0x00, 0x3E, 0x3F, 0xB8, 0xD8, 0x6C, 0x66, 0x63, 0x39, 0x9E, 0xC3, 0xF8, 0xFE, 0x7B, 0xF0,
0x60, 0x18, 0x06, 0x00, 0xC0, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F, 0x38, 0xC6, 0x79, 0xFF,
0x18, 0x00, 0x06, 0x03, 0x00, 0xC0, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F, 0x38, 0xC6, 0x79,
0xFF, 0x18, 0x00, 0x1C, 0x0F, 0x83, 0x70, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F, 0x38, 0xC6,
0x79, 0xFF, 0x18, 0x00, 0x10, 0x0F, 0xC2, 0xE0, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F, 0x38,
0xC6, 0x79, 0xFF, 0x18, 0x00, 0x36, 0x0D, 0xC3, 0x60, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F,
0x38, 0xC6, 0x79, 0xFF, 0x18, 0x00, 0x08, 0x07, 0x81, 0x60, 0x70, 0x1C, 0x0F, 0xC6, 0x31, 0x8C,
0x3F, 0x1F, 0xCE, 0x31, 0x9E, 0x7F, 0xC6, 0x00, 0x1C, 0x70, 0x7F, 0xF9, 0x8E, 0x33, 0x1C, 0x33,
0xFF, 0xEF, 0xFF, 0xF8, 0xE1, 0x33, 0xE6, 0x7E, 0xFC, 0x30, 0x60, 0x0E, 0x0F, 0xC7, 0x39, 0x86,
0x60, 0x18, 0x06, 0x19, 0xCE, 0x3F, 0x03, 0x00, 0xE0, 0x78, 0x0C, 0x00, 0x18, 0x0E, 0x03, 0x01,
0xC3, 0xF3, 0x9D, 0x86, 0xFF, 0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0x06, 0x07, 0x03, 0x01, 0xC3,
0xF3, 0x9D, 0x86, 0xFF, 0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0x0C, 0x0F, 0x0C, 0xC1, 0xC3, 0xF3,
0x9D, 0x86, 0xFF, 0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0x12, 0x1F, 0x8C, 0xC1, 0xC3, 0xF3, 0x9D,
0x86, 0xFF, 0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0xDD, 0x86, 0xDB, 0x6D, 0xB0, 0x6E, 0xC0, 0xCC,
0xCC, 0xCC, 0xCC, 0x31, 0xEC, 0xC0, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x4B, 0xFC, 0xC0, 0x30,
0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x02, 0x07, 0x80, 0xE0, 0x3C, 0x0D, 0x8F, 0xE7, 0x39, 0x87, 0x60,
0xD8, 0x36, 0x19, 0xCE, 0x3F, 0x03, 0x00, 0x10, 0x3E, 0x6C, 0x1C, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0x18, 0x03, 0x00, 0xC0, 0x38, 0x3F, 0x1C, 0xE6, 0x1D, 0x83, 0x60, 0xD8, 0x67,
0x38, 0xFC, 0x0C, 0x00, 0x06, 0x01, 0x80, 0xC0, 0x38, 0x3F, 0x1C, 0xE6, 0x1D, 0x83, 0x60, 0xD8,
0x67, 0x38, 0xFC, 0x0C, 0x00, 0x0C, 0x07, 0x83, 0x30, 0x38, 0x3F, 0x1C, 0xE6, 0x1D, 0x83, 0x60,
0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x08, 0x07, 0xC3, 0x70, 0x38, 0x3F, 0x1C, 0xE6, 0x1D, 0x83,
0x60, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x12, 0x0F, 0xC1, 0xB0, 0x38, 0x3F, 0x1C, 0xE6, 0x1D,
0x83, 0x60, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x18, 0x1C, 0x18, 0xFF, 0xFF, 0x00, 0x1C, 0x18,
0x00, 0x03, 0xE3, 0xF1, 0xCE, 0x67, 0xDB, 0x36, 0xCD, 0xE6, 0x7B, 0x8F, 0xC2, 0xC0, 0x00, 0x30,
0x38, 0x18, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x0C, 0x1C, 0x18, 0x01,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x18, 0x3C, 0x66, 0x01, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x24, 0x7E, 0x66, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3,
0xE7, 0x7F, 0x10, 0x0C, 0x1C, 0x18, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x13,
0x03, 0x7F, 0x7E, 0xC0, 0x60, 0x30, 0x1B, 0x8F, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xFC, 0xDF,
0xEC, 0xC6, 0x03, 0x01, 0x80, 0x24, 0x7E, 0x76, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7,
0x7F, 0x13, 0x03, 0x7F, 0x7E, 0x3F, 0x03, 0xC0, 0x38, 0x0F, 0x01, 0xF0, 0x36, 0x0E, 0xC1, 0x98,
0x31, 0x8F, 0xF1, 0xFE, 0x30, 0xE6, 0x0D, 0x81, 0x80, 0x3F, 0x0F, 0x81, 0xC0, 0xFC, 0x63, 0x18,
0xC3, 0xF1, 0xFC, 0xE3, 0x19, 0xE7, 0xFC, 0x60, 0x33, 0x03, 0xE0, 0x38, 0x07, 0x01, 0xE0, 0x3E,
0x06, 0xC1, 0xD8, 0x33, 0x06, 0x31, 0xFE, 0x3F, 0xC6, 0x1C, 0xC1, 0xB0, 0x30, 0x32, 0x0F, 0x81,
0xE0, 0x70, 0x3F, 0x18, 0xC6, 0x30, 0xFC, 0x7F, 0x38, 0xC6, 0x79, 0xFF, 0x18, 0x00, 0x0E, 0x03,
0xC0, 0x7C, 0x0D, 0x83, 0xB0, 0x66, 0x0C, 0x63, 0xFC, 0x7F, 0x8C, 0x39, 0x83, 0x60, 0x60, 0x0C,
0x01, 0xC0, 0x38, 0x1C, 0x0F, 0xC6, 0x31, 0x8C, 0x3F, 0x1F, 0xCE, 0x31, 0x9E, 0x7F, 0xC6, 0x60,
0x18, 0x07, 0x0C, 0x06, 0x02, 0x07, 0xC7, 0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x60, 0x30, 0x78, 0x3C,
0x1F, 0xBD, 0xFC, 0x38, 0x06, 0x01, 0x80, 0xC0, 0x38, 0x3F, 0x1C, 0xE6, 0x19, 0x80, 0x60, 0x18,
0x67, 0x38, 0xFC, 0x0C, 0x00, 0x1C, 0x1F, 0x0C, 0x87, 0xC7, 0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x60,
0x30, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x0C, 0x07, 0x83, 0x30, 0x38, 0x3F, 0x1C, 0xE6, 0x19,
0x80, 0x60, 0x18, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x1C, 0x0E, 0x02, 0x07, 0xC7, 0xF7, 0x0F, 0x07,
0x83, 0xC0, 0x60, 0x30, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x0C, 0x03, 0x00, 0xC0, 0x38, 0x3F,
0x1C, 0xE6, 0x19, 0x80, 0x60, 0x18, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x36, 0x1F, 0x07, 0x07, 0xC7,
0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x60, 0x30, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x33, 0x07, 0xC0,
0xE0, 0x38, 0x3F, 0x1C, 0xE6, 0x19, 0x80, 0x60, 0x18, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x3B, 0x03,
0xC0, 0x30, 0x7F, 0x8F, 0xF8, 0x63, 0x8C, 0x31, 0x87, 0x30, 0xE6, 0x1C, 0xC3, 0x98, 0x63, 0x0C,
0xFF, 0xBF, 0xE0, 0x00, 0xF0, 0x07, 0x80, 0x3C, 0x39, 0xE3, 0xFC, 0x38, 0xE1, 0x87, 0x0C, 0x18,
0x60, 0xC3, 0x0E, 0x1C, 0xF0, 0x7F, 0x80, 0xC0, 0x00, 0xFF, 0x1F, 0xF0, 0xC7, 0x18, 0x63, 0x0E,
0xF9, 0xFF, 0x39, 0x87, 0x30, 0xC6, 0x19, 0xFF, 0x7F, 0xC0, 0x00, 0xC0, 0xFC, 0x1F, 0x8E, 0x63,
0xFC, 0xE3, 0x98, 0x73, 0x06, 0x60, 0xCC, 0x39, 0xCF, 0x1F, 0xE0, 0xC0, 0x7E, 0x3C, 0xFF, 0xFF,
0xC0, 0xC0, 0xC0, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x3F, 0x1F, 0x83, 0x87, 0xE7, 0x3B,
0x0D, 0xFE, 0xFF, 0x61, 0xB9, 0xCF, 0xC1, 0x80, 0x66, 0x7E, 0x1C, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0,
0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x33, 0x1F, 0x87, 0x81, 0xC3, 0xF3, 0x9D, 0x86, 0xFF,
0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0x18, 0x18, 0x18, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE,
0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0x0C, 0x06, 0x03, 0x01, 0xC3, 0xF3, 0x9D, 0x86, 0xFF, 0x7F, 0xB0,
0xDC, 0xE7, 0xE0, 0xC0, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF,
0x06, 0x07, 0x07, 0x0E, 0x1F, 0x9C, 0xEC, 0x37, 0xFB, 0xFD, 0x86, 0xE7, 0x3F, 0x07, 0x01, 0xC0,
0xE0, 0x66, 0x3C, 0x18, 0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xFE, 0xFE, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF,
0x33, 0x0F, 0x03, 0x81, 0xC3, 0xF3, 0x9D, 0x86, 0xFF, 0x7F, 0xB0, 0xDC, 0xE7, 0xE0, 0xC0, 0x1C,
0x1F, 0x0C, 0x87, 0xC7, 0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x67, 0xF3, 0xF8, 0x3C, 0x1F, 0xBD, 0xFE,
0x30, 0x0E, 0x07, 0xC1, 0xB0, 0x72, 0x3F, 0xDC, 0x76, 0x1D, 0x83, 0x60, 0xD8, 0x77, 0xBC, 0xFF,
0x00, 0xC0, 0x33, 0xFC, 0xFE, 0x32, 0x1F, 0x07, 0x07, 0xC7, 0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x67,
0xF3, 0xF8, 0x3C, 0x1F, 0xBD, 0xFE, 0x30, 0x19, 0x07, 0xC0, 0xE0, 0x72, 0x3F, 0xDC, 0x76, 0x1D,
0x83, 0x60, 0xD8, 0x77, 0xBC, 0xFF, 0x00, 0xC0, 0x33, 0xFC, 0xFE, 0x0C, 0x0E, 0x02, 0x07, 0xC7,
0xF7, 0x0F, 0x07, 0x83, 0xC0, 0x67, 0xF3, 0xF8, 0x3C, 0x1F, 0xBD, 0xFE, 0x30, 0x04, 0x03, 0x80,
0x40, 0x72, 0x3F, 0xDC, 0x76, 0x1D, 0x83, 0x60, 0xD8, 0x77, 0xBC, 0xFF, 0x00, 0xC0, 0x33, 0xFC,
0xFE, 0x3E, 0x3F, 0xB8, 0x78, 0x3C, 0x1E, 0x03, 0x3F, 0x9F, 0xC1, 0xE0, 0xFD, 0xEF, 0xF1, 0x80,
0xE0, 0x70, 0x18, 0x08, 0x00, 0x06, 0x03, 0x00, 0xE0, 0x38, 0x1C, 0x8F, 0xF7, 0x1D, 0x87, 0x60,
0xD8, 0x36, 0x1D, 0xEF, 0x3F, 0xC0, 0x30, 0x0C, 0xFF, 0x3F, 0x80, 0x1C, 0x1F, 0x0D, 0x98, 0x3C,
0x1E, 0x0F, 0x07, 0x83, 0xFF, 0xFF, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x06, 0x38, 0x1F, 0x0C, 0xC0,
0xC0, 0x30, 0x0C, 0x03, 0x70, 0xFE, 0x38, 0xCC, 0x33, 0x0C, 0xC3, 0x30, 0xCC, 0x33, 0x0C, 0x60,
0xCC, 0x1B, 0xFF, 0xFF, 0xF6, 0x0C, 0xFF, 0x9F, 0xF3, 0x06, 0x60, 0xCC, 0x19, 0x83, 0x30, 0x60,
0x60, 0x7E, 0x3F, 0x0D, 0xC7, 0xF3, 0x8D, 0x86, 0xC3, 0x61, 0xB0, 0xD8, 0x6C, 0x30, 0x6F, 0xF1,
0x8C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x00, 0x23, 0xFD, 0x80, 0x30, 0xC3, 0x0C,
0x30, 0xC3, 0x0C, 0xFD, 0xE3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0xFF, 0xF0,
0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x00, 0xCF, 0xF3, 0x8C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C,
0x30, 0xC3, 0x00, 0xCF, 0xF7, 0x80, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0xDB, 0x6D, 0xB6, 0xDB,
0x6D, 0xB8, 0xDB, 0x0D, 0xB6, 0xDB, 0x6D, 0xB8, 0xDB, 0x6D, 0xB6, 0xDB, 0x6D, 0xB0, 0x3F, 0xFF,
0xC0, 0xF9, 0xFF, 0x9F, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x20, 0xC0, 0x0C, 0x30, 0xC3, 0x0C,
0x39, 0xC1, 0xF8, 0x06, 0x00, 0xCD, 0x9F, 0x30, 0x2C, 0xD9, 0xB3, 0x66, 0xCD, 0x9B, 0x36, 0x60,
0xC1, 0x87, 0x0C, 0x07, 0x03, 0xE0, 0xCC, 0x3E, 0x0F, 0x80, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xCC,
0x33, 0x0C, 0xE3, 0x1D, 0xC7, 0xE0, 0x60, 0x31, 0xEC, 0xC0, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C,
0x30, 0xCF, 0x18, 0xC3, 0xE3, 0xB3, 0x9B, 0x8F, 0x87, 0x83, 0xC1, 0xF0, 0xDC, 0x67, 0x31, 0xD8,
0x70, 0x00, 0xC0, 0x70, 0x30, 0x18, 0x00, 0xC0, 0xC0, 0xC0, 0xC2, 0xCE, 0xDC, 0xF8, 0xF0, 0xF0,
0xD8, 0xCE, 0xC7, 0x00, 0x38, 0x38, 0x18, 0x10, 0x02, 0xCE, 0xDC, 0xF8, 0xF0, 0xF0, 0xD8, 0xCE,
0xC7, 0x60, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF,
0x7B, 0x6D, 0xB6, 0xDB, 0x6D, 0xB0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0,
0xFF, 0xFF, 0x00, 0x18, 0x18, 0x18, 0x18, 0xFF, 0xFF, 0xFF, 0x3F, 0xC0, 0xD8, 0xD8, 0xD0, 0xD0,
0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0xDE, 0xF5, 0xAC, 0x63, 0x18, 0xC6, 0x31, 0x80,
0xC0, 0xC0, 0xC0, 0xC0, 0xC4, 0xCE, 0xC4, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, 0xC3, 0x0C, 0x30, 0xC3,
0x0C, 0xB7, 0xCB, 0x0C, 0x30, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x38, 0x0F, 0x07, 0x03, 0xC0, 0x30,
0x0C, 0x03, 0xFC, 0xFF, 0x30, 0xC3, 0x0C, 0x30, 0xF3, 0xFC, 0xF0, 0xC3, 0x0C, 0x0C, 0x06, 0x02,
0x1C, 0x3F, 0x1F, 0x8F, 0xE7, 0xB3, 0xD9, 0xE6, 0xF3, 0x79, 0xFC, 0x7E, 0x3F, 0x1E, 0x0C, 0x0C,
0x18, 0x1C, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE1, 0xF8, 0xFC, 0x7F, 0x3D, 0x9E,
0xCF, 0x37, 0x9B, 0xCF, 0xE3, 0xF1, 0xF8, 0xF0, 0x00, 0xE0, 0x70, 0x18, 0x08, 0x00, 0x1C, 0xFE,
0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0x00, 0x18, 0x18, 0x18, 0x18, 0x36, 0x1F, 0x07, 0x1C,
0x3F, 0x1F, 0x8F, 0xE7, 0xB3, 0xD9, 0xE6, 0xF3, 0x79, 0xFC, 0x7E, 0x3F, 0x1E, 0x66, 0x3E, 0x1C,
0x1C, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC0, 0xE0, 0x60, 0xC0, 0x1C, 0xFE, 0xE3,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE1, 0xF8, 0xFC, 0x7F, 0x3D, 0x9E, 0xCF, 0x37, 0x9B, 0xCF,
0xE3, 0xF1, 0xF8, 0xF0, 0x18, 0x0C, 0x1E, 0x0E, 0x1C, 0xFE, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0x03, 0x03, 0x0F, 0x0E, 0x3E, 0x0F, 0x83, 0xE1, 0xFE, 0xE1, 0xB0, 0x6C, 0x1F, 0x07, 0xC1,
0xF0, 0x7C, 0x1B, 0x06, 0xF7, 0x9F, 0xC1, 0xC0, 0x3F, 0x0F, 0xC0, 0xE0, 0xFC, 0x73, 0x98, 0x76,
0x0D, 0x83, 0x61, 0x9C, 0xE3, 0xF0, 0x30, 0x32, 0x0F, 0x81, 0xC0, 0xF8, 0x7F, 0xB8, 0x6C, 0x1B,
0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70, 0x33, 0x07, 0xC1, 0xE0, 0x38,
0x3F, 0x1C, 0xE6, 0x1D, 0x83, 0x60, 0xD8, 0x67, 0x38, 0xFC, 0x0C, 0x00, 0x1F, 0x0F, 0x83, 0x40,
0xF8, 0x7F, 0xB8, 0x6C, 0x1B, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x06, 0xC1, 0xBD, 0xE7, 0xF0, 0x70,
0x0F, 0x06, 0xC1, 0xE0, 0x38, 0x3F, 0x1C, 0xE6, 0x1D, 0x83, 0x60, 0xD8, 0x67, 0x38, 0xFC, 0x0C,
0x00, 0x1F, 0xF3, 0xFF, 0xB9, 0x81, 0x8C, 0x0C, 0x60, 0x63, 0xFB, 0x1F, 0xD8, 0xC0, 0xC6, 0x07,
0x30, 0x1F, 0xFC, 0x7F, 0xF0, 0x0E, 0x1C, 0x1F, 0xBF, 0x1C, 0xF8, 0xCC, 0x38, 0x66, 0x0F, 0xF3,
0x07, 0xF9, 0x87, 0x0C, 0xE7, 0xCE, 0x3F, 0x7E, 0x06, 0x0C, 0x00, 0x0C, 0x0E, 0x06, 0x1F, 0xCF,
0xF6, 0x0F, 0x07, 0x83, 0xC3, 0xFF, 0xBF, 0xF8, 0x7C, 0x3E, 0x1F, 0x0E, 0x19, 0x8C, 0x3F, 0xF3,
0x18, 0xC6, 0x31, 0x80, 0xFE, 0x7F, 0xB0, 0x78, 0x3C, 0x1E, 0x1F, 0xFD, 0xFF, 0xC3, 0xE1, 0xF0,
0xF8, 0x70, 0x00, 0xC0, 0x70, 0x38, 0x18, 0x00, 0x1F, 0xF9, 0x8C, 0x63, 0x18, 0xC0, 0x31, 0x8C,
0x60, 0x66, 0x1E, 0x06, 0x1F, 0xCF, 0xF6, 0x0F, 0x07, 0x83, 0xC3, 0xFF, 0xBF, 0xF8, 0x7C, 0x3E,
0x1F, 0x0E, 0x6C, 0xF8, 0xE0, 0x67, 0xCE, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x00, 0x06, 0x03, 0x80,
0xC0, 0xFC, 0x7F, 0x98, 0x66, 0x1D, 0xC2, 0x3E, 0x07, 0xE4, 0x1F, 0x83, 0x60, 0xDC, 0xE3, 0xF8,
0x30, 0x0C, 0x0C, 0x18, 0x1C, 0x7E, 0x63, 0x62, 0x7E, 0x1F, 0x63, 0x63, 0x3F, 0x0C, 0x0E, 0x07,
0xC3, 0x30, 0xFC, 0x7F, 0x98, 0x66, 0x1D, 0xC2, 0x3E, 0x07, 0xE4, 0x1F, 0x83, 0x60, 0xDC, 0xE3,
0xF8, 0x30, 0x1C, 0x3E, 0x76, 0x1C, 0x7E, 0x63, 0x62, 0x7E, 0x1F, 0x63, 0x63, 0x3F, 0x0C, 0x3F,
0x1F, 0xE6, 0x19, 0x87, 0x70, 0x8F, 0x81, 0xF9, 0x07, 0xE0, 0xD8, 0x37, 0x38, 0xFE, 0x0E, 0x01,
0x81, 0xE0, 0x38, 0x1C, 0x7E, 0x63, 0x62, 0x7E, 0x1F, 0x63, 0x63, 0x3F, 0x0C, 0x0E, 0x1E, 0x1C,
0x33, 0x07, 0x80, 0xC0, 0xFC, 0x7F, 0x98, 0x66, 0x1D, 0xC2, 0x3E, 0x07, 0xE4, 0x1F, 0x83, 0x60,
0xDC, 0xE3, 0xF8, 0x30, 0x66, 0x3E, 0x1C, 0x1C, 0x7E, 0x63, 0x62, 0x7E, 0x1F, 0x63, 0x63, 0x3F,
0x0C, 0xFF, 0xFF, 0xF0, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30,
0x0C, 0x03, 0x81, 0xE0, 0x30, 0x00, 0x30, 0x63, 0xFF, 0xE3, 0x06, 0x0C, 0x18, 0x30, 0x70, 0xF1,
0x83, 0x8F, 0x0C, 0x33, 0x07, 0x80, 0xC3, 0xFF, 0xFF, 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00,
0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x06, 0x06, 0x1E, 0x1E, 0x7E, 0xFE, 0x18, 0x18, 0x18, 0x18,
0x18, 0x1C, 0x1E, 0xFF, 0xFF, 0xF0, 0xC0, 0x30, 0x0C, 0x0F, 0xC3, 0xF0, 0x30, 0x0C, 0x03, 0x00,
0xC0, 0x30, 0x00, 0x30, 0x63, 0xFF, 0xE3, 0x06, 0x7F, 0xFE, 0x30, 0x70, 0xF0, 0x3A, 0x1F, 0x09,
0x18, 0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x10,
0x7E, 0x6C, 0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x3E, 0x1F, 0x30, 0x78,
0x3C, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3F, 0x7B, 0xF8, 0x70, 0x7E, 0x7E, 0x01,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x32, 0x1F, 0x07, 0x18, 0x3C, 0x1E, 0x0F,
0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x66, 0x7E, 0x3C, 0x01, 0xC3,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x1C, 0x0F, 0x07, 0x03, 0x8C, 0x1E, 0x0F, 0x07,
0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0xDE, 0xFE, 0x1C, 0x00, 0x08, 0x3C, 0x3C, 0x3C,
0x01, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0x1F, 0x1B, 0x0D, 0x18, 0x3C, 0x1E,
0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xBD, 0xFC, 0x38, 0x16, 0x36, 0x3C, 0x01,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x10, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1E, 0x0F,
0x07, 0x83, 0xC1, 0xE0, 0xFD, 0xEF, 0xE1, 0xE0, 0x30, 0x18, 0x01, 0x61, 0xB0, 0xD8, 0x6C, 0x36,
0x1B, 0x0D, 0xCE, 0x7F, 0x09, 0x80, 0xE0, 0x70, 0x03, 0x80, 0x1F, 0x00, 0xCC, 0x30, 0xE3, 0xE7,
0x8D, 0x9E, 0x36, 0x6C, 0xD9, 0xB3, 0x66, 0xDD, 0xBB, 0x66, 0xCD, 0x8F, 0x3E, 0x3C, 0x78, 0xF1,
0xE3, 0xC7, 0x80, 0x07, 0x00, 0x7C, 0x03, 0x70, 0x84, 0x26, 0x71, 0xB3, 0xDD, 0x9E, 0xCD, 0xB6,
0x6D, 0xB1, 0xE7, 0x8F, 0x3C, 0x71, 0xC0, 0x0E, 0x07, 0xC3, 0x33, 0x03, 0xE1, 0xD8, 0x63, 0x38,
0xEC, 0x1E, 0x07, 0x80, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x18, 0x3C, 0x66, 0x01, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC3, 0xE7, 0x7F, 0x13, 0x03, 0x7F, 0x7E, 0x33, 0x0F, 0xC1, 0x23, 0x03, 0xE1,
0xD8, 0x63, 0x38, 0xEC, 0x1E, 0x07, 0x80, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x0E, 0x06, 0x02,
0x0F, 0xF7, 0xF8, 0x1C, 0x0C, 0x0C, 0x0E, 0x0E, 0x0E, 0x06, 0x06, 0x07, 0xFF, 0xFE, 0x18, 0x30,
0xC7, 0xEF, 0xE1, 0xC7, 0x1C, 0x71, 0xC3, 0xF7, 0xF0, 0x0C, 0x0E, 0x02, 0x0F, 0xF7, 0xF8, 0x1C,
0x0C, 0x0C, 0x0E, 0x0E, 0x0E, 0x06, 0x06, 0x07, 0xFF, 0xFE, 0x30, 0x70, 0xC7, 0xEF, 0xE1, 0xC7,
0x1C, 0x71, 0xC3, 0xF7, 0xF0, 0x37, 0x0F, 0x03, 0x0F, 0xF7, 0xF8, 0x1C, 0x0C, 0x0C, 0x0E, 0x0E,
0x0E, 0x06, 0x06, 0x07, 0xFF, 0xFE, 0xCC, 0xF8, 0xE7, 0xEF, 0xE1, 0xC7, 0x1C, 0x71, 0xC3, 0xF7,
0xF0, 0x3C, 0xF3, 0x1C, 0xF0, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x67, 0x30, 0x36, 0x60, 0x38, 0x79,
0xB8, 0x20, 0xF9, 0x70, 0x7C, 0xF8, 0x4D, 0xF7, 0x80, 0x6D, 0x80, 0x6C, 0xD9, 0xB0, 0x73, 0xCE,
0x60, 0x23, 0x96, 0xE0, 0x6D, 0xB7, 0x80, 0x66, 0xF8, 0xE0, 0x79, 0xB3, 0xC0, 0x39, 0xF4, 0xC0,
0x69, 0xB0, 0x66, 0x60, 0x6D, 0x00, 0x6C, 0xD9, 0x00, 0x6D, 0xA0, 0x21, 0x8E, 0xF7, 0x00, 0x44,
0x66, 0x4D, 0xF3, 0x80, 0x7C, 0xF8, 0x7C, 0xFC, 0x7F, 0xF7, 0xFF, 0x00, 0x03, 0x06, 0x06, 0x0C,
0x18, 0x18, 0x30, 0x30, 0x60, 0x40, 0x00, 0x03, 0x06, 0x06, 0x06, 0x0C, 0x0C, 0x08, 0x18, 0x18,
0x30, 0x30, 0x20, 0x60, 0x60, 0x40, 0xFF, 0xE0, 0xFF, 0x7F, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xC0, 0xFF, 0xFF, 0xFF, 0xC0, 0x06, 0x46, 0x60, 0x06, 0x73, 0x60, 0x06, 0x73, 0x60, 0x00, 0xD9,
0x63, 0xE7, 0xC0, 0x00, 0x00, 0xF9, 0xF1, 0xA6, 0xC0, 0x00, 0x00, 0xF9, 0xF1, 0xA6, 0xC0, 0x00,
0x18, 0x18, 0x18, 0x7E, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0xFE,
0xFF, 0x18, 0x18, 0xFE, 0xFF, 0x18, 0x18, 0x18, 0x6F, 0xFF, 0xF0, 0x00, 0x0D, 0xDD, 0xFB, 0x92,
0x20, 0x78, 0x30, 0xFC, 0x60, 0xCC, 0xE0, 0xCD, 0xC0, 0x7D, 0x80, 0x3B, 0x00, 0x06, 0x44, 0x0F,
0xFF, 0x1D, 0xBB, 0x19, 0x33, 0x31, 0xBB, 0x61, 0xFF, 0x00, 0x44, 0x6D, 0xA4, 0x6D, 0xE7, 0x96,
0x58, 0x06, 0x3D, 0xE7, 0x07, 0x07, 0x83, 0x83, 0xC3, 0xC7, 0x87, 0x1D, 0xEE, 0x20, 0x00, 0x60,
0x18, 0x07, 0x01, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x38, 0x06, 0x01, 0x80, 0x60, 0x00, 0x00,
0x70, 0x6E, 0x1D, 0xC3, 0x00, 0xC0, 0x30, 0x0E, 0x03, 0x80, 0x60, 0x18, 0x06, 0x19, 0x87, 0xB0,
0xF0, 0x04, 0x0F, 0x05, 0x0F, 0x8F, 0xEF, 0xFE, 0xFF, 0x7F, 0xA8, 0xFC, 0x7E, 0xFF, 0x7F, 0xBF,
0xFB, 0xF9, 0xF0, 0xF0, 0x3F, 0x8F, 0xE3, 0x00, 0xC0, 0x30, 0x0F, 0xE3, 0xF8, 0xC0, 0x7C, 0x3F,
0x83, 0x00, 0xC0, 0x1F, 0x0F, 0xE3, 0x1C, 0xC3, 0x30, 0xDF, 0x03, 0x01, 0xF0, 0x30, 0x0C, 0x07,
0xF9, 0xFF, 0x38, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x33, 0x63,
0x3E, 0x31, 0xE3, 0x1E, 0x3F, 0x83, 0xFC, 0x30, 0xCF, 0xFF, 0xFF, 0xF3, 0x1C, 0x3F, 0xC3, 0xF0,
0x30, 0x03, 0x00, 0x30, 0x03, 0x00, 0x63, 0xC6, 0x63, 0xC6, 0x63, 0xCE, 0x63, 0xCC, 0xFF, 0xFE,
0xFF, 0xFE, 0xFF, 0xFE, 0xFF, 0xFE, 0x36, 0x6C, 0x3C, 0x78, 0x3C, 0x78, 0x1C, 0x38, 0x00, 0xC0,
0xFC, 0x1F, 0x8E, 0x63, 0xFC, 0xE3, 0x98, 0x73, 0x06, 0x60, 0xCC, 0x39, 0xCF, 0x1F, 0xE0, 0xC0,
0xFF, 0x80, 0x1F, 0x87, 0xF8, 0xC3, 0x98, 0x3F, 0xC7, 0xFC, 0x3F, 0x07, 0xF3, 0x70, 0x66, 0x1C,
0xF7, 0x0F, 0xE0, 0x60, 0x30, 0xC6, 0x38, 0xCE, 0x1B, 0x83, 0xE1, 0xFF, 0x3F, 0xE1, 0xF0, 0x37,
0x06, 0x70, 0xC7, 0x18, 0x70, 0x3F, 0x83, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0x3F, 0xC3,
0xF0, 0x30, 0x03, 0x00, 0x30, 0x03, 0x00, 0x08, 0x06, 0x0F, 0x8F, 0xEE, 0xDE, 0x6F, 0x37, 0x98,
0xCF, 0xE7, 0xF3, 0x79, 0xBF, 0xFB, 0xFC, 0x70, 0x18, 0x08, 0x06, 0x0F, 0x8F, 0xEE, 0xDE, 0x6F,
0x37, 0x98, 0xCC, 0x66, 0xF3, 0x79, 0xBF, 0xFB, 0xF8, 0x70, 0x18, 0xFF, 0xFF, 0xE0, 0x63, 0xFF,
0xFF, 0x81, 0x8F, 0xE3, 0xF8, 0x06, 0x01, 0x80, 0x60, 0x18, 0x30, 0x18, 0x0F, 0x0F, 0x8F, 0x01,
0xE3, 0xE7, 0x63, 0x31, 0x99, 0x8F, 0xC7, 0xC0, 0x0C, 0x03, 0x03, 0xF1, 0xFE, 0xED, 0xB3, 0x7C,
0xCF, 0x33, 0xCC, 0xF3, 0x3C, 0xCF, 0x33, 0xC0, 0xF0, 0x30, 0x3F, 0x87, 0xF8, 0xC3, 0x18, 0x73,
0x0C, 0xFF, 0xBF, 0xE1, 0x80, 0xFF, 0x06, 0x00, 0xC0, 0x18, 0x00, 0x1E, 0x07, 0x8F, 0xF3, 0xFE,
0x30, 0xCC, 0x33, 0x0C, 0xFE, 0x3F, 0x8C, 0x73, 0x0C, 0xC3, 0xFF, 0xFF, 0xE1, 0xE0, 0x78, 0x08,
0x00, 0xE0, 0x0E, 0x00, 0xE0, 0x0F, 0xFF, 0xBF, 0xFC, 0xC0, 0x03, 0x00, 0x0C, 0x00, 0x00, 0x00,
0x0C, 0x07, 0x83, 0xF1, 0xF6, 0x6C, 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x00,
0x80, 0x06, 0x00, 0x18, 0x00, 0x6F, 0xFF, 0xBF, 0xFC, 0x01, 0xC0, 0x1C, 0x00, 0xC0, 0x00, 0x00,
0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC1, 0x32, 0x6D, 0xCF, 0xE1, 0xF0, 0x38, 0x00,
0x00, 0x08, 0x80, 0xE6, 0x0E, 0x18, 0xE0, 0x6F, 0xFF, 0xBF, 0xFC, 0xC1, 0xC3, 0x1C, 0x0C, 0xC0,
0x00, 0x00, 0x0C, 0x07, 0x83, 0xF1, 0xF6, 0x6C, 0xC3, 0x00, 0xC1, 0x32, 0x6D, 0xCF, 0xE1, 0xF0,
0x38, 0x00, 0x00, 0x7C, 0x7F, 0x38, 0x1E, 0x0F, 0x86, 0xE3, 0x38, 0x0E, 0x03, 0x80, 0x00, 0x1F,
0x1F, 0xC0, 0xE0, 0xF0, 0xF8, 0xEC, 0xE6, 0xE0, 0xE0, 0x00, 0x00, 0x00, 0x70, 0x1C, 0x27, 0x31,
0xD8, 0x7C, 0x1E, 0x3F, 0x3F, 0x80, 0x00, 0x01, 0xE1, 0xD9, 0xCD, 0xC7, 0xC3, 0xC1, 0xF8, 0xFE,
0x00, 0x38, 0x1E, 0x03, 0x87, 0xE7, 0xF7, 0x3B, 0x0F, 0x83, 0xC1, 0xE1, 0xB9, 0xCF, 0xC1, 0x80,
0x01, 0x03, 0xC3, 0xF9, 0xDE, 0x66, 0xDB, 0xB6, 0xCD, 0xB3, 0x7B, 0x8F, 0xC3, 0xC0, 0xC0, 0x0E,
0x07, 0x81, 0xE0, 0x6C, 0x33, 0x0C, 0xC3, 0x39, 0xC6, 0x61, 0x98, 0x67, 0xFF, 0xFF, 0xFF, 0x7F,
0xB0, 0xD8, 0x6C, 0x36, 0x1B, 0x0D, 0x86, 0xC3, 0x61, 0xB0, 0xD8, 0x6C, 0x36, 0x1B, 0x0D, 0x82,
0x7F, 0xDF, 0xF6, 0x00, 0xC0, 0x38, 0x07, 0x00, 0xC0, 0x18, 0x0E, 0x03, 0x01, 0x80, 0xE0, 0x70,
0x18, 0x07, 0xFD, 0xFF, 0xFF, 0xFF, 0x06, 0x0C, 0x30, 0x60, 0xC3, 0x06, 0x0C, 0x30, 0x60, 0xC3,
0x06, 0x0C, 0x30, 0x60, 0xC0, 0x77, 0xBF, 0xE0, 0x00, 0x00, 0xC0, 0x30, 0x0C, 0x06, 0x01, 0x80,
0x60, 0x18, 0x0C, 0x73, 0x3C, 0xC3, 0x30, 0x78, 0x1E, 0x07, 0x81, 0xE0, 0x13, 0x1F, 0xE6, 0xCD,
0xB3, 0x7F, 0x8E, 0xE0, 0x1C, 0xF3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xCF, 0x38,
0x3B, 0x7F, 0xCE, 0x3B, 0x7F, 0xCE, 0x00, 0x0C, 0x0C, 0xFF, 0xFF, 0x7F, 0xFF, 0x30, 0x30, 0x01,
0x0F, 0x3E, 0xF0, 0xF0, 0x7C, 0x1F, 0x03, 0x7F, 0xFF, 0xC0, 0xF0, 0x7E, 0x0F, 0x07, 0x3E, 0xF8,
0xC0, 0x7F, 0xFF,
};
static const EpdGlyph spacegroteskGlyphs[] = {
{ 0, 0, 10, 0, 0, 0, 0 }, //
{ 0, 0, 4, 0, 0, 0, 0 }, //
{ 4, 13, 4, 0, 12, 7, 0 }, // !
{ 7, 6, 7, 0, 13, 6, 7 }, // "
{ 10, 12, 10, 0, 12, 15, 13 }, // #
{ 10, 16, 10, 0, 14, 20, 28 }, // $
{ 13, 13, 13, 0, 12, 22, 48 }, // %
{ 10, 12, 10, 0, 12, 15, 70 }, // &
{ 4, 6, 4, 0, 13, 3, 85 }, // '
{ 6, 17, 6, 1, 13, 13, 88 }, // (
{ 6, 17, 6, 0, 13, 13, 101 }, // )
{ 8, 8, 9, 0, 12, 8, 114 }, // *
{ 8, 8, 10, 1, 10, 8, 122 }, // +
{ 4, 6, 4, 0, 3, 3, 130 }, // ,
{ 6, 2, 8, 1, 5, 2, 133 }, // -
{ 4, 4, 4, 0, 3, 2, 135 }, // .
{ 7, 16, 6, 0, 12, 14, 137 }, // /
{ 9, 13, 11, 1, 12, 15, 151 }, // 0
{ 6, 12, 7, 0, 12, 9, 166 }, // 1
{ 10, 12, 10, 0, 12, 15, 175 }, // 2
{ 10, 13, 10, 0, 12, 17, 190 }, // 3
{ 10, 12, 10, 0, 12, 15, 207 }, // 4
{ 10, 13, 10, 0, 12, 17, 222 }, // 5
{ 9, 13, 10, 1, 12, 15, 239 }, // 6
{ 9, 12, 9, 0, 12, 14, 254 }, // 7
{ 10, 13, 10, 0, 12, 17, 268 }, // 8
{ 10, 13, 10, 0, 12, 17, 285 }, // 9
{ 4, 10, 4, 0, 9, 5, 302 }, // :
{ 4, 12, 4, 0, 9, 6, 307 }, // ;
{ 8, 8, 10, 1, 10, 8, 313 }, // <
{ 8, 5, 10, 1, 8, 5, 321 }, // =
{ 8, 8, 10, 1, 10, 8, 326 }, // >
{ 9, 13, 10, 0, 12, 15, 334 }, // ?
{ 15, 16, 17, 1, 12, 30, 349 }, // @
{ 11, 12, 11, 0, 12, 17, 379 }, // A
{ 11, 12, 11, 0, 12, 17, 396 }, // B
{ 9, 13, 11, 1, 12, 15, 413 }, // C
{ 11, 12, 11, 0, 12, 17, 428 }, // D
{ 8, 12, 9, 1, 12, 12, 445 }, // E
{ 8, 12, 9, 1, 12, 12, 457 }, // F
{ 9, 13, 11, 1, 12, 15, 469 }, // G
{ 9, 12, 11, 1, 12, 14, 484 }, // H
{ 2, 12, 4, 1, 12, 3, 498 }, // I
{ 10, 13, 10, 0, 12, 17, 501 }, // J
{ 9, 12, 10, 1, 12, 14, 518 }, // K
{ 8, 12, 9, 1, 12, 12, 532 }, // L
{ 13, 12, 14, 1, 12, 20, 544 }, // M
{ 9, 12, 11, 1, 12, 14, 564 }, // N
{ 10, 13, 11, 1, 12, 17, 578 }, // O
{ 9, 12, 10, 1, 12, 14, 595 }, // P
{ 10, 15, 11, 1, 12, 19, 609 }, // Q
{ 9, 12, 11, 1, 12, 14, 628 }, // R
{ 10, 13, 10, 0, 12, 17, 642 }, // S
{ 10, 12, 10, 0, 12, 15, 659 }, // T
{ 9, 13, 11, 1, 12, 15, 674 }, // U
{ 10, 12, 10, 0, 12, 15, 689 }, // V
{ 14, 12, 15, 0, 12, 21, 704 }, // W
{ 11, 12, 11, 0, 12, 17, 725 }, // X
{ 10, 12, 10, 0, 12, 15, 742 }, // Y
{ 9, 12, 10, 0, 12, 14, 757 }, // Z
{ 5, 17, 6, 1, 13, 11, 771 }, // [
{ 8, 16, 6, -1, 12, 16, 782 }, // <backslash>
{ 5, 17, 6, 0, 13, 11, 798 }, // ]
{ 9, 5, 10, 1, 12, 6, 809 }, // ^
{ 9, 3, 10, 1, 0, 4, 815 }, // _
{ 4, 3, 5, 0, 12, 2, 819 }, // `
{ 10, 10, 10, 0, 9, 13, 821 }, // a
{ 9, 13, 11, 1, 12, 15, 834 }, // b
{ 10, 10, 10, 0, 9, 13, 849 }, // c
{ 10, 13, 11, 0, 12, 17, 862 }, // d
{ 9, 10, 10, 0, 9, 12, 879 }, // e
{ 7, 12, 7, 0, 12, 11, 891 }, // f
{ 10, 13, 11, 0, 9, 17, 902 }, // g
{ 8, 12, 10, 1, 12, 12, 919 }, // h
{ 3, 12, 4, 1, 12, 5, 931 }, // i
{ 5, 16, 4, -1, 12, 10, 936 }, // j
{ 8, 12, 9, 1, 12, 12, 946 }, // k
{ 2, 12, 4, 1, 12, 3, 958 }, // l
{ 13, 9, 14, 1, 9, 15, 961 }, // m
{ 8, 9, 10, 1, 9, 9, 976 }, // n
{ 10, 10, 10, 0, 9, 13, 985 }, // o
{ 9, 13, 11, 1, 9, 15, 998 }, // p
{ 10, 13, 11, 0, 9, 17, 1013 }, // q
{ 5, 9, 6, 1, 9, 6, 1030 }, // r
{ 8, 10, 9, 0, 9, 10, 1036 }, // s
{ 7, 12, 8, 0, 12, 11, 1046 }, // t
{ 8, 10, 10, 1, 9, 10, 1057 }, // u
{ 9, 9, 9, 0, 9, 11, 1067 }, // v
{ 13, 9, 13, 0, 9, 15, 1078 }, // w
{ 10, 9, 10, 0, 9, 12, 1093 }, // x
{ 8, 13, 10, 1, 9, 13, 1105 }, // y
{ 7, 9, 9, 1, 9, 8, 1118 }, // z
{ 7, 17, 7, 0, 13, 15, 1126 }, // {
{ 2, 19, 4, 1, 14, 5, 1141 }, // |
{ 7, 17, 7, 0, 13, 15, 1146 }, // }
{ 8, 4, 10, 1, 8, 4, 1161 }, // ~
{ 0, 0, 4, 0, 0, 0, 1165 }, //  
{ 4, 13, 4, 0, 9, 7, 1165 }, // ¡
{ 9, 13, 10, 1, 11, 15, 1172 }, // ¢
{ 10, 12, 10, 0, 12, 15, 1187 }, // £
{ 10, 10, 10, 0, 11, 13, 1202 }, // ¤
{ 11, 12, 10, 0, 12, 17, 1215 }, // ¥
{ 2, 19, 4, 1, 14, 5, 1232 }, // ¦
{ 8, 13, 8, 0, 12, 13, 1237 }, // §
{ 7, 3, 7, 0, 12, 3, 1250 }, // ¨
{ 10, 13, 12, 1, 12, 17, 1253 }, // ©
{ 7, 7, 7, 0, 12, 7, 1270 }, // ª
{ 11, 8, 11, 0, 8, 11, 1277 }, // «
{ 8, 4, 10, 1, 7, 4, 1288 }, // ¬
{ 6, 2, 8, 1, 5, 2, 1292 }, // ­
{ 8, 8, 9, 0, 12, 8, 1294 }, // ®
{ 7, 2, 7, 0, 11, 2, 1302 }, // ¯
{ 6, 6, 6, 0, 12, 5, 1304 }, // °
{ 8, 10, 10, 1, 10, 10, 1309 }, // ±
{ 6, 7, 6, 0, 13, 6, 1319 }, // ²
{ 6, 7, 6, 0, 12, 6, 1325 }, // ³
{ 4, 3, 5, 0, 12, 2, 1331 }, // ´
{ 9, 13, 10, 1, 9, 15, 1333 }, // µ
{ 9, 12, 10, 0, 12, 14, 1348 }, // ¶
{ 3, 4, 3, 0, 7, 2, 1362 }, // ·
{ 5, 5, 5, 0, 1, 4, 1364 }, // ¸
{ 4, 6, 4, 0, 12, 3, 1368 }, // ¹
{ 7, 7, 7, 0, 12, 7, 1371 }, // º
{ 10, 8, 11, 1, 8, 10, 1378 }, // »
{ 14, 13, 14, 0, 12, 23, 1388 }, // ¼
{ 14, 13, 14, 0, 12, 23, 1411 }, // ½
{ 15, 13, 15, 0, 12, 25, 1434 }, // ¾
{ 9, 13, 10, 0, 9, 15, 1459 }, // ¿
{ 11, 15, 11, 0, 15, 21, 1474 }, // À
{ 11, 15, 11, 0, 15, 21, 1495 }, // Á
{ 11, 15, 11, 0, 15, 21, 1516 }, // Â
{ 11, 15, 11, 0, 15, 21, 1537 }, // Ã
{ 11, 15, 11, 0, 15, 21, 1558 }, // Ä
{ 11, 16, 11, 0, 16, 22, 1579 }, // Å
{ 14, 12, 14, 0, 12, 21, 1601 }, // Æ
{ 9, 16, 11, 1, 12, 18, 1622 }, // Ç
{ 8, 15, 9, 1, 15, 15, 1640 }, // È
{ 8, 15, 9, 1, 15, 15, 1655 }, // É
{ 8, 15, 9, 1, 15, 15, 1670 }, // Ê
{ 8, 15, 9, 1, 15, 15, 1685 }, // Ë
{ 3, 15, 4, 0, 15, 6, 1700 }, // Ì
{ 3, 15, 4, 1, 15, 6, 1706 }, // Í
{ 6, 15, 4, -1, 15, 12, 1712 }, // Î
{ 6, 15, 4, -1, 15, 12, 1724 }, // Ï
{ 11, 12, 11, 0, 12, 17, 1736 }, // Ð
{ 9, 15, 11, 1, 15, 17, 1753 }, // Ñ
{ 10, 16, 11, 1, 15, 20, 1770 }, // Ò
{ 10, 16, 11, 1, 15, 20, 1790 }, // Ó
{ 10, 16, 11, 1, 15, 20, 1810 }, // Ô
{ 10, 16, 11, 1, 15, 20, 1830 }, // Õ
{ 10, 16, 11, 1, 15, 20, 1850 }, // Ö
{ 8, 8, 10, 1, 10, 8, 1870 }, // ×
{ 10, 15, 11, 1, 13, 19, 1878 }, // Ø
{ 9, 16, 11, 1, 15, 18, 1897 }, // Ù
{ 9, 16, 11, 1, 15, 18, 1915 }, // Ú
{ 9, 16, 11, 1, 15, 18, 1933 }, // Û
{ 9, 16, 11, 1, 15, 18, 1951 }, // Ü
{ 10, 15, 10, 0, 15, 19, 1969 }, // Ý
{ 9, 12, 10, 1, 12, 14, 1988 }, // Þ
{ 9, 13, 10, 1, 12, 15, 2002 }, // ß
{ 10, 13, 10, 0, 12, 17, 2017 }, // à
{ 10, 13, 10, 0, 12, 17, 2034 }, // á
{ 10, 13, 10, 0, 12, 17, 2051 }, // â
{ 10, 13, 10, 0, 12, 17, 2068 }, // ã
{ 10, 13, 10, 0, 12, 17, 2085 }, // ä
{ 10, 14, 10, 0, 13, 18, 2102 }, // å
{ 15, 10, 15, 0, 9, 19, 2120 }, // æ
{ 10, 13, 10, 0, 9, 17, 2139 }, // ç
{ 9, 13, 10, 0, 12, 15, 2156 }, // è
{ 9, 13, 10, 0, 12, 15, 2171 }, // é
{ 9, 13, 10, 0, 12, 15, 2186 }, // ê
{ 9, 13, 10, 0, 12, 15, 2201 }, // ë
{ 3, 12, 4, 0, 12, 5, 2216 }, // ì
{ 4, 12, 4, 1, 12, 6, 2221 }, // í
{ 6, 12, 4, -1, 12, 9, 2227 }, // î
{ 6, 12, 4, -1, 12, 9, 2236 }, // ï
{ 10, 14, 10, 0, 13, 18, 2245 }, // ð
{ 8, 12, 10, 1, 12, 12, 2263 }, // ñ
{ 10, 13, 10, 0, 12, 17, 2275 }, // ò
{ 10, 13, 10, 0, 12, 17, 2292 }, // ó
{ 10, 13, 10, 0, 12, 17, 2309 }, // ô
{ 10, 13, 10, 0, 12, 17, 2326 }, // õ
{ 10, 13, 10, 0, 12, 17, 2343 }, // ö
{ 8, 8, 10, 1, 10, 8, 2360 }, // ÷
{ 10, 12, 10, 0, 10, 15, 2368 }, // ø
{ 8, 13, 10, 1, 12, 13, 2383 }, // ù
{ 8, 13, 10, 1, 12, 13, 2396 }, // ú
{ 8, 13, 10, 1, 12, 13, 2409 }, // û
{ 8, 13, 10, 1, 12, 13, 2422 }, // ü
{ 8, 16, 10, 1, 12, 16, 2435 }, // ý
{ 9, 16, 11, 1, 12, 18, 2451 }, // þ
{ 8, 16, 10, 1, 12, 16, 2469 }, // ÿ
{ 11, 14, 11, 0, 14, 20, 2485 }, // Ā
{ 10, 12, 10, 0, 11, 15, 2505 }, // ā
{ 11, 15, 11, 0, 15, 21, 2520 }, // Ă
{ 10, 13, 10, 0, 12, 17, 2541 }, // ă
{ 11, 15, 11, 0, 12, 21, 2558 }, // Ą
{ 10, 12, 10, 0, 9, 15, 2579 }, // ą
{ 9, 16, 11, 1, 15, 18, 2594 }, // Ć
{ 10, 13, 10, 0, 12, 17, 2612 }, // ć
{ 9, 16, 11, 1, 15, 18, 2629 }, // Ĉ
{ 10, 13, 10, 0, 12, 17, 2647 }, // ĉ
{ 9, 16, 11, 1, 15, 18, 2664 }, // Ċ
{ 10, 13, 10, 0, 12, 17, 2682 }, // ċ
{ 9, 16, 11, 1, 15, 18, 2699 }, // Č
{ 10, 13, 10, 0, 12, 17, 2717 }, // č
{ 11, 15, 11, 0, 15, 21, 2734 }, // Ď
{ 13, 13, 12, 0, 12, 22, 2755 }, // ď
{ 11, 12, 11, 0, 12, 17, 2777 }, // Đ
{ 11, 13, 11, 0, 12, 18, 2794 }, // đ
{ 8, 14, 9, 1, 14, 14, 2812 }, // Ē
{ 9, 12, 10, 0, 11, 14, 2826 }, // ē
{ 8, 15, 9, 1, 15, 15, 2840 }, // Ĕ
{ 9, 13, 10, 0, 12, 15, 2855 }, // ĕ
{ 8, 15, 9, 1, 15, 15, 2870 }, // Ė
{ 9, 13, 10, 0, 12, 15, 2885 }, // ė
{ 8, 15, 9, 1, 12, 15, 2900 }, // Ę
{ 9, 12, 10, 0, 9, 14, 2915 }, // ę
{ 8, 15, 9, 1, 15, 15, 2929 }, // Ě
{ 9, 13, 10, 0, 12, 15, 2944 }, // ě
{ 9, 16, 11, 1, 15, 18, 2959 }, // Ĝ
{ 10, 16, 11, 0, 12, 20, 2977 }, // ĝ
{ 9, 16, 11, 1, 15, 18, 2997 }, // Ğ
{ 10, 16, 11, 0, 12, 20, 3015 }, // ğ
{ 9, 16, 11, 1, 15, 18, 3035 }, // Ġ
{ 10, 16, 11, 0, 12, 20, 3053 }, // ġ
{ 9, 17, 11, 1, 12, 20, 3073 }, // Ģ
{ 10, 17, 11, 0, 13, 22, 3093 }, // ģ
{ 9, 15, 11, 1, 15, 17, 3115 }, // Ĥ
{ 10, 15, 10, -1, 15, 19, 3132 }, // ĥ
{ 11, 12, 11, 0, 12, 17, 3151 }, // Ħ
{ 9, 12, 10, 0, 12, 14, 3168 }, // ħ
{ 6, 15, 4, -1, 15, 12, 3182 }, // Ĩ
{ 6, 12, 4, -1, 12, 9, 3194 }, // ĩ
{ 6, 14, 4, -1, 14, 11, 3203 }, // Ī
{ 6, 11, 4, -1, 11, 9, 3214 }, // ī
{ 6, 15, 4, -1, 15, 12, 3223 }, // Ĭ
{ 6, 12, 4, -1, 12, 9, 3235 }, // ĭ
{ 3, 15, 4, 1, 12, 6, 3244 }, // Į
{ 3, 15, 4, 1, 12, 6, 3250 }, // į
{ 3, 15, 4, 1, 15, 6, 3256 }, // İ
{ 2, 9, 4, 1, 9, 3, 3262 }, // ı
{ 12, 13, 12, 0, 12, 20, 3265 }, // IJ
{ 7, 16, 8, 1, 12, 14, 3285 }, // ij
{ 10, 16, 10, 0, 15, 20, 3299 }, // Ĵ
{ 6, 16, 4, -1, 12, 12, 3319 }, // ĵ
{ 9, 17, 10, 1, 12, 20, 3331 }, // Ķ
{ 8, 17, 9, 1, 12, 17, 3351 }, // ķ
{ 8, 9, 9, 1, 9, 9, 3368 }, // ĸ
{ 8, 15, 9, 1, 15, 15, 3377 }, // Ĺ
{ 3, 15, 4, 1, 15, 6, 3392 }, // ĺ
{ 8, 17, 9, 1, 12, 17, 3398 }, // Ļ
{ 2, 17, 4, 1, 12, 5, 3415 }, // ļ
{ 8, 12, 9, 1, 12, 12, 3420 }, // Ľ
{ 5, 12, 5, 1, 12, 8, 3432 }, // ľ
{ 8, 12, 9, 1, 12, 12, 3440 }, // Ŀ
{ 6, 12, 7, 1, 12, 9, 3452 }, // ŀ
{ 10, 12, 10, 0, 12, 15, 3461 }, // Ł
{ 6, 12, 6, 0, 12, 9, 3476 }, // ł
{ 9, 15, 11, 1, 15, 17, 3485 }, // Ń
{ 8, 12, 10, 1, 12, 12, 3502 }, // ń
{ 9, 17, 11, 1, 12, 20, 3514 }, // Ņ
{ 8, 14, 10, 1, 9, 14, 3534 }, // ņ
{ 9, 15, 11, 1, 15, 17, 3548 }, // Ň
{ 8, 12, 10, 1, 12, 12, 3565 }, // ň
{ 8, 13, 10, 1, 13, 13, 3577 }, // ʼn
{ 9, 16, 11, 1, 12, 18, 3590 }, // Ŋ
{ 8, 13, 10, 1, 9, 13, 3608 }, // ŋ
{ 10, 15, 11, 1, 14, 19, 3621 }, // Ō
{ 10, 12, 10, 0, 11, 15, 3640 }, // ō
{ 10, 16, 11, 1, 15, 20, 3655 }, // Ŏ
{ 10, 13, 10, 0, 12, 17, 3675 }, // ŏ
{ 10, 16, 11, 1, 15, 20, 3692 }, // Ő
{ 10, 13, 10, 0, 12, 17, 3712 }, // ő
{ 13, 12, 14, 1, 12, 20, 3729 }, // Œ
{ 17, 10, 17, 0, 9, 22, 3749 }, // œ
{ 9, 15, 11, 1, 15, 17, 3771 }, // Ŕ
{ 5, 12, 6, 1, 12, 8, 3788 }, // ŕ
{ 9, 17, 11, 1, 12, 20, 3796 }, // Ŗ
{ 5, 14, 6, 1, 9, 9, 3816 }, // ŗ
{ 9, 15, 11, 1, 15, 17, 3825 }, // Ř
{ 7, 12, 6, 0, 12, 11, 3842 }, // ř
{ 10, 16, 10, 0, 15, 20, 3853 }, // Ś
{ 8, 13, 9, 0, 12, 13, 3873 }, // ś
{ 10, 16, 10, 0, 15, 20, 3886 }, // Ŝ
{ 8, 13, 9, 0, 12, 13, 3906 }, // ŝ
{ 10, 16, 10, 0, 12, 20, 3919 }, // Ş
{ 8, 13, 9, 0, 9, 13, 3939 }, // ş
{ 10, 16, 10, 0, 15, 20, 3952 }, // Š
{ 8, 13, 9, 0, 12, 13, 3972 }, // š
{ 10, 16, 10, 0, 12, 20, 3985 }, // Ţ
{ 7, 16, 8, 0, 12, 14, 4005 }, // ţ
{ 10, 15, 10, 0, 15, 19, 4019 }, // Ť
{ 8, 13, 8, 0, 13, 13, 4038 }, // ť
{ 10, 12, 10, 0, 12, 15, 4051 }, // Ŧ
{ 7, 12, 8, 0, 12, 11, 4066 }, // ŧ
{ 9, 16, 11, 1, 15, 18, 4077 }, // Ũ
{ 8, 13, 10, 1, 12, 13, 4095 }, // ũ
{ 9, 15, 11, 1, 14, 17, 4108 }, // Ū
{ 8, 12, 10, 1, 11, 12, 4125 }, // ū
{ 9, 16, 11, 1, 15, 18, 4137 }, // Ŭ
{ 8, 13, 10, 1, 12, 13, 4155 }, // ŭ
{ 9, 17, 11, 1, 16, 20, 4168 }, // Ů
{ 8, 14, 10, 1, 13, 14, 4188 }, // ů
{ 9, 16, 11, 1, 15, 18, 4202 }, // Ű
{ 8, 13, 10, 1, 12, 13, 4220 }, // ű
{ 9, 15, 11, 1, 12, 17, 4233 }, // Ų
{ 9, 12, 10, 1, 9, 14, 4250 }, // ų
{ 14, 15, 15, 0, 15, 27, 4264 }, // Ŵ
{ 13, 12, 13, 0, 12, 20, 4291 }, // ŵ
{ 10, 15, 10, 0, 15, 19, 4311 }, // Ŷ
{ 8, 16, 10, 1, 12, 16, 4330 }, // ŷ
{ 10, 15, 10, 0, 15, 19, 4346 }, // Ÿ
{ 9, 15, 10, 0, 15, 17, 4365 }, // Ź
{ 7, 12, 9, 1, 12, 11, 4382 }, // ź
{ 9, 15, 10, 0, 15, 17, 4393 }, // Ż
{ 7, 12, 9, 1, 12, 11, 4410 }, // ż
{ 9, 15, 10, 0, 15, 17, 4421 }, // Ž
{ 7, 12, 9, 1, 12, 11, 4438 }, // ž
{ 6, 12, 6, 0, 12, 9, 4449 }, // ſ
{ 4, 3, 0, 0, 12, 2, 4458 }, // ̀
{ 4, 3, 0, 0, 12, 2, 4460 }, // ́
{ 7, 3, 0, 0, 12, 3, 4462 }, // ̂
{ 7, 3, 0, 0, 12, 3, 4465 }, // ̃
{ 7, 2, 0, 0, 11, 2, 4468 }, // ̄
{ 6, 3, 0, 0, 12, 3, 4470 }, // ̆
{ 3, 3, 0, 0, 12, 2, 4473 }, // ̇
{ 7, 3, 0, 0, 12, 3, 4475 }, // ̈
{ 5, 4, 0, 0, 13, 3, 4478 }, // ̉
{ 5, 4, 0, 0, 13, 3, 4481 }, // ̊
{ 6, 3, 0, 0, 12, 3, 4484 }, // ̋
{ 7, 3, 0, 0, 12, 3, 4487 }, // ̌
{ 6, 3, 0, 0, 12, 3, 4490 }, // ̏
{ 6, 3, 0, 0, 12, 3, 4493 }, // ̑
{ 3, 4, 0, 0, 13, 2, 4496 }, // ̒
{ 4, 3, 0, 0, 10, 2, 4498 }, // ̛
{ 3, 3, 0, 0, -1, 2, 4500 }, // ̣
{ 7, 3, 0, 0, -1, 3, 4502 }, // ̤
{ 3, 4, 0, 0, -1, 2, 4505 }, // ̦
{ 5, 5, 0, 0, 1, 4, 4507 }, // ̧
{ 4, 4, 0, 0, 1, 2, 4511 }, // ̨
{ 6, 3, 0, 0, -1, 3, 4513 }, // ̮
{ 7, 2, 0, 0, -1, 2, 4516 }, // ̱
{ 7, 2, 0, 0, 11, 2, 4518 }, // ̵
{ 12, 2, 0, 0, 10, 3, 4520 }, // ̶
{ 8, 12, 0, 0, 10, 12, 4523 }, // ̷
{ 8, 15, 0, 0, 13, 15, 4535 }, // ̸
{ 0, 0, 10, 0, 0, 0, 4550 }, //
{ 0, 0, 4, 0, 0, 0, 4550 }, //
{ 0, 0, 3, 0, 0, 0, 4550 }, //
{ 0, 0, 1, 0, 0, 0, 4550 }, //
{ 0, 0, 0, 0, 0, 0, 4550 }, //
{ 6, 2, 8, 1, 5, 2, 4550 }, //
{ 9, 2, 10, 1, 5, 3, 4552 }, //
{ 8, 2, 10, 1, 5, 2, 4555 }, //
{ 13, 2, 15, 1, 5, 4, 4557 }, // —
{ 13, 2, 15, 1, 5, 4, 4561 }, // ―
{ 4, 6, 4, 0, 13, 3, 4565 }, //
{ 4, 6, 4, 0, 13, 3, 4568 }, //
{ 4, 6, 4, 0, 3, 3, 4571 }, //
{ 7, 6, 7, 0, 13, 6, 4574 }, // “
{ 7, 6, 7, 0, 13, 6, 4580 }, // ”
{ 7, 6, 7, 0, 3, 6, 4586 }, // „
{ 8, 12, 10, 1, 12, 12, 4592 }, // †
{ 8, 12, 10, 1, 12, 12, 4604 }, // ‡
{ 4, 5, 6, 1, 7, 3, 4616 }, // •
{ 11, 4, 11, 0, 3, 6, 4619 }, // …
{ 16, 13, 16, 0, 12, 26, 4625 }, // ‰
{ 3, 5, 3, 0, 12, 2, 4651 }, //
{ 6, 5, 6, 0, 12, 4, 4653 }, // ″
{ 7, 8, 8, 0, 8, 7, 4657 }, //
{ 6, 8, 8, 1, 8, 6, 4664 }, //
{ 11, 13, 11, 0, 12, 18, 4670 }, //
{ 11, 13, 12, 0, 12, 18, 4688 }, // ⁒
{ 9, 16, 11, 1, 14, 18, 4706 }, // ₡
{ 10, 12, 10, 0, 12, 15, 4724 }, // ₣
{ 10, 12, 10, 0, 12, 15, 4739 }, // ₤
{ 12, 12, 13, 0, 12, 18, 4754 }, // ₦
{ 12, 12, 12, 0, 12, 18, 4772 }, // ₧
{ 16, 12, 16, 0, 12, 24, 4790 }, // ₩
{ 11, 14, 12, 0, 12, 20, 4814 }, // ₫
{ 11, 13, 11, 0, 12, 18, 4834 }, // €
{ 11, 12, 11, 0, 12, 17, 4852 }, // ₭
{ 12, 12, 12, 0, 12, 18, 4869 }, // ₱
{ 9, 16, 11, 1, 14, 18, 4887 }, // ₲
{ 9, 16, 11, 1, 14, 18, 4905 }, // ₵
{ 10, 12, 11, 1, 12, 15, 4923 }, // ₹
{ 9, 12, 9, 0, 12, 14, 4938 }, // ₺
{ 10, 14, 12, 1, 14, 18, 4952 }, // ₼
{ 11, 12, 11, 0, 12, 17, 4970 }, // ₽
{ 10, 16, 12, 1, 14, 20, 4987 }, // ₿
{ 13, 10, 13, 0, 9, 17, 5007 }, // ←
{ 10, 12, 10, 0, 12, 15, 5024 }, // ↑
{ 13, 10, 13, 0, 9, 17, 5039 }, // →
{ 10, 13, 10, 0, 12, 17, 5056 }, // ↓
{ 13, 10, 13, 0, 9, 17, 5073 }, // ↔
{ 10, 13, 10, 0, 12, 17, 5090 }, // ↕
{ 9, 10, 10, 1, 9, 12, 5107 }, // ↖
{ 9, 10, 10, 0, 9, 12, 5119 }, // ↗
{ 9, 9, 10, 0, 9, 11, 5131 }, // ↘
{ 9, 9, 10, 1, 9, 11, 5142 }, // ↙
{ 9, 13, 10, 1, 12, 15, 5153 }, // ∂
{ 10, 12, 11, 0, 10, 15, 5168 }, // ∅
{ 10, 12, 10, 0, 12, 15, 5183 }, // ∆
{ 9, 16, 10, 1, 12, 18, 5198 }, // ∏
{ 10, 16, 10, 0, 12, 20, 5216 }, // ∑
{ 8, 2, 10, 1, 7, 2, 5236 }, //
{ 7, 17, 6, 0, 14, 15, 5238 }, //
{ 5, 5, 10, 3, 8, 4, 5253 }, // ∙
{ 10, 15, 10, 0, 15, 19, 5257 }, // √
{ 10, 6, 10, 0, 9, 8, 5276 }, // ∞
{ 6, 16, 10, 2, 12, 12, 5284 }, // ∫
{ 8, 7, 10, 1, 9, 7, 5296 }, // ≈
{ 8, 8, 10, 1, 10, 8, 5303 }, // ≠
{ 8, 10, 10, 1, 10, 10, 5311 }, // ≤
{ 8, 10, 10, 1, 10, 10, 5321 }, // ≥
};
static const EpdUnicodeInterval spacegroteskIntervals[] = {
{ 0xD, 0xD, 0x0 },
{ 0x20, 0x7E, 0x1 },
{ 0xA0, 0xFF, 0x60 },
{ 0x100, 0x17F, 0xC0 },
{ 0x300, 0x304, 0x140 },
{ 0x306, 0x30C, 0x145 },
{ 0x30F, 0x30F, 0x14C },
{ 0x311, 0x312, 0x14D },
{ 0x31B, 0x31B, 0x14F },
{ 0x323, 0x324, 0x150 },
{ 0x326, 0x328, 0x152 },
{ 0x32E, 0x32E, 0x155 },
{ 0x331, 0x331, 0x156 },
{ 0x335, 0x338, 0x157 },
{ 0x2007, 0x200B, 0x15B },
{ 0x2010, 0x2010, 0x160 },
{ 0x2012, 0x2015, 0x161 },
{ 0x2018, 0x201A, 0x165 },
{ 0x201C, 0x201E, 0x168 },
{ 0x2020, 0x2022, 0x16B },
{ 0x2026, 0x2026, 0x16E },
{ 0x2030, 0x2030, 0x16F },
{ 0x2032, 0x2033, 0x170 },
{ 0x2039, 0x203A, 0x172 },
{ 0x2044, 0x2044, 0x174 },
{ 0x2052, 0x2052, 0x175 },
{ 0x20A1, 0x20A1, 0x176 },
{ 0x20A3, 0x20A4, 0x177 },
{ 0x20A6, 0x20A7, 0x179 },
{ 0x20A9, 0x20A9, 0x17B },
{ 0x20AB, 0x20AD, 0x17C },
{ 0x20B1, 0x20B2, 0x17F },
{ 0x20B5, 0x20B5, 0x181 },
{ 0x20B9, 0x20BA, 0x182 },
{ 0x20BC, 0x20BD, 0x184 },
{ 0x20BF, 0x20BF, 0x186 },
{ 0x2190, 0x2199, 0x187 },
{ 0x2202, 0x2202, 0x191 },
{ 0x2205, 0x2206, 0x192 },
{ 0x220F, 0x220F, 0x194 },
{ 0x2211, 0x2212, 0x195 },
{ 0x2215, 0x2215, 0x197 },
{ 0x2219, 0x221A, 0x198 },
{ 0x221E, 0x221E, 0x19A },
{ 0x222B, 0x222B, 0x19B },
{ 0x2248, 0x2248, 0x19C },
{ 0x2260, 0x2260, 0x19D },
{ 0x2264, 0x2265, 0x19E },
};
static const EpdFontData spacegrotesk = {
spacegroteskBitmaps,
spacegroteskGlyphs,
spacegroteskIntervals,
48,
21,
17,
-5,
false,
};
+21 -21
View File
@@ -8,39 +8,39 @@ echo "// The contents of this file are generated by ./lib/EpdFont/scripts/build-
echo "#pragma once"
echo ""
echo "#define ALEO_12_FONT_ID ($(
echo "#define BOOKERLY_12_FONT_ID ($(
ruby -rdigest -e 'puts [
"./aleo_12_regular.h",
"./aleo_12_bold.h",
"./aleo_12_bolditalic.h",
"./aleo_12_italic.h",
"./bookerly_12_regular.h",
"./bookerly_12_bold.h",
"./bookerly_12_bolditalic.h",
"./bookerly_12_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define ALEO_14_FONT_ID ($(
echo "#define BOOKERLY_14_FONT_ID ($(
ruby -rdigest -e 'puts [
"./aleo_14_regular.h",
"./aleo_14_bold.h",
"./aleo_14_bolditalic.h",
"./aleo_14_italic.h",
"./bookerly_14_regular.h",
"./bookerly_14_bold.h",
"./bookerly_14_bolditalic.h",
"./bookerly_14_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define ALEO_16_FONT_ID ($(
echo "#define BOOKERLY_16_FONT_ID ($(
ruby -rdigest -e 'puts [
"./aleo_16_regular.h",
"./aleo_16_bold.h",
"./aleo_16_bolditalic.h",
"./aleo_16_italic.h",
"./bookerly_16_regular.h",
"./bookerly_16_bold.h",
"./bookerly_16_bolditalic.h",
"./bookerly_16_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
echo "#define ALEO_18_FONT_ID ($(
echo "#define BOOKERLY_18_FONT_ID ($(
ruby -rdigest -e 'puts [
"./aleo_18_regular.h",
"./aleo_18_bold.h",
"./aleo_18_bolditalic.h",
"./aleo_18_italic.h",
"./bookerly_18_regular.h",
"./bookerly_18_bold.h",
"./bookerly_18_bolditalic.h",
"./bookerly_18_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
@@ -132,6 +132,6 @@ ruby -rdigest -e 'puts [
echo "#define SMALL_FONT_ID ($(
ruby -rdigest -e 'puts [
"./spacegrotesk.h",
"./notosans_8_regular.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
+5 -5
View File
@@ -5,14 +5,14 @@ set -e
cd "$(dirname "$0")"
READER_FONT_STYLES=("Regular" "Italic" "Bold" "BoldItalic")
ALEO_FONT_SIZES=(12 14 16 18)
BOOKERLY_FONT_SIZES=(12 14 16 18)
NOTOSANS_FONT_SIZES=(12 14 16 18)
OPENDYSLEXIC_FONT_SIZES=(8 10 12 14)
for size in ${ALEO_FONT_SIZES[@]}; do
for size in ${BOOKERLY_FONT_SIZES[@]}; do
for style in ${READER_FONT_STYLES[@]}; do
font_name="aleo_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Aleo/Aleo-${style}.ttf"
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
echo "Generated $output_path"
@@ -52,4 +52,4 @@ for size in ${UI_FONT_SIZES[@]}; do
done
done
python fontconvert.py spacegrotesk 8 ../builtinFonts/source/SpaceGrotesk/SpaceGrotesk-Regular.ttf > ../builtinFonts/spacegrotesk.h
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
+154 -9
View File
@@ -8,6 +8,7 @@
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNavParser.h"
#include "Epub/parsers/TocNcxParser.h"
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
@@ -80,6 +81,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
tocNcxItem = opfParser.tocNcxPath;
}
if (!opfParser.tocNavPath.empty()) {
tocNavItem = opfParser.tocNavPath;
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
return true;
}
@@ -141,6 +146,63 @@ bool Epub::parseTocNcxFile() const {
return true;
}
bool Epub::parseTocNavFile() const {
// the nav file should have been specified in the content.opf file (EPUB 3)
if (tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
return false;
}
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile;
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
tempNavFile.close();
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
}
const auto navSize = tempNavFile.size();
// Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf
// and the HTMLX nav file will have hrefs relative to itself
const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1);
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
if (!navParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
return false;
}
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
if (!navBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
return false;
}
while (tempNavFile.available()) {
const auto readSize = tempNavFile.read(navBuffer, 1024);
const auto processedSize = navParser.write(navBuffer, readSize);
if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
free(navBuffer);
tempNavFile.close();
return false;
}
}
free(navBuffer);
tempNavFile.close();
SdMan.remove(tmpNavPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
return true;
}
// load in the meta data for the epub file
bool Epub::load(const bool buildIfMissing) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
@@ -184,15 +246,31 @@ bool Epub::load(const bool buildIfMissing) {
return false;
}
// TOC Pass
// TOC Pass - try EPUB 3 nav first, fall back to NCX
if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
return false;
}
if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
return false;
bool tocParsed = false;
// Try EPUB 3 nav document first (preferred)
if (!tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
tocParsed = parseTocNavFile();
}
// Fall back to NCX if nav parsing failed or wasn't available
if (!tocParsed && !tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
tocParsed = parseTocNcxFile();
}
if (!tocParsed) {
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
// Continue anyway - book will work without TOC
}
if (!bookMetadataCache->endTocPass()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
return false;
@@ -270,11 +348,14 @@ const std::string& Epub::getAuthor() const {
return bookMetadataCache->coreMetadata.author;
}
std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
std::string Epub::getCoverBmpPath(bool cropped) const {
const auto coverFileName = "cover" + cropped ? "_crop" : "";
return cachePath + "/" + coverFileName + ".bmp";
}
bool Epub::generateCoverBmp() const {
bool Epub::generateCoverBmp(bool cropped) const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) {
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
return true;
}
@@ -306,7 +387,7 @@ bool Epub::generateCoverBmp() const {
}
FsFile coverBmp;
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) {
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close();
return false;
}
@@ -317,7 +398,7 @@ bool Epub::generateCoverBmp() const {
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath().c_str());
SdMan.remove(getCoverBmpPath(cropped).c_str());
}
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
return success;
@@ -328,6 +409,70 @@ bool Epub::generateCoverBmp() const {
return false;
}
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
bool Epub::generateThumbBmp() const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) {
return true;
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis());
return false;
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str());
}
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no");
return success;
} else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
}
return false;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
+8 -3
View File
@@ -12,8 +12,10 @@
class ZipFile;
class Epub {
// the ncx file
// the ncx file (EPUB 2)
std::string tocNcxItem;
// the nav file (EPUB 3)
std::string tocNavItem;
// where is the EPUBfile?
std::string filepath;
// the base path for items in the EPUB file
@@ -26,6 +28,7 @@ class Epub {
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@@ -41,8 +44,10 @@ class Epub {
const std::string& getPath() const;
const std::string& getTitle() const;
const std::string& getAuthor() const;
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
+1 -1
View File
@@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 3;
constexpr uint8_t BOOK_CACHE_VERSION = 4;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
+3 -3
View File
@@ -32,7 +32,7 @@ void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, co
}
bool Page::serialize(FsFile& file) const {
const uint32_t count = elements.size();
const uint16_t count = elements.size();
serialization::writePod(file, count);
for (const auto& el : elements) {
@@ -49,10 +49,10 @@ bool Page::serialize(FsFile& file) const {
std::unique_ptr<Page> Page::deserialize(FsFile& file) {
auto page = std::unique_ptr<Page>(new Page());
uint32_t count;
uint16_t count;
serialization::readPod(file, count);
for (uint32_t i = 0; i < count; i++) {
for (uint16_t i = 0; i < count; i++) {
uint8_t tag;
serialization::readPod(file, tag);
+4 -4
View File
@@ -10,7 +10,7 @@
constexpr int MAX_COST = std::numeric_limits<int>::max();
void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle) {
if (word.empty()) return;
words.push_back(std::move(word));
@@ -18,7 +18,7 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int viewportWidth,
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) {
if (words.empty()) {
@@ -43,7 +43,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
wordWidths.reserve(totalWordCount);
// add em-space at the beginning of first word in paragraph to indent
if (!extraParagraphSpacing) {
if ((style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) && !extraParagraphSpacing) {
std::string& first_word = words.front();
first_word.insert(0, "\xe2\x80\x83");
}
@@ -188,7 +188,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontStyle> lineWordStyles;
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
+7 -7
View File
@@ -14,8 +14,8 @@ class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontStyle> wordStyles;
TextBlock::BLOCK_STYLE style;
std::list<EpdFontFamily::Style> wordStyles;
TextBlock::Style style;
bool extraParagraphSpacing;
std::vector<size_t> computeLineBreaks(int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths) const;
@@ -25,16 +25,16 @@ class ParsedText {
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public:
explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing)
explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing)
: style(style), extraParagraphSpacing(extraParagraphSpacing) {}
~ParsedText() = default;
void addWord(std::string word, EpdFontStyle fontStyle);
void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; }
TextBlock::BLOCK_STYLE getStyle() const { return style; }
void addWord(std::string word, EpdFontFamily::Style fontStyle);
void setStyle(const TextBlock::Style style) { this->style = style; }
TextBlock::Style getStyle() const { return style; }
size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int viewportWidth,
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
};
+22 -14
View File
@@ -7,9 +7,9 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 7;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) +
sizeof(int) + sizeof(int) + sizeof(uint32_t);
constexpr uint8_t SECTION_FILE_VERSION = 9;
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(uint32_t);
} // namespace
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
@@ -30,19 +30,21 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
}
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) {
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight) {
if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
return;
}
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) +
sizeof(pageCount) + sizeof(uint32_t),
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
serialization::writePod(file, lineCompression);
serialization::writePod(file, extraParagraphSpacing);
serialization::writePod(file, paragraphAlignment);
serialization::writePod(file, viewportWidth);
serialization::writePod(file, viewportHeight);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
@@ -50,7 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
}
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) {
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight) {
if (!SdMan.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -66,18 +69,21 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
return false;
}
int fileFontId, fileViewportWidth, fileViewportHeight;
int fileFontId;
uint16_t fileViewportWidth, fileViewportHeight;
float fileLineCompression;
bool fileExtraParagraphSpacing;
uint8_t fileParagraphAlignment;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
serialization::readPod(file, fileParagraphAlignment);
serialization::readPod(file, fileViewportWidth);
serialization::readPod(file, fileViewportHeight);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
viewportHeight != fileViewportHeight) {
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight) {
file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache();
@@ -108,8 +114,8 @@ bool Section::clearCache() const {
}
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight,
const std::function<void()>& progressSetupFn,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href;
@@ -165,11 +171,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight);
std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn);
success = visitor.parseAndBuildPages();
+8 -7
View File
@@ -14,12 +14,12 @@ class Section {
std::string filePath;
FsFile file;
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight);
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight);
uint32_t onPageComplete(std::unique_ptr<Page> page);
public:
int pageCount = 0;
uint16_t pageCount = 0;
int currentPage = 0;
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
@@ -28,11 +28,12 @@ class Section {
renderer(renderer),
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default;
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight);
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight);
bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight,
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
};
+4 -4
View File
@@ -32,7 +32,7 @@ bool TextBlock::serialize(FsFile& file) const {
}
// Word data
serialization::writePod(file, static_cast<uint32_t>(words.size()));
serialization::writePod(file, static_cast<uint16_t>(words.size()));
for (const auto& w : words) serialization::writeString(file, w);
for (auto x : wordXpos) serialization::writePod(file, x);
for (auto s : wordStyles) serialization::writePod(file, s);
@@ -44,11 +44,11 @@ bool TextBlock::serialize(FsFile& file) const {
}
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint32_t wc;
uint16_t wc;
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontStyle> wordStyles;
BLOCK_STYLE style;
std::list<EpdFontFamily::Style> wordStyles;
Style style;
// Word count
serialization::readPod(file, wc);
+8 -8
View File
@@ -8,10 +8,10 @@
#include "Block.h"
// represents a block of words in the html document
// Represents a line of text on a page
class TextBlock final : public Block {
public:
enum BLOCK_STYLE : uint8_t {
enum Style : uint8_t {
JUSTIFIED = 0,
LEFT_ALIGN = 1,
CENTER_ALIGN = 2,
@@ -21,16 +21,16 @@ class TextBlock final : public Block {
private:
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontStyle> wordStyles;
BLOCK_STYLE style;
std::list<EpdFontFamily::Style> wordStyles;
Style style;
public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos, std::list<EpdFontStyle> word_styles,
const BLOCK_STYLE style)
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
std::list<EpdFontFamily::Style> word_styles, const Style style)
: words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {}
~TextBlock() override = default;
void setStyle(const BLOCK_STYLE style) { this->style = style; }
BLOCK_STYLE getStyle() const { return style; }
void setStyle(const Style style) { this->style = style; }
Style getStyle() const { return style; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines
-163
View File
@@ -1,163 +0,0 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#include "htmlEntities.h"
#include <cstring>
#include <unordered_map>
const int MAX_ENTITY_LENGTH = 10;
// Use book: entities_ww2.epub to test this (Page 7: Entities parser test)
// Note the supported keys are only in lowercase
// Store the mappings in a unordered hash map
static std::unordered_map<std::string, std::string> entity_lookup(
{{"&quot;", "\""}, {"&frasl;", ""}, {"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
{"&Agrave;", "À"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"},
{"&Aring;", "Å"}, {"&AElig;", "Æ"}, {"&Ccedil;", "Ç"}, {"&Egrave;", "È"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Euml;", "Ë"}, {"&Igrave;", "Ì"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"},
{"&Iuml;", "Ï"}, {"&ETH;", "Ð"}, {"&Ntilde;", "Ñ"}, {"&Ograve;", "Ò"}, {"&Oacute;", "Ó"},
{"&Ocirc;", "Ô"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"}, {"&Oslash;", "Ø"}, {"&Ugrave;", "Ù"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Uuml;", "Ü"}, {"&Yacute;", "Ý"}, {"&THORN;", "Þ"},
{"&szlig;", "ß"}, {"&agrave;", "à"}, {"&aacute;", "á"}, {"&acirc;", "â"}, {"&atilde;", "ã"},
{"&auml;", "ä"}, {"&aring;", "å"}, {"&aelig;", "æ"}, {"&ccedil;", "ç"}, {"&egrave;", "è"},
{"&eacute;", "é"}, {"&ecirc;", "ê"}, {"&euml;", "ë"}, {"&igrave;", "ì"}, {"&iacute;", "í"},
{"&icirc;", "î"}, {"&iuml;", "ï"}, {"&eth;", "ð"}, {"&ntilde;", "ñ"}, {"&ograve;", "ò"},
{"&oacute;", "ó"}, {"&ocirc;", "ô"}, {"&otilde;", "õ"}, {"&ouml;", "ö"}, {"&oslash;", "ø"},
{"&ugrave;", "ù"}, {"&uacute;", "ú"}, {"&ucirc;", "û"}, {"&uuml;", "ü"}, {"&yacute;", "ý"},
{"&thorn;", "þ"}, {"&yuml;", "ÿ"}, {"&nbsp;", " "}, {"&iexcl;", "¡"}, {"&cent;", "¢"},
{"&pound;", "£"}, {"&curren;", "¤"}, {"&yen;", "¥"}, {"&brvbar;", "¦"}, {"&sect;", "§"},
{"&uml;", "¨"}, {"&copy;", "©"}, {"&ordf;", "ª"}, {"&laquo;", "«"}, {"&not;", "¬"},
{"&shy;", "­"}, {"&reg;", "®"}, {"&macr;", "¯"}, {"&deg;", "°"}, {"&plusmn;", "±"},
{"&sup2;", "²"}, {"&sup3;", "³"}, {"&acute;", "´"}, {"&micro;", "µ"}, {"&para;", ""},
{"&cedil;", "¸"}, {"&sup1;", "¹"}, {"&ordm;", "º"}, {"&raquo;", "»"}, {"&frac14;", "¼"},
{"&frac12;", "½"}, {"&frac34;", "¾"}, {"&iquest;", "¿"}, {"&times;", "×"}, {"&divide;", "÷"},
{"&forall;", ""}, {"&part;", ""}, {"&exist;", ""}, {"&empty;", ""}, {"&nabla;", ""},
{"&isin;", ""}, {"&notin;", ""}, {"&ni;", ""}, {"&prod;", ""}, {"&sum;", ""},
{"&minus;", ""}, {"&lowast;", ""}, {"&radic;", ""}, {"&prop;", ""}, {"&infin;", ""},
{"&ang;", ""}, {"&and;", ""}, {"&or;", ""}, {"&cap;", ""}, {"&cup;", ""},
{"&int;", ""}, {"&there4;", ""}, {"&sim;", ""}, {"&cong;", ""}, {"&asymp;", ""},
{"&ne;", ""}, {"&equiv;", ""}, {"&le;", ""}, {"&ge;", ""}, {"&sub;", ""},
{"&sup;", ""}, {"&nsub;", ""}, {"&sube;", ""}, {"&supe;", ""}, {"&oplus;", ""},
{"&otimes;", ""}, {"&perp;", ""}, {"&sdot;", ""}, {"&Alpha;", "Α"}, {"&Beta;", "Β"},
{"&Gamma;", "Γ"}, {"&Delta;", "Δ"}, {"&Epsilon;", "Ε"}, {"&Zeta;", "Ζ"}, {"&Eta;", "Η"},
{"&Theta;", "Θ"}, {"&Iota;", "Ι"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"},
{"&Nu;", "Ν"}, {"&Xi;", "Ξ"}, {"&Omicron;", "Ο"}, {"&Pi;", "Π"}, {"&Rho;", "Ρ"},
{"&Sigma;", "Σ"}, {"&Tau;", "Τ"}, {"&Upsilon;", "Υ"}, {"&Phi;", "Φ"}, {"&Chi;", "Χ"},
{"&Psi;", "Ψ"}, {"&Omega;", "Ω"}, {"&alpha;", "α"}, {"&beta;", "β"}, {"&gamma;", "γ"},
{"&delta;", "δ"}, {"&epsilon;", "ε"}, {"&zeta;", "ζ"}, {"&eta;", "η"}, {"&theta;", "θ"},
{"&iota;", "ι"}, {"&kappa;", "κ"}, {"&lambda;", "λ"}, {"&mu;", "μ"}, {"&nu;", "ν"},
{"&xi;", "ξ"}, {"&omicron;", "ο"}, {"&pi;", "π"}, {"&rho;", "ρ"}, {"&sigmaf;", "ς"},
{"&sigma;", "σ"}, {"&tau;", "τ"}, {"&upsilon;", "υ"}, {"&phi;", "φ"}, {"&chi;", "χ"},
{"&psi;", "ψ"}, {"&omega;", "ω"}, {"&thetasym;", "ϑ"}, {"&upsih;", "ϒ"}, {"&piv;", "ϖ"},
{"&OElig;", "Œ"}, {"&oelig;", "œ"}, {"&Scaron;", "Š"}, {"&scaron;", "š"}, {"&Yuml;", "Ÿ"},
{"&fnof;", "ƒ"}, {"&circ;", "ˆ"}, {"&tilde;", "˜"}, {"&ensp;", ""}, {"&emsp;", ""},
{"&thinsp;", ""}, {"&zwnj;", ""}, {"&zwj;", ""}, {"&lrm;", ""}, {"&rlm;", ""},
{"&ndash;", ""}, {"&mdash;", ""}, {"&lsquo;", ""}, {"&rsquo;", ""}, {"&sbquo;", ""},
{"&ldquo;", ""}, {"&rdquo;", ""}, {"&bdquo;", ""}, {"&dagger;", ""}, {"&Dagger;", ""},
{"&bull;", ""}, {"&hellip;", ""}, {"&permil;", ""}, {"&prime;", ""}, {"&Prime;", ""},
{"&lsaquo;", ""}, {"&rsaquo;", ""}, {"&oline;", ""}, {"&euro;", ""}, {"&trade;", ""},
{"&larr;", ""}, {"&uarr;", ""}, {"&rarr;", ""}, {"&darr;", ""}, {"&harr;", ""},
{"&crarr;", ""}, {"&lceil;", ""}, {"&rceil;", ""}, {"&lfloor;", ""}, {"&rfloor;", ""},
{"&loz;", ""}, {"&spades;", ""}, {"&clubs;", ""}, {"&hearts;", ""}, {"&diams;", ""}});
// converts from a unicode code point to the utf8 equivalent
void convert_to_utf8(const int code, std::string& res) {
// convert to a utf8 sequence
if (code < 0x80) {
res += static_cast<char>(code);
} else if (code < 0x800) {
res += static_cast<char>(0xc0 | (code >> 6));
res += static_cast<char>(0x80 | (code & 0x3f));
} else if (code < 0x10000) {
res += static_cast<char>(0xe0 | (code >> 12));
res += static_cast<char>(0x80 | ((code >> 6) & 0x3f));
res += static_cast<char>(0x80 | (code & 0x3f));
} else if (code < 0x200000) {
res += static_cast<char>(0xf0 | (code >> 18));
res += static_cast<char>(0x80 | ((code >> 12) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 6) & 0x3f));
res += static_cast<char>(0x80 | (code & 0x3f));
} else if (code < 0x4000000) {
res += static_cast<char>(0xf8 | (code >> 24));
res += static_cast<char>(0x80 | ((code >> 18) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 12) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 6) & 0x3f));
res += static_cast<char>(0x80 | (code & 0x3f));
} else if (code < 0x80000000) {
res += static_cast<char>(0xfc | (code >> 30));
res += static_cast<char>(0x80 | ((code >> 24) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 18) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 12) & 0x3f));
res += static_cast<char>(0x80 | ((code >> 6) & 0x3f));
}
}
// handles numeric entities - e.g. &#1234; or &#x1234;
bool process_numeric_entity(const std::string& entity, std::string& res) {
int code = 0;
// is it hex?
if (entity[2] == 'x' || entity[2] == 'X') {
// parse the hex code
code = strtol(entity.substr(3, entity.size() - 3).c_str(), nullptr, 16);
} else {
code = strtol(entity.substr(2, entity.size() - 3).c_str(), nullptr, 10);
}
if (code != 0) {
// special handling for nbsp
if (code == 0xA0) {
res += " ";
} else {
convert_to_utf8(code, res);
}
return true;
}
return false;
}
// handles named entities - e.g. &amp;
bool process_string_entity(const std::string& entity, std::string& res) {
// it's a named entity - find it in the lookup table
// find it in the map
const auto it = entity_lookup.find(entity);
if (it != entity_lookup.end()) {
res += it->second;
return true;
}
return false;
}
// replace all the entities in the string
std::string replaceHtmlEntities(const char* text) {
std::string res;
res.reserve(strlen(text));
for (int i = 0; i < strlen(text); ++i) {
bool flag = false;
// do we have a potential entity?
if (text[i] == '&') {
// find the end of the entity
int j = i + 1;
while (j < strlen(text) && text[j] != ';' && j - i < MAX_ENTITY_LENGTH) {
j++;
}
if (j - i > 2) {
char entity[j - i + 1];
strncpy(entity, text + i, j - i);
// is it a numeric code?
if (entity[1] == '#') {
flag = process_numeric_entity(entity, res);
} else {
flag = process_string_entity(entity, res);
}
// skip past the entity if we successfully decoded it
if (flag) {
i = j;
}
}
}
if (!flag) {
res += text[i];
}
}
return res;
}
-7
View File
@@ -1,7 +0,0 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#pragma once
#include <string>
std::string replaceHtmlEntities(const char* text);
+46 -15
View File
@@ -6,7 +6,6 @@
#include <expat.h>
#include "../Page.h"
#include "../htmlEntities.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
@@ -42,7 +41,7 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
}
// start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style) {
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) {
@@ -97,7 +96,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (strcmp(name, "br") == 0) {
self->startNewTextBlock(self->currentTextBlock->getStyle());
} else {
self->startNewTextBlock(TextBlock::JUSTIFIED);
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
}
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
@@ -116,13 +118,13 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
return;
}
EpdFontStyle fontStyle = REGULAR;
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC;
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD;
fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC;
fontStyle = EpdFontFamily::ITALIC;
}
for (int i = 0; i < len; i++) {
@@ -130,17 +132,46 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
if (self->partWordBufferIndex > 0) {
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
}
// Skip the whitespace char
continue;
}
// Skip soft-hyphen with UTF-8 representation (U+00AD) = 0xC2 0xAD
const XML_Char SHY_BYTE_1 = static_cast<XML_Char>(0xC2);
const XML_Char SHY_BYTE_2 = static_cast<XML_Char>(0xAD);
// 1. Check for the start of the 2-byte Soft Hyphen sequence
if (s[i] == SHY_BYTE_1) {
// 2. Check if the next byte exists AND if it completes the sequence
// We must check i + 1 < len to prevent reading past the end of the buffer.
if ((i + 1 < len) && (s[i + 1] == SHY_BYTE_2)) {
// Sequence 0xC2 0xAD found!
// Skip the current byte (0xC2) and the next byte (0xAD)
i++; // Increment 'i' one more time to skip the 0xAD byte
continue; // Skip the rest of the loop and move to the next iteration
}
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF);
const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB);
const XML_Char FEFF_BYTE_3 = static_cast<XML_Char>(0xBF);
if (s[i] == FEFF_BYTE_1) {
// Check if the next two bytes complete the 3-byte sequence
if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) {
// Sequence 0xEF 0xBB 0xBF found!
i += 2; // Skip the next two bytes
continue; // Move to the next iteration
}
}
// If we're about to run out of space, then cut the word off and start a new one
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
}
@@ -172,17 +203,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1;
if (shouldBreakText) {
EpdFontStyle fontStyle = REGULAR;
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC;
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD;
fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC;
fontStyle = EpdFontFamily::ITALIC;
}
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
}
}
@@ -206,7 +237,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
startNewTextBlock(TextBlock::JUSTIFIED);
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
const XML_Parser parser = XML_ParserCreate(nullptr);
int done;
@@ -33,10 +33,11 @@ class ChapterHtmlSlimParser {
int fontId;
float lineCompression;
bool extraParagraphSpacing;
int viewportWidth;
int viewportHeight;
uint8_t paragraphAlignment;
uint16_t viewportWidth;
uint16_t viewportHeight;
void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void startNewTextBlock(TextBlock::Style style);
void makePages();
// XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
@@ -45,8 +46,9 @@ class ChapterHtmlSlimParser {
public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth,
const int viewportHeight,
const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath),
@@ -54,6 +56,7 @@ class ChapterHtmlSlimParser {
fontId(fontId),
lineCompression(lineCompression),
extraParagraphSpacing(extraParagraphSpacing),
paragraphAlignment(paragraphAlignment),
viewportWidth(viewportWidth),
viewportHeight(viewportHeight),
completePageFn(completePageFn),
+14 -2
View File
@@ -161,14 +161,17 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
std::string itemId;
std::string href;
std::string mediaType;
std::string properties;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "id") == 0) {
itemId = atts[i + 1];
} else if (strcmp(atts[i], "href") == 0) {
href = self->baseContentPath + atts[i + 1];
href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
} else if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1];
} else if (strcmp(atts[i], "properties") == 0) {
properties = atts[i + 1];
}
}
@@ -188,6 +191,15 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
href.c_str());
}
}
// EPUB 3: Check for nav document (properties contains "nav")
if (!properties.empty() && self->tocNavPath.empty()) {
// Properties is space-separated, check if "nav" is present as a word
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
self->tocNavPath = href;
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
}
}
return;
}
@@ -231,7 +243,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
break;
}
} else if (strcmp(atts[i], "href") == 0) {
textHref = self->baseContentPath + atts[i + 1];
textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
}
}
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
+1
View File
@@ -35,6 +35,7 @@ class ContentOpfParser final : public Print {
std::string title;
std::string author;
std::string tocNcxPath;
std::string tocNavPath; // EPUB 3 nav document path
std::string coverItemHref;
std::string textReferenceHref;
+185
View File
@@ -0,0 +1,185 @@
#include "TocNavParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include "../BookMetadataCache.h"
bool TocNavParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis());
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
return true;
}
TocNavParser::~TocNavParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
}
size_t TocNavParser::write(const uint8_t data) { return write(&data, 1); }
size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
if (!parser) return 0;
const uint8_t* currentBufferPos = buffer;
auto remainingInBuffer = size;
while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<TocNavParser*>(userData);
// Track HTML structure loosely - we mainly care about finding <nav epub:type="toc">
if (strcmp(name, "html") == 0) {
self->state = IN_HTML;
return;
}
if (self->state == IN_HTML && strcmp(name, "body") == 0) {
self->state = IN_BODY;
return;
}
// Look for <nav epub:type="toc"> anywhere in body (or nested elements)
if (self->state >= IN_BODY && strcmp(name, "nav") == 0) {
for (int i = 0; atts[i]; i += 2) {
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
self->state = IN_NAV_TOC;
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis());
return;
}
}
return;
}
// Only process ol/li/a if we're inside the toc nav
if (self->state < IN_NAV_TOC) {
return;
}
if (strcmp(name, "ol") == 0) {
self->olDepth++;
self->state = IN_OL;
return;
}
if (self->state == IN_OL && strcmp(name, "li") == 0) {
self->state = IN_LI;
self->currentLabel.clear();
self->currentHref.clear();
return;
}
if (self->state == IN_LI && strcmp(name, "a") == 0) {
self->state = IN_ANCHOR;
// Get href attribute
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "href") == 0) {
self->currentHref = atts[i + 1];
break;
}
}
return;
}
}
void XMLCALL TocNavParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<TocNavParser*>(userData);
// Only collect text when inside an anchor within the TOC nav
if (self->state == IN_ANCHOR) {
self->currentLabel.append(s, len);
}
}
void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<TocNavParser*>(userData);
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
// Create TOC entry when closing anchor tag (we have all data now)
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref);
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
if (self->cache) {
// olDepth gives us the nesting level (1-based from the outer ol)
self->cache->createTocEntry(self->currentLabel, href, anchor, self->olDepth);
}
self->currentLabel.clear();
self->currentHref.clear();
}
self->state = IN_LI;
return;
}
if (strcmp(name, "li") == 0 && (self->state == IN_LI || self->state == IN_OL)) {
self->state = IN_OL;
return;
}
if (strcmp(name, "ol") == 0 && self->state >= IN_NAV_TOC) {
self->olDepth--;
if (self->olDepth == 0) {
self->state = IN_NAV_TOC;
} else {
self->state = IN_LI; // Back to parent li
}
return;
}
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
self->state = IN_BODY;
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis());
return;
}
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <Print.h>
#include <expat.h>
#include <string>
class BookMetadataCache;
// Parser for EPUB 3 nav.xhtml navigation documents
// Parses HTML5 nav elements with epub:type="toc" to extract table of contents
class TocNavParser final : public Print {
enum ParserState {
START,
IN_HTML,
IN_BODY,
IN_NAV_TOC, // Inside <nav epub:type="toc">
IN_OL, // Inside <ol>
IN_LI, // Inside <li>
IN_ANCHOR, // Inside <a>
};
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
ParserState state = START;
BookMetadataCache* cache;
// Track nesting depth for <ol> elements to determine TOC depth
uint8_t olDepth = 0;
// Current entry data being collected
std::string currentLabel;
std::string currentHref;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len);
static void endElement(void* userData, const XML_Char* name);
public:
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
~TocNavParser() override;
bool setup();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
};
+2 -1
View File
@@ -1,5 +1,6 @@
#include "TocNcxParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include "../BookMetadataCache.h"
@@ -159,7 +160,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
// This is the safest place to push the data, assuming <navLabel> always comes before <content>.
// NCX spec says navLabel comes before content.
if (!self->currentLabel.empty() && !self->currentSrc.empty()) {
std::string href = self->baseContentPath + self->currentSrc;
std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc);
std::string anchor;
const size_t pos = href.find('#');
+37 -145
View File
@@ -8,119 +8,15 @@
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Brightness adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true
constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true
constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
const int product = gray * 255;
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
static inline uint8_t quantizeNoise(int gray, int x, int y) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Floyd-Steinberg quantization with error diffusion and serpentine scanning
// Returns 2-bit value (0-3) and updates error buffers
static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow,
bool reverseDir) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDir) {
// Left to right
errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16
errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16
} else {
// Right to left (mirrored)
errorCurRow[x] += (error * 7) >> 4; // Left: 7/16
errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16
}
return quantized;
}
Bitmap::~Bitmap() {
delete[] errorCurRow;
delete[] errorNextRow;
delete atkinsonDitherer;
delete fsDitherer;
}
uint16_t Bitmap::readLE16(FsFile& f) {
@@ -244,40 +140,25 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed;
}
// Allocate Floyd-Steinberg error buffers if enabled
if (USE_FLOYD_STEINBERG) {
delete[] errorCurRow;
delete[] errorNextRow;
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
lastRowY = -1;
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
if (bpp > 2 && dithering) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(width);
} else {
fsDitherer = new FloydSteinbergDitherer(width);
}
}
return BmpReaderError::Ok;
}
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const {
BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
// Handle Floyd-Steinberg error buffer progression
const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow;
if (useFS) {
// Check if we need to advance to next row (or reset if jumping)
if (rowY != lastRowY + 1 && rowY != 0) {
// Non-sequential row access - reset error buffers
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
} else if (rowY > 0) {
// Sequential access - swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
lastRowY = rowY;
}
prevRowY += 1;
uint8_t* outPtr = data;
uint8_t currentOutByte = 0;
@@ -287,12 +168,18 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons
// Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](const uint8_t lum) {
uint8_t color;
if (useFS) {
// Floyd-Steinberg error diffusion
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false);
if (atkinsonDitherer) {
color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX);
} else if (fsDitherer) {
color = fsDitherer->processPixel(adjustPixel(lum), currentX);
} else {
// Simple quantization or noise dithering
color = quantize(lum, currentX, rowY);
if (bpp > 2) {
// Simple quantization or noise dithering
color = quantize(adjustPixel(lum), currentX, prevRowY);
} else {
// do not quantize 2bpp image
color = static_cast<uint8_t>(lum >> 6);
}
}
currentOutByte |= (color << bitShift);
if (bitShift == 0) {
@@ -341,7 +228,10 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons
}
case 1: {
for (int x = 0; x < width; x++) {
lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
// Get palette index (0 or 1) from bit at position x
const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0;
// Use palette lookup for proper black/white mapping
lum = paletteLum[palIndex];
packPixel(lum);
}
break;
@@ -350,6 +240,11 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons
return BmpReaderError::UnsupportedBpp;
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
// Flush remaining bits if width is not a multiple of 4
if (bitShift != 6) *outPtr = currentOutByte;
@@ -361,12 +256,9 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed;
}
// Reset Floyd-Steinberg error buffers when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
lastRowY = -1;
}
// Reset dithering when rewinding
if (fsDitherer) fsDitherer->reset();
if (atkinsonDitherer) atkinsonDitherer->reset();
return BmpReaderError::Ok;
}
+13 -3
View File
@@ -2,6 +2,10 @@
#include <SdFat.h>
#include <cstdint>
#include "BitmapHelpers.h"
enum class BmpReaderError : uint8_t {
Ok = 0,
FileInvalid,
@@ -28,22 +32,25 @@ class Bitmap {
public:
static const char* errorToString(BmpReaderError err);
explicit Bitmap(FsFile& file) : file(file) {}
explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {}
~Bitmap();
BmpReaderError parseHeaders();
BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const;
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
BmpReaderError rewindToData() const;
int getWidth() const { return width; }
int getHeight() const { return height; }
bool isTopDown() const { return topDown; }
bool hasGreyscale() const { return bpp > 1; }
int getRowBytes() const { return rowBytes; }
bool is1Bit() const { return bpp == 1; }
uint16_t getBpp() const { return bpp; }
private:
static uint16_t readLE16(FsFile& f);
static uint32_t readLE32(FsFile& f);
FsFile& file;
bool dithering = false;
int width = 0;
int height = 0;
bool topDown = false;
@@ -55,5 +62,8 @@ class Bitmap {
// Floyd-Steinberg dithering state (mutable for const methods)
mutable int16_t* errorCurRow = nullptr;
mutable int16_t* errorNextRow = nullptr;
mutable int lastRowY = -1; // Track row progression for error propagation
mutable int prevRowY = -1; // Track row progression for error propagation
mutable AtkinsonDitherer* atkinsonDitherer = nullptr;
mutable FloydSteinbergDitherer* fsDitherer = nullptr;
};
+106
View File
@@ -0,0 +1,106 @@
#include "BitmapHelpers.h"
#include <cstdint>
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - divide into 4 levels
// The thresholds are fine-tuned to the X4 display
uint8_t quantizeSimple(int gray) {
if (gray < 45) {
return 0;
} else if (gray < 70) {
return 1;
} else if (gray < 140) {
return 2;
} else {
return 3;
}
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// 1-bit noise dithering for fast home screen rendering
// Uses hash-based noise for consistent dithering that works well at small sizes
uint8_t quantize1bit(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Simple threshold with noise: gray >= (128 + noise offset) -> white
// The noise adds variation around the 128 midpoint
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}
+314
View File
@@ -0,0 +1,314 @@
#pragma once
#include <cstring>
// Helper functions
uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
class Atkinson1BitDitherer {
public:
explicit Atkinson1BitDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~Atkinson1BitDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
// EXPLICITLY DELETE THE COPY CONSTRUCTOR
Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete;
// EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR
Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete;
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 2 levels (1-bit): 0 = black, 1 = white
uint8_t quantized;
int quantizedValue;
if (adjusted < 128) {
quantized = 0;
quantizedValue = 0;
} else {
quantized = 1;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
explicit AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
// **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR**
AtkinsonDitherer(const AtkinsonDitherer& other) = delete;
// **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR**
AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete;
uint8_t processPixel(int gray, int x) {
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (false) { // original thresholds
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
} else { // fine-tuned to X4 eink display
if (adjusted < 30) {
quantized = 0;
quantizedValue = 15;
} else if (adjusted < 50) {
quantized = 1;
quantizedValue = 30;
} else if (adjusted < 140) {
quantized = 2;
quantizedValue = 80;
} else {
quantized = 3;
quantizedValue = 210;
}
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
explicit FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR**
FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete;
// **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR**
FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete;
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (false) { // original thresholds
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
} else { // fine-tuned to X4 eink display
if (adjusted < 30) {
quantized = 0;
quantizedValue = 15;
} else if (adjusted < 50) {
quantized = 1;
quantizedValue = 30;
} else if (adjusted < 140) {
quantized = 2;
quantizedValue = 80;
} else {
quantized = 3;
quantizedValue = 210;
}
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!isReverseRow()) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
+333 -18
View File
@@ -66,7 +66,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontStyle style) const {
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;
@@ -78,13 +78,13 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontS
}
void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black,
const EpdFontStyle style) const {
const EpdFontFamily::Style style) const {
const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2;
drawText(fontId, x, y, text, black, style);
}
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontStyle style) const {
const EpdFontFamily::Style style) const {
const int yPos = y + getFontAscenderSize(fontId);
int xpos = x;
@@ -152,18 +152,30 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const {
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
return;
}
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f);
Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
isScaled = true;
}
Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled");
// Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
@@ -178,32 +190,45 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner.
int screenY = y + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
if (isScaled) {
screenY = std::floor(screenY * scale);
}
screenY += y; // the offset should not be scaled
if (screenY >= getScreenHeight()) {
break;
}
if (screenY < 0) {
continue;
}
if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
free(outputRow);
free(rowBytes);
return;
}
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + bmpX;
if (bmpY < cropPixY) {
// Skip the row if it's outside the crop area
continue;
}
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
int screenX = bmpX - cropPixX;
if (isScaled) {
screenX = std::floor(screenX * scale);
}
screenX += x; // the offset should not be scaled
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@@ -221,6 +246,143 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
free(rowBytes);
}
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
isScaled = true;
}
// For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow)
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis());
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// Read rows sequentially using readNextRow
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY);
free(outputRow);
free(rowBytes);
return;
}
// Calculate screen Y based on whether BMP is top-down or bottom-up
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
continue; // Continue reading to keep row counter in sync
}
if (screenY < 0) {
continue;
}
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
// Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
}
// White pixels (val == 3) are not drawn (leave background)
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
if (numPoints < 3) return;
// Find bounding box
int minY = yPoints[0], maxY = yPoints[0];
for (int i = 1; i < numPoints; i++) {
if (yPoints[i] < minY) minY = yPoints[i];
if (yPoints[i] > maxY) maxY = yPoints[i];
}
// Clip to screen
if (minY < 0) minY = 0;
if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1;
// Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) {
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis());
return;
}
// Scanline fill algorithm
for (int scanY = minY; scanY <= maxY; scanY++) {
int nodes = 0;
// Find all intersection points with edges
int j = numPoints - 1;
for (int i = 0; i < numPoints; i++) {
if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) {
// Calculate X intersection using fixed-point to avoid float
int dy = yPoints[j] - yPoints[i];
if (dy != 0) {
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy;
}
}
j = i;
}
// Sort nodes by X (simple bubble sort, numPoints is small)
for (int i = 0; i < nodes - 1; i++) {
for (int k = i + 1; k < nodes; k++) {
if (nodeX[i] > nodeX[k]) {
int temp = nodeX[i];
nodeX[i] = nodeX[k];
nodeX[k] = temp;
}
}
}
// Fill between pairs of nodes
for (int i = 0; i < nodes - 1; i += 2) {
int startX = nodeX[i];
int endX = nodeX[i + 1];
// Clip to screen
if (startX < 0) startX = 0;
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
// Draw horizontal line
for (int x = startX; x <= endX; x++) {
drawPixel(x, scanY, state);
}
}
}
free(nodeX);
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const {
@@ -238,6 +400,17 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons
einkDisplay.displayBuffer(refreshMode);
}
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const {
std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style);
while (itemWidth > maxWidth && item.length() > 8) {
item.replace(item.length() - 5, 5, "...");
itemWidth = getTextWidth(fontId, item.c_str(), style);
}
return item;
}
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
int GfxRenderer::getScreenWidth() const {
switch (orientation) {
@@ -273,7 +446,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
return 0;
}
return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX;
return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX;
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
@@ -282,7 +455,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
return 0;
}
return fontMap.at(fontId).getData(REGULAR)->ascender;
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
}
int GfxRenderer::getLineHeight(const int fontId) const {
@@ -291,7 +464,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return 0;
}
return fontMap.at(fontId).getData(REGULAR)->advanceY;
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY;
}
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
@@ -316,6 +489,148 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
}
}
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = getScreenWidth();
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 5; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
drawTextRotated90CW(fontId, textX, textY, labels[i]);
}
}
}
int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;
}
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
}
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return;
}
const auto font = fontMap.at(fontId);
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
// For 90° clockwise rotation:
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
// Text reads from bottom to top
int yPos = y; // Current Y position (decreases as we draw characters)
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph('?', style);
}
if (!glyph) {
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° clockwise rotation transformation:
// screenX = x + (ascender - top + glyphY)
// screenY = yPos - (left + glyphX)
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
const int screenY = yPos - left - glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going up, so decrease Y)
yPos -= glyph->advanceX;
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
@@ -436,7 +751,7 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
const bool pixelState, const EpdFontStyle style) const {
const bool pixelState, const EpdFontFamily::Style style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) {
// TODO: Replace with fallback glyph property?
+22 -7
View File
@@ -31,7 +31,7 @@ class GfxRenderer {
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
EpdFontStyle style) const;
EpdFontFamily::Style style) const;
void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
@@ -66,26 +66,41 @@ class GfxRenderer {
void drawRect(int x, int y, int width, int height, bool state = true) const;
void fillRect(int x, int y, int width, int height, bool state = true) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Text
int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const;
void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawCenteredText(int fontId, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getSpaceWidth(int fontId) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const;
std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const;
public:
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer();
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const;
// Low level functions
+118 -289
View File
@@ -7,6 +7,8 @@
#include <cstdio>
#include <cstring>
#include "BitmapHelpers.h"
// Context structure for picojpeg callback
struct JpegReadContext {
FsFile& file;
@@ -23,282 +25,12 @@ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantizati
constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion)
constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
static inline int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
gray = adjustPixel(gray);
// Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Map gray (0-255) to 4 levels with dithering
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x, bool reverseDirection) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDirection) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
@@ -355,8 +87,47 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
}
}
// Helper function: Write BMP header with 1-bit color depth (black and white)
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize); // File size
write32(bmpOut, 0); // Reserved
write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8)
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 1); // Bits per pixel (1 bit)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 2); // colorsUsed
write32(bmpOut, 2); // colorsImportant
// Color Palette (2 colors x 4 bytes = 8 bytes)
// Format: Blue, Green, Red, Reserved (BGRA)
// Note: In 1-bit BMP, palette index 0 = black, 1 = white
uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Helper function: Write BMP header with 2-bit color depth
void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) {
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up
const int imageSize = bytesPerRow * height;
@@ -427,9 +198,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
return 0; // Success
}
// Core function: Convert JPEG file to 2-bit BMP
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis());
// Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
targetWidth, targetHeight);
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@@ -464,11 +237,13 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) {
if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) {
// Calculate scale to fit within target dimensions while maintaining aspect ratio
const float scaleToFitWidth = static_cast<float>(TARGET_MAX_WIDTH) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(TARGET_MAX_HEIGHT) / imageInfo.m_height;
const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
// We scale to the smaller dimension, so we can potentially crop later.
// TODO: ideally, we already crop here.
const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
outWidth = static_cast<int>(imageInfo.m_width * scale);
outHeight = static_cast<int>(imageInfo.m_height * scale);
@@ -484,16 +259,19 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT);
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight);
}
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
} else {
writeBmpHeader(bmpOut, outWidth, outHeight);
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
@@ -524,11 +302,16 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return false;
}
// Create ditherer if enabled (only for 2-bit output)
// Create ditherer if enabled
// Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
if (!USE_8BIT_OUTPUT) {
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
if (oneBit) {
// For 1-bit output, use Atkinson dithering for better quality
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
@@ -614,19 +397,32 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
// No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else {
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, y);
}
@@ -677,19 +473,32 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else {
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
: quantize1bit(gray, x, currentOutY);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, currentOutY);
}
@@ -730,9 +539,29 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
if (fsDitherer) {
delete fsDitherer;
}
if (atkinson1BitDitherer) {
delete atkinson1BitDitherer;
}
free(mcuRowBuffer);
free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
return true;
}
// Core function: Convert JPEG file to 2-bit BMP (uses default target size)
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
}
// Convert with custom target size (for thumbnails, 2-bit)
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
}
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
}
+6 -2
View File
@@ -5,11 +5,15 @@ class Print;
class ZipFile;
class JpegToBmpConverter {
static void writeBmpHeader(Print& bmpOut, int width, int height);
// [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit);
public:
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut);
// Convert with custom target size (for thumbnails)
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
};
+219
View File
@@ -0,0 +1,219 @@
#include "OpdsParser.h"
#include <HardwareSerial.h>
#include <cstring>
OpdsParser::~OpdsParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
}
bool OpdsParser::parse(const char* xmlData, const size_t length) {
clear();
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis());
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
// Parse in chunks to avoid large buffer allocations
const char* currentPos = xmlData;
size_t remaining = length;
constexpr size_t chunkSize = 1024;
while (remaining > 0) {
void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) {
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis());
XML_ParserFree(parser);
parser = nullptr;
return false;
}
const size_t toRead = remaining < chunkSize ? remaining : chunkSize;
memcpy(buf, currentPos, toRead);
const bool isFinal = (remaining == toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), isFinal) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_ParserFree(parser);
parser = nullptr;
return false;
}
currentPos += toRead;
remaining -= toRead;
}
// Clean up parser
XML_ParserFree(parser);
parser = nullptr;
Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size());
return true;
}
void OpdsParser::clear() {
entries.clear();
currentEntry = OpdsEntry{};
currentText.clear();
inEntry = false;
inTitle = false;
inAuthor = false;
inAuthorName = false;
inId = false;
}
std::vector<OpdsEntry> OpdsParser::getBooks() const {
std::vector<OpdsEntry> books;
for (const auto& entry : entries) {
if (entry.type == OpdsEntryType::BOOK) {
books.push_back(entry);
}
}
return books;
}
const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], name) == 0) {
return atts[i + 1];
}
}
return nullptr;
}
void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<OpdsParser*>(userData);
// Check for entry element (with or without namespace prefix)
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
self->inEntry = true;
self->currentEntry = OpdsEntry{};
return;
}
if (!self->inEntry) return;
// Check for title element
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
self->inTitle = true;
self->currentText.clear();
return;
}
// Check for author element
if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
self->inAuthor = true;
return;
}
// Check for author name element
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
self->inAuthorName = true;
self->currentText.clear();
return;
}
// Check for id element
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
self->inId = true;
self->currentText.clear();
return;
}
// Check for link element
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
const char* rel = findAttribute(atts, "rel");
const char* type = findAttribute(atts, "type");
const char* href = findAttribute(atts, "href");
if (href) {
// Check for acquisition link with epub type (this is a downloadable book)
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
strcmp(type, "application/epub+zip") == 0) {
self->currentEntry.type = OpdsEntryType::BOOK;
self->currentEntry.href = href;
}
// Check for navigation link (subsection or no rel specified with atom+xml type)
else if (type && strstr(type, "application/atom+xml") != nullptr) {
// Only set navigation link if we don't already have an epub link
if (self->currentEntry.type != OpdsEntryType::BOOK) {
self->currentEntry.type = OpdsEntryType::NAVIGATION;
self->currentEntry.href = href;
}
}
}
}
}
void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<OpdsParser*>(userData);
// Check for entry end
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
// Only add entry if it has required fields (title and href)
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
self->entries.push_back(self->currentEntry);
}
self->inEntry = false;
self->currentEntry = OpdsEntry{};
return;
}
if (!self->inEntry) return;
// Check for title end
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
if (self->inTitle) {
self->currentEntry.title = self->currentText;
}
self->inTitle = false;
return;
}
// Check for author end
if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
self->inAuthor = false;
return;
}
// Check for author name end
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
if (self->inAuthorName) {
self->currentEntry.author = self->currentText;
}
self->inAuthorName = false;
return;
}
// Check for id end
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
if (self->inId) {
self->currentEntry.id = self->currentText;
}
self->inId = false;
return;
}
}
void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<OpdsParser*>(userData);
// Only accumulate text when in a text element
if (self->inTitle || self->inAuthorName || self->inId) {
self->currentText.append(s, len);
}
}
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include <expat.h>
#include <string>
#include <vector>
/**
* Type of OPDS entry.
*/
enum class OpdsEntryType {
NAVIGATION, // Link to another catalog
BOOK // Downloadable book
};
/**
* Represents an entry from an OPDS feed (either a navigation link or a book).
*/
struct OpdsEntry {
OpdsEntryType type = OpdsEntryType::NAVIGATION;
std::string title;
std::string author; // Only for books
std::string href; // Navigation URL or epub download URL
std::string id;
};
// Legacy alias for backward compatibility
using OpdsBook = OpdsEntry;
/**
* Parser for OPDS (Open Publication Distribution System) Atom feeds.
* Uses the Expat XML parser to parse OPDS catalog entries.
*
* Usage:
* OpdsParser parser;
* if (parser.parse(xmlData, xmlLength)) {
* for (const auto& entry : parser.getEntries()) {
* if (entry.type == OpdsEntryType::BOOK) {
* // Downloadable book
* } else {
* // Navigation link to another catalog
* }
* }
* }
*/
class OpdsParser {
public:
OpdsParser() = default;
~OpdsParser();
// Disable copy
OpdsParser(const OpdsParser&) = delete;
OpdsParser& operator=(const OpdsParser&) = delete;
/**
* Parse an OPDS XML feed.
* @param xmlData Pointer to the XML data
* @param length Length of the XML data
* @return true if parsing succeeded, false on error
*/
bool parse(const char* xmlData, size_t length);
/**
* Get the parsed entries (both navigation and book entries).
* @return Vector of OpdsEntry entries
*/
const std::vector<OpdsEntry>& getEntries() const { return entries; }
/**
* Get only book entries (legacy compatibility).
* @return Vector of book entries
*/
std::vector<OpdsEntry> getBooks() const;
/**
* Clear all parsed entries.
*/
void clear();
private:
// Expat callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL endElement(void* userData, const XML_Char* name);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
// Helper to find attribute value
static const char* findAttribute(const XML_Char** atts, const char* name);
XML_Parser parser = nullptr;
std::vector<OpdsEntry> entries;
OpdsEntry currentEntry;
std::string currentText;
// Parser state
bool inEntry = false;
bool inTitle = false;
bool inAuthor = false;
bool inAuthorName = false;
bool inId = false;
};
+191
View File
@@ -0,0 +1,191 @@
#include "Txt.h"
#include <FsHelpers.h>
#include <JpegToBmpConverter.h>
Txt::Txt(std::string path, std::string cacheBasePath)
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
// Generate cache path from file path hash
const size_t hash = std::hash<std::string>{}(filepath);
cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash);
}
bool Txt::load() {
if (loaded) {
return true;
}
if (!SdMan.exists(filepath.c_str())) {
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
return false;
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
return false;
}
fileSize = file.size();
file.close();
loaded = true;
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
return true;
}
std::string Txt::getTitle() const {
// Extract filename without path and extension
size_t lastSlash = filepath.find_last_of('/');
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
// Remove .txt extension
if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") {
filename = filename.substr(0, filename.length() - 4);
}
return filename;
}
void Txt::setupCacheDir() const {
if (!SdMan.exists(cacheBasePath.c_str())) {
SdMan.mkdir(cacheBasePath.c_str());
}
if (!SdMan.exists(cachePath.c_str())) {
SdMan.mkdir(cachePath.c_str());
}
}
std::string Txt::findCoverImage() const {
// Get the folder containing the txt file
size_t lastSlash = filepath.find_last_of('/');
std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : "";
if (folder.empty()) {
folder = "/";
}
// Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt")
std::string baseName = getTitle();
// Image extensions to try
const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"};
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + baseName + ext;
if (SdMan.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
}
// Fallback: look for cover image files
const char* coverNames[] = {"cover", "Cover", "COVER"};
for (const auto& name : coverNames) {
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + std::string(name) + ext;
if (SdMan.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
}
}
return "";
}
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Txt::generateCoverBmp() const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) {
return true;
}
std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis());
return false;
}
// Setup cache directory
setupCacheDir();
// Get file extension
const size_t len = coverImagePath.length();
const bool isJpg =
(len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP");
if (isBmp) {
// Copy BMP file to cache
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
FsFile src, dst;
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
src.close();
return false;
}
uint8_t buffer[1024];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
src.close();
dst.close();
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis());
return true;
}
if (isJpg) {
// Convert JPG/JPEG to BMP (same approach as Epub)
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
FsFile coverJpg, coverBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
coverJpg.close();
coverBmp.close();
if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath().c_str());
} else {
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
}
return success;
}
// PNG files are not supported (would need a PNG decoder)
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis());
return false;
}
bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
if (!loaded) {
return false;
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
return false;
}
if (!file.seek(offset)) {
file.close();
return false;
}
size_t bytesRead = file.read(buffer, length);
file.close();
return bytesRead > 0;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <SDCardManager.h>
#include <memory>
#include <string>
class Txt {
std::string filepath;
std::string cacheBasePath;
std::string cachePath;
bool loaded = false;
size_t fileSize = 0;
public:
explicit Txt(std::string path, std::string cacheBasePath);
bool load();
[[nodiscard]] const std::string& getPath() const { return filepath; }
[[nodiscard]] const std::string& getCachePath() const { return cachePath; }
[[nodiscard]] std::string getTitle() const;
[[nodiscard]] size_t getFileSize() const { return fileSize; }
void setupCacheDir() const;
// Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file
[[nodiscard]] std::string getCoverBmpPath() const;
[[nodiscard]] bool generateCoverBmp() const;
[[nodiscard]] std::string findCoverImage() const;
// Read content from file
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
};
+261
View File
@@ -293,6 +293,267 @@ bool Xtc::generateCoverBmp() const {
return true;
}
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
bool Xtc::generateThumbBmp() const {
// Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) {
return true;
}
if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis());
return false;
}
if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
return false;
}
// Setup cache directory
setupCacheDir();
// Get first page info for cover
xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
return false;
}
// Get bit depth
const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
// Calculate scale factor
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
// Only scale down, never up
if (scale >= 1.0f) {
// Page is already small enough, just use cover.bmp
// Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
}
return false;
}
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
pageInfo.height, thumbWidth, thumbHeight, scale);
// Allocate buffer for page data
size_t bitmapSize;
if (bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
}
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
return false;
}
// Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis());
free(pageBuffer);
return false;
}
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer);
return false;
}
// Write 1-bit BMP header for fast home screen rendering
const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
const uint32_t imageSize = rowSize * thumbHeight;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
// File header
thumbBmp.write('B');
thumbBmp.write('M');
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t widthVal = thumbWidth;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1; // 1-bit for black and white
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit: black and white)
uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
};
thumbBmp.write(palette, 8);
// Allocate row buffer for 1-bit output
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
if (!rowBuffer) {
free(pageBuffer);
thumbBmp.close();
return false;
}
// Fixed-point scale factor (16.16)
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
// Pre-calculate plane info for 2-bit mode
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0;
const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr;
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
// Calculate source Y range with bounds checking
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
uint32_t srcYEnd = (static_cast<uint32_t>(dstY + 1) * scaleInv_fp) >> 16;
if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) {
// Calculate source X range with bounds checking
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16;
if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1;
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1;
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
// Area averaging: sum grayscale values (0-255 range)
uint32_t graySum = 0;
uint32_t totalCount = 0;
for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) {
for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) {
uint8_t grayValue = 255; // Default: white
if (bitDepth == 2) {
// XTH 2-bit mode: pixel value 0-3
// Bounds check for column index
if (srcX < pageInfo.width) {
const size_t colIndex = pageInfo.width - 1 - srcX;
const size_t byteInCol = srcY / 8;
const size_t bitInByte = 7 - (srcY % 8);
const size_t byteOffset = colIndex * colBytes + byteInCol;
// Bounds check for buffer access
if (byteOffset < planeSize) {
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
}
}
} else {
// 1-bit mode
const size_t byteIdx = srcY * srcRowBytes + srcX / 8;
const size_t bitIdx = 7 - (srcX % 8);
// Bounds check for buffer access
if (byteIdx < bitmapSize) {
const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1;
// XTC polarity: 1=black, 0=white
grayValue = pixelBit ? 0 : 255;
}
}
graySum += grayValue;
totalCount++;
}
}
// Calculate average grayscale and quantize to 1-bit with noise dithering
uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255;
// Hash-based noise dithering for 1-bit output
uint32_t hash = static_cast<uint32_t>(dstX) * 374761393u + static_cast<uint32_t>(dstY) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
// Quantize to 1-bit: 0=black, 1=white
uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0;
// Pack 1-bit value into row buffer (MSB first, 8 pixels per byte)
const size_t byteIndex = dstX / 8;
const size_t bitOffset = 7 - (dstX % 8);
// Bounds check for row buffer access
if (byteIndex < rowSize) {
if (oneBit) {
rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white
} else {
rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black
}
}
}
// Write row (already padded to 4-byte boundary by rowSize)
thumbBmp.write(rowBuffer, rowSize);
}
free(rowBuffer);
thumbBmp.close();
free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
getThumbBmpPath().c_str());
return true;
}
uint32_t Xtc::getPageCount() const {
if (!loaded || !parser) {
return 0;
+3
View File
@@ -62,6 +62,9 @@ class Xtc {
// Cover image support (for sleep screen)
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
// Page access
uint32_t getPageCount() const;
+8 -3
View File
@@ -1,7 +1,9 @@
[platformio]
crosspoint_version = 0.11.0
default_envs = default
[crosspoint]
version = 0.14.0
[base]
platform = espressif32 @ 6.12.0
board = esp32-c3-devkitm-1
@@ -26,6 +28,8 @@ build_flags =
-DXML_GE=0
-DXML_CONTEXT_BYTES=1024
-std=c++2a
# Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1
; Board configuration
board_build.flash_mode = dio
@@ -43,15 +47,16 @@ lib_deps =
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
ArduinoJson @ 7.4.2
QRCode @ 0.0.1
links2004/WebSockets @ ^2.4.1
[env:default]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\"
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
[env:gh_release]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\"
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
+72 -7
View File
@@ -4,6 +4,8 @@
#include <SDCardManager.h>
#include <Serialization.h>
#include <cstring>
#include "fontIds.h"
// Initialize the static instance
@@ -12,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 10;
constexpr uint8_t SETTINGS_COUNT = 18;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@@ -37,6 +39,15 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fontFamily);
serialization::writePod(outputFile, fontSize);
serialization::writePod(outputFile, lineSpacing);
serialization::writePod(outputFile, paragraphAlignment);
serialization::writePod(outputFile, sleepTimeout);
serialization::writePod(outputFile, refreshFrequency);
serialization::writePod(outputFile, screenMargin);
serialization::writePod(outputFile, sleepScreenCoverMode);
serialization::writeString(outputFile, std::string(opdsServerUrl));
serialization::writePod(outputFile, textAntiAliasing);
serialization::writePod(outputFile, hideBatteryPercentage);
serialization::writePod(outputFile, longPressChapterSkip);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@@ -83,6 +94,28 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, lineSpacing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, paragraphAlignment);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepTimeout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, screenMargin);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepScreenCoverMode);
if (++settingsRead >= fileSettingsCount) break;
{
std::string urlStr;
serialization::readString(inputFile, urlStr);
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
}
serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hideBatteryPercentage);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, longPressChapterSkip);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();
@@ -92,7 +125,7 @@ bool CrossPointSettings::loadFromFile() {
float CrossPointSettings::getReaderLineCompression() const {
switch (fontFamily) {
case ALEO:
case BOOKERLY:
default:
switch (lineSpacing) {
case TIGHT:
@@ -126,20 +159,52 @@ float CrossPointSettings::getReaderLineCompression() const {
}
}
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
switch (sleepTimeout) {
case SLEEP_1_MIN:
return 1UL * 60 * 1000;
case SLEEP_5_MIN:
return 5UL * 60 * 1000;
case SLEEP_10_MIN:
default:
return 10UL * 60 * 1000;
case SLEEP_15_MIN:
return 15UL * 60 * 1000;
case SLEEP_30_MIN:
return 30UL * 60 * 1000;
}
}
int CrossPointSettings::getRefreshFrequency() const {
switch (refreshFrequency) {
case REFRESH_1:
return 1;
case REFRESH_5:
return 5;
case REFRESH_10:
return 10;
case REFRESH_15:
default:
return 15;
case REFRESH_30:
return 30;
}
}
int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) {
case ALEO:
case BOOKERLY:
default:
switch (fontSize) {
case SMALL:
return ALEO_12_FONT_ID;
return BOOKERLY_12_FONT_ID;
case MEDIUM:
default:
return ALEO_14_FONT_ID;
return BOOKERLY_14_FONT_ID;
case LARGE:
return ALEO_16_FONT_ID;
return BOOKERLY_16_FONT_ID;
case EXTRA_LARGE:
return ALEO_18_FONT_ID;
return BOOKERLY_18_FONT_ID;
}
case NOTOSANS:
switch (fontSize) {
+41 -7
View File
@@ -16,7 +16,8 @@ class CrossPointSettings {
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
// Should match with SettingsActivity text
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 };
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 };
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 };
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 };
@@ -31,7 +32,7 @@ class CrossPointSettings {
// Front button layout options
// Default: Back, Confirm, Left, Right
// Swapped: Left, Right, Back, Confirm
enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1 };
enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2 };
// Side button layout options
// Default: Previous, Next
@@ -39,19 +40,35 @@ class CrossPointSettings {
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1 };
// Font family options
enum FONT_FAMILY { ALEO = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 };
enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 };
// Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
// Auto-sleep timeout options (in minutes)
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
// E-ink refresh frequency (pages between full refreshes)
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 };
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 };
// Sleep screen settings
uint8_t sleepScreen = DARK;
// Sleep screen cover mode settings
uint8_t sleepScreenCoverMode = FIT;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
// Duration of the power button press
uint8_t shortPwrBtn = 0;
uint8_t textAntiAliasing = 1;
// Short power button click behaviour
uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT;
@@ -59,22 +76,39 @@ class CrossPointSettings {
uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT;
uint8_t sideButtonLayout = PREV_NEXT;
// Reader font settings
uint8_t fontFamily = ALEO;
uint8_t fontFamily = BOOKERLY;
uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL;
uint8_t paragraphAlignment = JUSTIFIED;
// Auto-sleep timeout setting (default 10 minutes)
uint8_t sleepTimeout = SLEEP_10_MIN;
// E-ink refresh frequency (default 15 pages)
uint8_t refreshFrequency = REFRESH_15;
// Reader screen margin settings
uint8_t screenMargin = 5;
// OPDS browser settings
char opdsServerUrl[128] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1;
~CrossPointSettings() = default;
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; }
uint16_t getPowerButtonDuration() const {
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
}
int getReaderFontId() const;
bool saveToFile() const;
bool loadFromFile();
float getReaderLineCompression() const;
unsigned long getSleepTimeoutMs() const;
int getRefreshFrequency() const;
};
// Helper macro to access settings
+8 -2
View File
@@ -5,7 +5,7 @@
#include <Serialization.h>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr uint8_t STATE_FILE_VERSION = 2;
constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
} // namespace
@@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const {
serialization::writePod(outputFile, STATE_FILE_VERSION);
serialization::writeString(outputFile, openEpubPath);
serialization::writePod(outputFile, lastSleepImage);
outputFile.close();
return true;
}
@@ -31,13 +32,18 @@ bool CrossPointState::loadFromFile() {
uint8_t version;
serialization::readPod(inputFile, version);
if (version != STATE_FILE_VERSION) {
if (version > STATE_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close();
return false;
}
serialization::readString(inputFile, openEpubPath);
if (version >= 2) {
serialization::readPod(inputFile, lastSleepImage);
} else {
lastSleepImage = 0;
}
inputFile.close();
return true;
+1
View File
@@ -8,6 +8,7 @@ class CrossPointState {
public:
std::string openEpubPath;
uint8_t lastSleepImage;
~CrossPointState() = default;
// Get singleton instance
+8
View File
@@ -11,6 +11,8 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_BACK;
@@ -19,6 +21,8 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_CONFIRM;
@@ -26,6 +30,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
case Button::Left:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
@@ -36,6 +41,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
default:
return InputManager::BTN_RIGHT;
}
@@ -85,6 +91,8 @@ MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return {previous, next, back, confirm};
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return {previous, back, confirm, next};
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return {back, confirm, previous, next};
+37 -12
View File
@@ -2,34 +2,36 @@
#include <GfxRenderer.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) {
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
const bool showPercentage) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int batteryHeight = 12;
const int x = left;
const int y = top + 7;
const int y = top + 6;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
renderer.drawPixel(x + batteryWidth - 1, y + 3);
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
@@ -37,5 +39,28 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
}
void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width,
const int height, const size_t current, const size_t total) {
if (total == 0) {
return;
}
// Use 64-bit arithmetic to avoid overflow for large files
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
// Draw outline
renderer.drawRect(x, y, width, height);
// Draw filled portion
const int fillWidth = (width - 4) * percent / 100;
if (fillWidth > 0) {
renderer.fillRect(x + 2, y + 2, fillWidth, height - 4);
}
// Draw percentage text centered below bar
const std::string percentText = std::to_string(percent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str());
}

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